feat(js): m10 extensions (pause, restart, speed-up via Frame) with tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Sonnet 5.5 committed 2026-10-28 23:56:49 +01:00
1 parent 25141ce86d
commit fd7c5f2acf
13 files changed
+1347 -17

No files matched your search

+4 -4
View File
@@ -143,16 +143,16 @@
"expected": null "expected": null
}, },
{ {
"name": "space-is-ignored (until m10)", "name": "constructor-is-not-a-key",
"args": [ "args": [
" " "constructor"
], ],
"expected": null "expected": null
}, },
{ {
"name": "r-is-ignored (until m10)", "name": "toString-is-not-a-key",
"args": [ "args": [
"r" "toString"
], ],
"expected": null "expected": null
} }
+66
View File
@@ -0,0 +1,66 @@
{
"function": "keyToAction",
"cases": [
{
"name": "space-toggles-pause",
"args": [
" "
],
"expected": {
"tag": "TogglePause"
}
},
{
"name": "r-restarts",
"args": [
"r"
],
"expected": {
"tag": "Restart"
}
},
{
"name": "R-restarts",
"args": [
"R"
],
"expected": {
"tag": "Restart"
}
},
{
"name": "ArrowUp-still-turns-up",
"args": [
"ArrowUp"
],
"expected": {
"tag": "Turn",
"dir": "Up"
}
},
{
"name": "d-still-turns-right",
"args": [
"d"
],
"expected": {
"tag": "Turn",
"dir": "Right"
}
},
{
"name": "Enter-is-not-bound",
"args": [
"Enter"
],
"expected": null
},
{
"name": "empty-string-is-not-bound",
"args": [
""
],
"expected": null
}
]
}
+47
View File
@@ -0,0 +1,47 @@
{
"function": "movesEvery",
"cases": [
{
"name": "score-0",
"args": [
0
],
"expected": 8
},
{
"name": "score-2",
"args": [
2
],
"expected": 8
},
{
"name": "score-3",
"args": [
3
],
"expected": 7
},
{
"name": "score-17",
"args": [
17
],
"expected": 3
},
{
"name": "score-18",
"args": [
18
],
"expected": 2
},
{
"name": "score-100",
"args": [
100
],
"expected": 2
}
]
}
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -7,12 +7,18 @@ import { keyDowns, interval, map, filterMap, merge, scan, startWith } from './st
import { keyToAction } from './input.js' import { keyToAction } from './input.js'
export const TICK_MS = 150 export const TICK_MS = 150
export const FRAME_MS = 25
// Everything that can happen to the game: a Tick every TICK_MS, and a Turn for each steering key. // Everything that can happen to the game: a Tick every TICK_MS, and a Turn for each steering key.
export const classicActions = merge( export const classicActions = merge(
map(interval(TICK_MS), () => Action.Tick), map(interval(TICK_MS), () => Action.Tick),
filterMap(keyDowns, keyToAction)) filterMap(keyDowns, keyToAction))
// m10: a Frame every FRAME_MS instead of a Tick; the game itself decides (movesEvery) which frames take a step.
export const speedActions = merge(
map(interval(FRAME_MS), () => Action.Frame),
filterMap(keyDowns, keyToAction))
// Actions folded into game states. startWith puts the initial state first so it is drawn right away. // Actions folded into game states. startWith puts the initial state first so it is drawn right away.
export const states = (seed, actions) => export const states = (seed, actions) =>
startWith(scan(actions, update, newGame(seed)), newGame(seed)) startWith(scan(actions, update, newGame(seed)), newGame(seed))
@@ -21,4 +27,4 @@ export const render = (state) => drawScene(view(state))
// The whole program as a value: get a seed, then turn the stream of states into a stream of drawings. // The whole program as a value: get a seed, then turn the stream of states into a stream of drawings.
// Nothing runs here; runtime.js interprets it. // Nothing runs here; runtime.js interprets it.
export const main = mapIO(randomSeed, (seed) => map(states(seed, classicActions), render)) export const main = mapIO(randomSeed, (seed) => map(states(seed, speedActions), render))
+21 -2
View File
@@ -1,12 +1,31 @@
// m05 game: one `update` function turns every action into a new state; replay is a fold over actions. // m05 game: one `update` function turns every action into a new state; replay is a fold over actions.
// m10 adds pause, restart and a frame clock that speeds up as the score grows.
import { turn } from './rules.js' import { turn } from './rules.js'
import { tick } from './spawn.js' import { tick, newGame } from './spawn.js'
/** How many frames pass between game steps: 8 at first, one fewer every 3 points, never fewer than 2. */
export const movesEvery = (score) => Math.max(2, 8 - Math.floor(score / 3))
// One animation frame: count it, and every `movesEvery(score)`-th frame run a game step.
const frame = (state) => {
if (state.status !== 'Playing') return state
const counted = { ...state, frame: state.frame + 1 }
return counted.frame % movesEvery(counted.score) === 0 ? tick(counted) : counted
}
// Pause and resume only make sense in those two statuses; a finished game stays finished.
const togglePause = (state) =>
state.status === 'Playing' ? { ...state, status: 'Paused' }
: state.status === 'Paused' ? { ...state, status: 'Playing' }
: state
/** Tick and Turn are handled here; other actions leave the state alone (m10 adds more). */
export const update = (state, action) => { export const update = (state, action) => {
switch (action.tag) { switch (action.tag) {
case 'Tick': return tick(state) case 'Tick': return tick(state)
case 'Turn': return turn(state, action.dir) case 'Turn': return turn(state, action.dir)
case 'TogglePause': return togglePause(state)
case 'Restart': return newGame(state.seed)
case 'Frame': return frame(state)
default: return state default: return state
} }
} }
+7 -1
View File
@@ -8,5 +8,11 @@ const directions = {
ArrowRight: 'Right', d: 'Right', D: 'Right', ArrowRight: 'Right', d: 'Right', D: 'Right',
} }
// Keys that are not steering (m10).
const commands = { ' ': Action.TogglePause, r: Action.Restart, R: Action.Restart }
// hasOwn, not `key in table`: "constructor" and "toString" are inherited properties, not keys we mapped.
export const keyToAction = (key) => export const keyToAction = (key) =>
Object.hasOwn(directions, key) ? Action.Turn(directions[key]) : null Object.hasOwn(directions, key) ? Action.Turn(directions[key])
: Object.hasOwn(commands, key) ? commands[key]
: null
+11 -1
View File
@@ -4,12 +4,21 @@ import { pure } from './io.js'
/** Milliseconds between game steps. */ /** Milliseconds between game steps. */
export const TICK_MS = 150 export const TICK_MS = 150
/** Milliseconds between animation frames (m10). */
export const FRAME_MS = 25
/** /**
* Stream of everything that can happen to the game: `Action.Tick` every TICK_MS (from `interval`), * Stream of everything that can happen to the game: `Action.Tick` every TICK_MS (from `interval`),
* merged with the `Action` for each key press that `keyToAction` recognises (from `keyDowns`). * merged with the `Action` for each key press that `keyToAction` recognises (from `keyDowns`).
*/ */
export const classicActions = { step: () => todo('m09') } export const classicActions = { step: () => todo('m09') }
/**
* (m10) Like `classicActions`, but the clock emits `Action.Frame` every FRAME_MS instead of `Tick` every TICK_MS;
* key presses are translated by `keyToAction` as before (now including pause and restart).
*/
export const speedActions = { step: () => todo('m10') }
/** /**
* Fold a stream of actions into a stream of game states: start from `newGame(seed)`, apply `update` to each action * Fold a stream of actions into a stream of game states: start from `newGame(seed)`, apply `update` to each action
* (scan), and emit the initial state first (startWith) so there is something to draw before anything happens. * (scan), and emit the initial state first (startWith) so there is something to draw before anything happens.
@@ -21,6 +30,7 @@ export const render = (state) => todo('m09')
/** /**
* The whole program as a value, `IO<Stream<IO<unit>>>`: ask for a random seed, then turn the stream of * The whole program as a value, `IO<Stream<IO<unit>>>`: ask for a random seed, then turn the stream of
* states (built from `classicActions`) into a stream of drawings (`render`). Nothing runs here. * states (built from `classicActions` in m09, from `speedActions` after m10) into a stream of drawings (`render`).
* Nothing runs here.
*/ */
export const main = pure({ step: () => todo('m09') }) export const main = pure({ step: () => todo('m09') })
+14 -2
View File
@@ -1,9 +1,21 @@
import { todo } from './todo.js' import { todo } from './todo.js'
/**
* How many frames pass between game steps (m10): `max(2, 8 - floor(score / 3))`.
* Score 0-2 -> 8, 3-5 -> 7, ..., 18 and up -> 2.
*/
export const movesEvery = (score) => todo('m10')
/** /**
* The one function that says how the game changes: `(state, action) -> state`. * The one function that says how the game changes: `(state, action) -> state`.
* `Tick` runs a game step (`tick`), `Turn` asks to change direction (`turn`); *
* every other action returns the state unchanged for now. * m05: `Tick` runs a game step (`tick`), `Turn` asks to change direction (`turn`);
* every other action returns the state unchanged.
*
* (m10) `TogglePause` switches Playing <-> Paused and does nothing in any other status.
* (m10) `Restart` gives `newGame(state.seed)`, whatever the status.
* (m10) `Frame` does nothing unless Playing; otherwise it adds 1 to `frame`, and when the new `frame` is a multiple of
* `movesEvery(score)` it also runs a game step (`tick`) on that incremented state.
*/ */
export const update = (state, action) => todo('m05') export const update = (state, action) => todo('m05')
+2 -1
View File
@@ -2,7 +2,8 @@ import { todo } from './todo.js'
/** /**
* Translate a keyboard key name (as in `KeyboardEvent.key`) into the game action it triggers, or `null`. * Translate a keyboard key name (as in `KeyboardEvent.key`) into the game action it triggers, or `null`.
* `ArrowUp`/`w`/`W` -> `Action.Turn('Up')`, `ArrowDown`/`s`/`S` -> Down, `ArrowLeft`/`a`/`A` -> Left, * m09: `ArrowUp`/`w`/`W` -> `Action.Turn('Up')`, `ArrowDown`/`s`/`S` -> Down, `ArrowLeft`/`a`/`A` -> Left,
* `ArrowRight`/`d`/`D` -> Right. Any other key -> `null`. * `ArrowRight`/`d`/`D` -> Right. Any other key -> `null`.
* (m10) `' '` (Space) -> `Action.TogglePause`, `r`/`R` -> `Action.Restart`.
*/ */
export const keyToAction = (key) => todo('m09') export const keyToAction = (key) => todo('m09')
+6 -4
View File
@@ -1,8 +1,8 @@
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { states, classicActions, render, main, TICK_MS } from '../src/app.js' import { states, classicActions, render, main, TICK_MS } from '../src/app.js'
import { run } from '../src/stream.js' import { run, map as mapStream } from '../src/stream.js'
import { trace } from '../src/io.js' import { trace, map as mapIO, randomSeed } from '../src/io.js'
import { view } from '../src/view.js' import { view } from '../src/view.js'
import { newGame } from '../src/spawn.js' import { newGame } from '../src/spawn.js'
import { Action } from '../src/model.js' import { Action } from '../src/model.js'
@@ -40,8 +40,10 @@ test('m09 main asks for a random seed, then builds the stream from it', () => {
assert.deepStrictEqual(trace(outs[0], 0).ops, trace(render(newGame(42)), 0).ops) assert.deepStrictEqual(trace(outs[0], 0).ops, trace(render(newGame(42)), 0).ops)
}) })
test('m09 main with seed 42 draws the new game first, then the ticked game after 150 ms', () => { // `main` itself switches to speedActions in m10, so the 150 ms behaviour is checked on the same program built from classicActions.
const { result: stream } = trace(main, 42) test('m09 the program built from classicActions draws the new game first, then the ticked game after 150 ms', () => {
const classicMain = mapIO(randomSeed, (seed) => mapStream(states(seed, classicActions), render))
const { result: stream } = trace(classicMain, 42)
const [first, next] = stream.step(time(0)) const [first, next] = stream.step(time(0))
const [second] = next.step(time(150)) const [second] = next.step(time(150))
assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), view(newGame(42))) assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), view(newGame(42)))
+5 -1
View File
@@ -2,7 +2,8 @@ import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { runIO, runApp } from '../src/runtime.js' import { runIO, runApp } from '../src/runtime.js'
import { pure, draw, randomSeed, map, sequence } from '../src/io.js' import { pure, draw, randomSeed, map, sequence } from '../src/io.js'
import { main } from '../src/app.js' import { states, classicActions, render } from '../src/app.js'
import { map as mapStream } from '../src/stream.js'
import { view } from '../src/view.js' import { view } from '../src/view.js'
import { newGame, tick } from '../src/spawn.js' import { newGame, tick } from '../src/spawn.js'
@@ -24,6 +25,9 @@ const fakePlatform = (seed = 42) => {
} }
} }
// The m09 program: ticks every 150 ms. (The shipped `main` uses speedActions after m10, so these tests build their own.)
const main = map(randomSeed, (seed) => mapStream(states(seed, classicActions), render))
// What the platform should have been asked to draw for a scene. // What the platform should have been asked to draw for a scene.
const expectedCalls = (scene) => const expectedCalls = (scene) =>
scene.map((s) => scene.map((s) =>
+51
View File
@@ -0,0 +1,51 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { update, movesEvery } from '../src/game.js'
import { keyToAction } from '../src/input.js'
import { speedActions, main, FRAME_MS } from '../src/app.js'
import { run } from '../src/stream.js'
import { trace } from '../src/io.js'
import { view } from '../src/view.js'
import { newGame } from '../src/spawn.js'
import { Action } from '../src/model.js'
import { loadFixture, freeze } from './helpers.js'
const fixtureTests = (name, f) => {
const fx = loadFixture(name)
fx.cases.forEach((c) =>
test(`m10 ${fx.function}: ${c.name}`, () => assert.deepStrictEqual(f(...freeze(c.args)), c.expected)))
}
fixtureTests('m10-update', update)
fixtureTests('m10-moves-every', movesEvery)
fixtureTests('m10-input', keyToAction)
const time = (t) => ({ tag: 'Time', t })
test('m10 the animation clock runs every 25 ms', () => assert.equal(FRAME_MS, 25))
test('m10 speedActions turns the clock into Frame actions every 25 ms', () =>
assert.deepStrictEqual(
run(speedActions, freeze([25, 50, 75, 100, 125, 150, 175, 200].map(time))),
Array.from({ length: 8 }, () => Action.Frame)))
test('m10 speedActions still turns keys into actions, including pause and restart', () =>
assert.deepStrictEqual(
run(speedActions, freeze([{ tag: 'KeyDown', key: ' ' }, { tag: 'KeyDown', key: 'r' }, { tag: 'KeyDown', key: 'ArrowUp' }])),
[Action.TogglePause, Action.Restart, Action.Turn('Up')]))
test('m10 main (seed 42) draws a moved snake after eight 25 ms frames', () => {
const { ops, result: stream } = trace(main, 42)
assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }])
const [first, afterFirst] = stream.step(time(0))
assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), view(newGame(42)))
const [drawings] = [25, 50, 75, 100, 125, 150, 175, 200].reduce(
([outs, s], t) => {
const [out, next] = s.step(time(t))
return [[...outs, ...out], next]
},
[[], afterFirst])
assert.equal(drawings.length, 8)
const moved = { ...newGame(42), snake: newGame(42).snake.map((p) => ({ x: p.x + 1, y: p.y })), frame: 8 }
assert.deepStrictEqual(trace(drawings[7], 0).ops.map((o) => o.shape), view(moved))
})