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:
Lucas WintherandClaude Fable 5.2 committed 2026-11-11 12:05:29 +01:00
1 parent 876f5aabb1
commit edd57f0148
253 files changed
+21753 -503

No files matched your search

+33 -13
View File
@@ -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.