diff --git a/fixtures/m11-pong-key.json b/fixtures/m11-pong-key.json index df9e7e2..c429aa2 100644 --- a/fixtures/m11-pong-key.json +++ b/fixtures/m11-pong-key.json @@ -146,6 +146,68 @@ "toString" ], "expected": null + }, + { + "name": "W-down-presses-up", + "args": [ + true, + "W" + ], + "expected": { + "tag": "Press", + "key": "Up" + } + }, + { + "name": "W-up-releases-up", + "args": [ + false, + "W" + ], + "expected": { + "tag": "Release", + "key": "Up" + } + }, + { + "name": "S-down-presses-down", + "args": [ + true, + "S" + ], + "expected": { + "tag": "Press", + "key": "Down" + } + }, + { + "name": "S-up-releases-down", + "args": [ + false, + "S" + ], + "expected": { + "tag": "Release", + "key": "Down" + } + }, + { + "name": "R-down-restarts", + "args": [ + true, + "R" + ], + "expected": { + "tag": "Restart" + } + }, + { + "name": "R-up-does-nothing", + "args": [ + false, + "R" + ], + "expected": null } ] } diff --git a/fixtures/m11-pong-update.json b/fixtures/m11-pong-update.json index 89bb71c..b057fb7 100644 --- a/fixtures/m11-pong-update.json +++ b/fixtures/m11-pong-update.json @@ -2221,6 +2221,116 @@ "seed": 1831565813, "frame": 0 } + }, + { + "name": "press-of-an-unknown-key-changes-nothing", + "args": [ + { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Press", + "key": "constructor" + } + ], + "expected": { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + } + }, + { + "name": "release-of-an-unknown-key-changes-nothing", + "args": [ + { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + }, + { + "tag": "Release", + "key": "constructor" + } + ], + "expected": { + "ball": { + "x": 40, + "y": 25 + }, + "vel": { + "dx": 1, + "dy": 1 + }, + "player": 21, + "cpu": 21, + "held": { + "up": false, + "down": false + }, + "score": { + "player": 0, + "cpu": 0 + }, + "status": "Playing", + "seed": 0, + "frame": 0 + } } ] } diff --git a/js/solutions/pong/app.js b/js/solutions/pong/app.js index 0415419..c5bfeff 100644 --- a/js/solutions/pong/app.js +++ b/js/solutions/pong/app.js @@ -8,14 +8,14 @@ import { keyDowns, keyUps, interval, map, filterMap, merge, scan, startWith } fr export const FRAME_MS_PONG = 30 const keys = { - ArrowUp: 'Up', w: 'Up', - ArrowDown: 'Down', s: 'Down', + ArrowUp: 'Up', w: 'Up', W: 'Up', + ArrowDown: 'Down', s: 'Down', S: 'Down', } // hasOwn, not `key in keys`: "constructor" and "toString" are inherited, not keys we mapped. export const pongKey = (isDown, key) => Object.hasOwn(keys, key) ? (isDown ? PongAction.Press : PongAction.Release)(keys[key]) - : key === 'r' && isDown ? PongAction.Restart + : (key === 'r' || key === 'R') && isDown ? PongAction.Restart : null export const pongActions = merge( diff --git a/js/solutions/pong/rules.js b/js/solutions/pong/rules.js index 25d7117..04ff925 100644 --- a/js/solutions/pong/rules.js +++ b/js/solutions/pong/rules.js @@ -56,10 +56,16 @@ const frame = (state) => { return scorer === null ? next : serve(next, scorer) } +// Only the keys the model knows ('Up', 'Down') are held; anything else leaves the state alone. +const heldFlag = { Up: 'up', Down: 'down' } + +const setHeld = (state, key, value) => + Object.hasOwn(heldFlag, key) ? { ...state, held: { ...state.held, [heldFlag[key]]: value } } : state + export const pongUpdate = (state, action) => { switch (action.tag) { - case 'Press': return { ...state, held: { ...state.held, [action.key.toLowerCase()]: true } } - case 'Release': return { ...state, held: { ...state.held, [action.key.toLowerCase()]: false } } + case 'Press': return setHeld(state, action.key, true) + case 'Release': return setHeld(state, action.key, false) case 'Restart': return initialPong(state.seed) case 'Frame': return frame(state) default: return state diff --git a/js/src/pong/app.js b/js/src/pong/app.js index dd3e177..2a7e120 100644 --- a/js/src/pong/app.js +++ b/js/src/pong/app.js @@ -6,8 +6,8 @@ export const FRAME_MS_PONG = 30 /** * Translate a key event into a `PongAction`, or `null`. `isDown` is true for a key press, false for a release. - * `ArrowUp`/`w` -> `Press('Up')` / `Release('Up')`, `ArrowDown`/`s` -> `Press('Down')` / `Release('Down')`, - * `r` -> `Restart` (on press only; releasing `r` does nothing). Any other key -> `null`. + * `ArrowUp`/`w`/`W` -> `Press('Up')` / `Release('Up')`, `ArrowDown`/`s`/`S` -> `Press('Down')` / `Release('Down')`, + * `r`/`R` -> `Restart` (on press only; releasing `r` does nothing). Any other key -> `null`. */ export const pongKey = (isDown, key) => todo('m11') diff --git a/js/src/pong/rules.js b/js/src/pong/rules.js index 65140b3..302311e 100644 --- a/js/src/pong/rules.js +++ b/js/src/pong/rules.js @@ -6,7 +6,7 @@ export const clamp = (lo, hi, x) => todo('m11') /** * The one function that says how a match changes: `(state, action) -> state`. * - * - `Press(key)` / `Release(key)`: set `held.up` or `held.down` (even when the match is over). + * - `Press(key)` / `Release(key)`: set `held.up` or `held.down` (even when the match is over); any other key leaves the state unchanged. * - `Restart`: `initialPong(state.seed)`. * - `Frame`: unchanged unless status is 'Playing'. Otherwise, in this order: * 1. `frame + 1`. diff --git a/js/test/helpers.js b/js/test/helpers.js index 15128cc..702e11d 100644 --- a/js/test/helpers.js +++ b/js/test/helpers.js @@ -1,6 +1,9 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' +import { states, classicActions, render } from '../src/app.js' +import { map as mapStream } from '../src/stream.js' +import { map as mapIO, randomSeed } from '../src/io.js' const here = dirname(fileURLToPath(import.meta.url)) @@ -17,3 +20,6 @@ export const freeze = (x, seen = new WeakSet()) => { } return x } + +/** The m09 program built from classicActions (ticks every 150 ms). The shipped `main` switches to speedActions in m10. */ +export const classicMain = mapIO(randomSeed, (seed) => mapStream(states(seed, classicActions), render)) diff --git a/js/test/m09-app.test.js b/js/test/m09-app.test.js index 83e7bc8..15531f5 100644 --- a/js/test/m09-app.test.js +++ b/js/test/m09-app.test.js @@ -1,12 +1,12 @@ import test from 'node:test' import assert from 'node:assert/strict' import { states, classicActions, render, main, TICK_MS } from '../src/app.js' -import { run, map as mapStream } from '../src/stream.js' -import { trace, map as mapIO, randomSeed } from '../src/io.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 { freeze } from './helpers.js' +import { freeze, classicMain } from './helpers.js' const time = (t) => ({ tag: 'Time', t }) const down = (key) => ({ tag: 'KeyDown', key }) @@ -42,7 +42,6 @@ test('m09 main asks for a random seed, then builds the stream from it', () => { // `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)) @@ -50,3 +49,12 @@ test('m09 the program built from classicActions draws the new game first, then t assert.equal(second.length, 1) assert.notDeepEqual(trace(second[0], 0).ops, trace(first[0], 0).ops) }) + +// Holds before and after m10: whatever `main` ticks with, the clock must be wired in, so time passing produces more drawings. +test('m09 main draws again once time has passed', () => { + const { result: stream } = trace(main, 42) + const [first, next] = stream.step(time(0)) + const [later] = next.step(time(150)) + assert.equal(first.length, 1) + assert.ok(later.length >= 1) +}) diff --git a/js/test/m09-runtime.test.js b/js/test/m09-runtime.test.js index a6a4664..7de0395 100644 --- a/js/test/m09-runtime.test.js +++ b/js/test/m09-runtime.test.js @@ -2,10 +2,9 @@ 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 { 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' +import { classicMain } from './helpers.js' // A fake platform: records every call and lets the test fire events by hand. const fakePlatform = (seed = 42) => { @@ -25,8 +24,8 @@ 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)) +// The m09 program (classicMain) ticks every 150 ms; the shipped `main` uses speedActions after m10. +const main = classicMain // What the platform should have been asked to draw for a scene. const expectedCalls = (scene) => diff --git a/js/test/m11-pong.test.js b/js/test/m11-pong.test.js index 6f373e5..3b24871 100644 --- a/js/test/m11-pong.test.js +++ b/js/test/m11-pong.test.js @@ -41,7 +41,9 @@ test('m11 clamp keeps a number inside [lo, hi]', () => test('m11 pongUpdate does not modify the state it is given', () => { const s = freeze(initialPong(0)) + const before = structuredClone(s) assert.doesNotThrow(() => pongUpdate(s, PongAction.Frame)) + assert.deepStrictEqual(s, before) }) // 3000 frames with no one touching the keys: the ball must always stay on the court and the match must end.