feat(course): audit fixes and twelve extensions (Modules 7b, 12, 13)
Audit pass: - Frame rule counts frames since the last move, so every step lasts exactly movesEvery(score) frames, also right after a speed-up - clampTime + MAX_GAP_MS: a hidden tab no longer replays the whole gap - Pong Restart keeps held keys - Fact corrections (mulberry32 output coverage, Haskell IO, uniform) - New fixture cases from a mutation analysis Extensions (all three tracks, shared fixtures, course text): - Turn queue (Exercise 10.6), persisted high score (10.7), replay viewer (10.8), play() end-to-end helper (9.5) - Optional State (4.7) and optional Module 7b, stack-safe IO - Module 12, property-based testing with generators, shrinking, invariants and a model-based oracle - Module 13, Conway's Life with a Click event - JS type checking via jsconfig, JSDoc and types.d.ts - Maintainer tools: scripts/mutate.mjs, scripts/typecheck-js.sh, Playwright end-to-end suite (e2e/, scripts/e2e.sh) Docs: docs/contract.md is the canonical contract; specs and build log under docs/superpowers. Verified: JS 728, Scala 689, F# 691 unit tests; stubs and progression M01-M13 and M07b; 176 end-to-end tests on Chromium and WebKit. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
876f5aabb1
commit
edd57f0148
253 files changed
+21753
-503
No files matched your search
@@ -55,8 +55,9 @@ Here is the model the course uses. (Now you may open your stub file: in Scala an
|
||||
/** @typedef {'Up'|'Down'|'Left'|'Right'} Direction */
|
||||
/** @typedef {'Playing'|'Paused'|'GameOver'|'Won'} Status */
|
||||
/** @typedef {{width: number, height: number, snake: Point[], dir: Direction, nextDir: Direction,
|
||||
* apple: Point|null, seed: number, score: number, status: Status, frame: number}} GameState */
|
||||
// Action: {tag:'Tick'} | {tag:'Turn', dir} | {tag:'TogglePause'} | {tag:'Restart'} | {tag:'Frame'}
|
||||
* apple: Point|null, seed: number, score: number, status: Status, frame: number,
|
||||
* queued: Direction|null}} GameState */
|
||||
/** @typedef {{tag: 'Tick'} | {tag: 'Turn', dir: Direction} | {tag: 'TogglePause'} | {tag: 'Restart'} | {tag: 'Frame'}} Action */
|
||||
```
|
||||
|
||||
**Scala**
|
||||
@@ -78,7 +79,8 @@ final case class GameState(
|
||||
dir: Direction, nextDir: Direction,
|
||||
apple: Option[Point],
|
||||
seed: Long, // unsigned 32-bit, 0 until 2^32
|
||||
score: Int, status: Status, frame: Int)
|
||||
score: Int, status: Status, frame: Int,
|
||||
queued: Option[Direction])
|
||||
```
|
||||
|
||||
**F#**
|
||||
@@ -98,10 +100,11 @@ type GameState =
|
||||
Dir: Direction; NextDir: Direction
|
||||
Apple: Point option
|
||||
Seed: uint32
|
||||
Score: int; Status: Status; Frame: int }
|
||||
Score: int; Status: Status; Frame: int
|
||||
Queued: Direction option }
|
||||
```
|
||||
|
||||
A fresh game is 20 x 20, the snake is `(10,10), (9,10), (8,10)` heading `Right` (both `dir` and `nextDir`), there is no apple yet, the seed is whatever you were given, the score is 0, the status `Playing`, and `frame` is 0. The `y` axis grows **downward**, as on a screen: `Up` is `{x: 0, y: -1}`.
|
||||
A fresh game is 20 x 20, the snake is `(10,10), (9,10), (8,10)` heading `Right` (both `dir` and `nextDir`), there is no apple yet, the seed is whatever you were given, the score is 0, the status `Playing`, `frame` is 0 and nothing is queued (`queued` is none). The `y` axis grows **downward**, as on a screen: `Up` is `{x: 0, y: -1}`.
|
||||
|
||||
Now compare with your design, question by question.
|
||||
|
||||
@@ -123,7 +126,7 @@ Now compare with your design, question by question.
|
||||
|
||||
**8. Events as data.** Everything that can happen to the game is a value of type `Action`: `Tick` (time passes one step), `Turn(dir)` (the player asks to turn), `TogglePause`, `Restart`, and `Frame` (a finer-grained clock you will use in Module 10 for speed-ups). Module 5 shows why this is powerful: a whole game becomes "a start state plus a list of actions".
|
||||
|
||||
There is one more field, `frame`. It counts small clock frames so that the snake can speed up later without changing the clock (Module 10). Until then it stays 0.
|
||||
There are two more fields, both dormant until Module 10. `frame` counts small clock frames since the snake last moved, so that the snake can speed up later without changing the clock; until then it stays 0. `queued` remembers one key press that came too early to be used yet, so that a quick Up-then-Left is not lost (Module 10, Exercise 10.6); until then it stays none. They are in the model from the start so that every fixture, from Module 2 to Module 12, has the same shape: a fixture state always carries `"frame"` and `"queued": null`, even in modules that never look at them.
|
||||
|
||||
Did your design differ? Common alternatives and their costs:
|
||||
|
||||
@@ -204,7 +207,24 @@ The `match` in Scala and F# is called **pattern matching**: it takes a sum apart
|
||||
|
||||
Note the JS lookup-table trick in `nextLight`: an object literal used as a function from a fixed set of strings to values. It is often the clearest way to write a function over a nullary sum type.
|
||||
|
||||
The important difference between the languages is **exhaustiveness checking**. If you add `case Triangle` to Scala's `Shape` and forget to handle it in `area`, the compiler warns you. F# does the same. JavaScript will silently return `undefined`. In JS you must be your own exhaustiveness checker; the fixtures, which cover every case, help.
|
||||
The important difference between the languages is **exhaustiveness checking**. If you add `case Triangle` to Scala's `Shape` and forget to handle it in `area`, the compiler warns you. F# does the same. JavaScript itself will silently return `undefined`. Node never checks; the fixtures, which cover every case, are what catches the mistake when the tests run.
|
||||
|
||||
If your editor understands types (Module 0, "If your editor understands types"), you can get the warning in JavaScript too, before running anything. The checker already knows that `Action` is a union of five tagged objects, and inside `case 'Turn':` it knows that `action` has a `dir`. To make it complain about a *forgotten* case, end the `switch` with a `default` that claims nothing is left:
|
||||
|
||||
```js
|
||||
switch (action.tag) {
|
||||
case 'Tick': return ...
|
||||
case 'Turn': return ...
|
||||
// ... the other cases ...
|
||||
default: {
|
||||
/** @type {never} */
|
||||
const unhandled = action // red if some case is missing: "Type '{ tag: "Frame"; }' is not assignable to type 'never'"
|
||||
return state
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`never` is the type with no values. After every handled `case` the checker removes that alternative from the type of `action`; if all five were handled, nothing is left and `action` has type `never` in the `default`, so the assignment is fine. If you forgot `Frame`, `action` can still be `{tag: 'Frame'}` there, and the checker names the missing case. So the precise statement is: in JavaScript the language does not check exhaustiveness, but a type checker reading your comments can, if you ask it to in this way. The stubs do not contain this `default`; add it yourself when you want the check.
|
||||
|
||||
### Making illegal states unrepresentable
|
||||
|
||||
@@ -330,7 +350,7 @@ def initialState(seed: Long): GameState
|
||||
let initialState (seed: uint32) : GameState
|
||||
```
|
||||
|
||||
A 20 x 20 board; snake `(10,10), (9,10), (8,10)`; `dir` and `nextDir` both `Right`; no apple; the given seed, stored as given; score 0; status `Playing`; frame 0.
|
||||
A 20 x 20 board; snake `(10,10), (9,10), (8,10)`; `dir` and `nextDir` both `Right`; no apple; the given seed, stored as given; score 0; status `Playing`; frame 0; nothing queued.
|
||||
|
||||
Fixture: `fixtures/m02-initial-state.json` (cases `seed-7` and `seed-is-stored-as-given`, which uses the largest 32-bit seed, 4294967295). Watch out: the order of the snake's points matters (head first), and in JS use your `point` function so that every point has the same shape.
|
||||
|
||||
@@ -433,7 +453,7 @@ This is a single record literal. Read the fixture `m02-initial-state.json`: the
|
||||
|
||||
<details><summary>Exercise 2.5 (initialState) — stronger hint</summary>
|
||||
|
||||
Ten fields: `width`, `height`, `snake`, `dir`, `nextDir`, `apple`, `seed`, `score`, `status`, `frame`. In Scala/F#, "no apple" is `None`; in JS it is `null`. In Scala the enum values are `Direction.Right` and `Status.Playing`.
|
||||
Eleven fields: `width`, `height`, `snake`, `dir`, `nextDir`, `apple`, `seed`, `score`, `status`, `frame`, `queued`. In Scala/F#, "no apple" and "nothing queued" are `None`; in JS they are `null`. In Scala the enum values are `Direction.Right` and `Status.Playing`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -444,19 +464,19 @@ export const initialState = (seed) => ({
|
||||
width: 20, height: 20,
|
||||
snake: [point(10, 10), ___, ___],
|
||||
dir: ___, nextDir: ___,
|
||||
apple: ___, seed, score: ___, status: ___, frame: ___,
|
||||
apple: ___, seed, score: ___, status: ___, frame: ___, queued: ___,
|
||||
})
|
||||
```
|
||||
```scala
|
||||
def initialState(seed: Long): GameState =
|
||||
GameState(20, 20, List(Point(10, 10), ___, ___), Direction.Right, ___, ___, seed, ___, ___, ___)
|
||||
GameState(20, 20, List(Point(10, 10), ___, ___), Direction.Right, ___, ___, seed, ___, ___, ___, ___)
|
||||
```
|
||||
```fsharp
|
||||
let initialState (seed: uint32) : GameState =
|
||||
{ Width = 20; Height = 20
|
||||
Snake = [ { X = 10; Y = 10 }; ___; ___ ]
|
||||
Dir = Right; NextDir = ___
|
||||
Apple = ___; Seed = seed; Score = ___; Status = ___; Frame = ___ }
|
||||
Apple = ___; Seed = seed; Score = ___; Status = ___; Frame = ___; Queued = ___ }
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -475,7 +495,7 @@ let initialState (seed: uint32) : GameState =
|
||||
## In production
|
||||
|
||||
- **Elm** models every application as a single immutable `Model` record plus a `Msg` custom type for everything that can happen, exactly like `GameState` and `Action`. See [custom types in the Elm guide](https://guide.elm-lang.org/types/custom_types.html).
|
||||
- **TypeScript** checks JavaScript tagged unions at compile time: a `switch` on `tag` can be made exhaustive. See [discriminated unions](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions). (We use plain JS in this course, so you are the type checker.)
|
||||
- **TypeScript** checks JavaScript tagged unions at compile time: a `switch` on `tag` can be made exhaustive. See [discriminated unions](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions). (The course stays in plain JS; its optional `@type` comments give you the same check in an editor, see Module 0.)
|
||||
- **Redux** actions are tagged objects (`{ type: 'todos/added', payload }`), the same idea as our `Action`.
|
||||
- Domain modelling with types is a whole discipline in the F# and Scala communities; see Scott Wlaschin's work below.
|
||||
|
||||
|
||||
Reference in new issue
Block a user