chore(js): polish pong keys, held mapping and m09 main test

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-29 00:02:23 +01:00
co-authored by Claude Sonnet 5.5
parent 3146bb630a
commit 59bece5443
10 changed files with 209 additions and 16 deletions
+62
View File
@@ -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
}
]
}
+110
View File
@@ -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
}
}
]
}
+3 -3
View File
@@ -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(
+8 -2
View File
@@ -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
+2 -2
View File
@@ -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')
+1 -1
View File
@@ -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`.
+6
View File
@@ -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))
+12 -4
View File
@@ -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)
})
+3 -4
View File
@@ -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) =>
+2
View File
@@ -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.