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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-28 23:56:49 +01:00
co-authored by Claude Sonnet 5.5
parent 25141ce86d
commit fd7c5f2acf
13 changed files with 1347 additions and 17 deletions
+4 -4
View File
@@ -143,16 +143,16 @@
"expected": null
},
{
"name": "space-is-ignored (until m10)",
"name": "constructor-is-not-a-key",
"args": [
" "
"constructor"
],
"expected": null
},
{
"name": "r-is-ignored (until m10)",
"name": "toString-is-not-a-key",
"args": [
"r"
"toString"
],
"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'
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.
export const classicActions = merge(
map(interval(TICK_MS), () => Action.Tick),
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.
export const states = (seed, actions) =>
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.
// 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.
// m10 adds pause, restart and a frame clock that speeds up as the score grows.
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) => {
switch (action.tag) {
case 'Tick': return tick(state)
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
}
}
+7 -1
View File
@@ -8,5 +8,11 @@ const directions = {
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) =>
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. */
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`),
* merged with the `Action` for each key press that `keyToAction` recognises (from `keyDowns`).
*/
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
* (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
* 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') })
+14 -2
View File
@@ -1,9 +1,21 @@
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`.
* `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')
+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`.
* `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`.
* (m10) `' '` (Space) -> `Action.TogglePause`, `r`/`R` -> `Action.Restart`.
*/
export const keyToAction = (key) => todo('m09')
+6 -4
View File
@@ -1,8 +1,8 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { states, classicActions, render, main, TICK_MS } from '../src/app.js'
import { run } from '../src/stream.js'
import { trace } from '../src/io.js'
import { run, map as mapStream } from '../src/stream.js'
import { trace, map as mapIO, randomSeed } from '../src/io.js'
import { view } from '../src/view.js'
import { newGame } from '../src/spawn.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)
})
test('m09 main with seed 42 draws the new game first, then the ticked game after 150 ms', () => {
const { result: stream } = trace(main, 42)
// `main` itself switches to speedActions in m10, so the 150 ms behaviour is checked on the same program built from classicActions.
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 [second] = next.step(time(150))
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 { runIO, runApp } from '../src/runtime.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 { 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.
const expectedCalls = (scene) =>
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))
})