From 08b462d5ccd930b0fa47f0ff3928b15eb0636510 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 28 Oct 2026 23:42:36 +0100 Subject: [PATCH] feat(js): m04 random and spawn with fixtures and tests Co-Authored-By: Claude Sonnet 5.5 --- fixtures/m04-free-cells.json | 227 ++++++++++++++++++ fixtures/m04-int-below.json | 82 +++++++ fixtures/m04-new-game.json | 73 ++++++ fixtures/m04-random.json | 305 +++++++++++++++++++++++ fixtures/m04-spawn.json | 452 +++++++++++++++++++++++++++++++++++ js/solutions/random.js | 31 +++ js/solutions/spawn.js | 26 ++ js/src/random.js | 30 +++ js/src/spawn.js | 17 ++ js/test/m04-random.test.js | 52 ++++ js/test/m04-spawn.test.js | 37 +++ 11 files changed, 1332 insertions(+) create mode 100644 fixtures/m04-free-cells.json create mode 100644 fixtures/m04-int-below.json create mode 100644 fixtures/m04-new-game.json create mode 100644 fixtures/m04-random.json create mode 100644 fixtures/m04-spawn.json create mode 100644 js/solutions/random.js create mode 100644 js/solutions/spawn.js create mode 100644 js/src/random.js create mode 100644 js/src/spawn.js create mode 100644 js/test/m04-random.test.js create mode 100644 js/test/m04-spawn.test.js diff --git a/fixtures/m04-free-cells.json b/fixtures/m04-free-cells.json new file mode 100644 index 0000000..5695ea1 --- /dev/null +++ b/fixtures/m04-free-cells.json @@ -0,0 +1,227 @@ +{ + "function": "freeCells", + "cases": [ + { + "name": "free-cells-row-major-order", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 0, + "score": 0, + "status": "Playing", + "frame": 0 + } + ], + "expected": [ + { + "x": 1, + "y": 0 + }, + { + "x": 2, + "y": 0 + }, + { + "x": 0, + "y": 1 + }, + { + "x": 1, + "y": 1 + }, + { + "x": 2, + "y": 1 + } + ] + }, + { + "name": "free-cells-skip-snake", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 1, + "y": 0 + }, + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 0, + "score": 0, + "status": "Playing", + "frame": 0 + } + ], + "expected": [ + { + "x": 2, + "y": 0 + }, + { + "x": 0, + "y": 1 + }, + { + "x": 1, + "y": 1 + }, + { + "x": 2, + "y": 1 + } + ] + }, + { + "name": "free-cells-snake-in-second-row", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 2, + "y": 1 + }, + { + "x": 1, + "y": 1 + }, + { + "x": 1, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 0, + "score": 0, + "status": "Playing", + "frame": 0 + } + ], + "expected": [ + { + "x": 0, + "y": 0 + }, + { + "x": 2, + "y": 0 + }, + { + "x": 0, + "y": 1 + } + ] + }, + { + "name": "free-cells-apple-cell-is-still-free", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 1, + "y": 0 + }, + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 0 + }, + "seed": 0, + "score": 0, + "status": "Playing", + "frame": 0 + } + ], + "expected": [ + { + "x": 2, + "y": 0 + }, + { + "x": 0, + "y": 1 + }, + { + "x": 1, + "y": 1 + }, + { + "x": 2, + "y": 1 + } + ] + }, + { + "name": "free-cells-full-board-is-empty", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 0 + }, + { + "x": 2, + "y": 0 + }, + { + "x": 2, + "y": 1 + }, + { + "x": 1, + "y": 1 + }, + { + "x": 0, + "y": 1 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 0, + "score": 0, + "status": "Playing", + "frame": 0 + } + ], + "expected": [] + } + ] +} diff --git a/fixtures/m04-int-below.json b/fixtures/m04-int-below.json new file mode 100644 index 0000000..03e7521 --- /dev/null +++ b/fixtures/m04-int-below.json @@ -0,0 +1,82 @@ +{ + "function": "intBelow", + "cases": [ + { + "name": "below-6-from-seed-0", + "args": [ + 6, + 0 + ], + "expected": [ + 2, + 1831565813 + ] + }, + { + "name": "below-1-from-seed-0", + "args": [ + 1, + 0 + ], + "expected": [ + 0, + 1831565813 + ] + }, + { + "name": "below-2-from-seed-1", + "args": [ + 2, + 1 + ], + "expected": [ + 1, + 1831565814 + ] + }, + { + "name": "below-10-from-seed-1", + "args": [ + 10, + 1 + ], + "expected": [ + 7, + 1831565814 + ] + }, + { + "name": "below-3-from-seed-4294967295", + "args": [ + 3, + 4294967295 + ], + "expected": [ + 2, + 1831565812 + ] + }, + { + "name": "below-400-from-seed-42", + "args": [ + 400, + 42 + ], + "expected": [ + 156, + 1831565855 + ] + }, + { + "name": "below-4-from-seed-12345", + "args": [ + 4, + 12345 + ], + "expected": [ + 1, + 1831578158 + ] + } + ] +} diff --git a/fixtures/m04-new-game.json b/fixtures/m04-new-game.json new file mode 100644 index 0000000..ede134b --- /dev/null +++ b/fixtures/m04-new-game.json @@ -0,0 +1,73 @@ +{ + "function": "newGame", + "cases": [ + { + "name": "seed-0", + "args": [ + 0 + ], + "expected": { + "width": 20, + "height": 20, + "snake": [ + { + "x": 10, + "y": 10 + }, + { + "x": 9, + "y": 10 + }, + { + "x": 8, + "y": 10 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 18, + "y": 13 + }, + "seed": 1831565813, + "score": 0, + "status": "Playing", + "frame": 0 + } + }, + { + "name": "seed-42", + "args": [ + 42 + ], + "expected": { + "width": 20, + "height": 20, + "snake": [ + { + "x": 10, + "y": 10 + }, + { + "x": 9, + "y": 10 + }, + { + "x": 8, + "y": 10 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 1, + "y": 9 + }, + "seed": 1831565855, + "score": 0, + "status": "Playing", + "frame": 0 + } + } + ] +} diff --git a/fixtures/m04-random.json b/fixtures/m04-random.json new file mode 100644 index 0000000..04d30d6 --- /dev/null +++ b/fixtures/m04-random.json @@ -0,0 +1,305 @@ +{ + "function": "nextUint32", + "cases": [ + { + "name": "seed-0-step-1", + "args": [ + 0 + ], + "expected": [ + 1144304738, + 1831565813 + ] + }, + { + "name": "seed-0-step-2", + "args": [ + 1831565813 + ], + "expected": [ + 1416247, + 3663131626 + ] + }, + { + "name": "seed-0-step-3", + "args": [ + 3663131626 + ], + "expected": [ + 958946056, + 1199730143 + ] + }, + { + "name": "seed-0-step-4", + "args": [ + 1199730143 + ], + "expected": [ + 627933444, + 3031295956 + ] + }, + { + "name": "seed-0-step-5", + "args": [ + 3031295956 + ], + "expected": [ + 2007157716, + 567894473 + ] + }, + { + "name": "seed-0-step-6", + "args": [ + 567894473 + ], + "expected": [ + 2340967985, + 2399460286 + ] + }, + { + "name": "seed-0-step-7", + "args": [ + 2399460286 + ], + "expected": [ + 2642484575, + 4231026099 + ] + }, + { + "name": "seed-0-step-8", + "args": [ + 4231026099 + ], + "expected": [ + 2787370982, + 1767624616 + ] + }, + { + "name": "seed-0-step-9", + "args": [ + 1767624616 + ], + "expected": [ + 1958536065, + 3599190429 + ] + }, + { + "name": "seed-0-step-10", + "args": [ + 3599190429 + ], + "expected": [ + 2496316458, + 1135788946 + ] + }, + { + "name": "seed-1-step-1", + "args": [ + 1 + ], + "expected": [ + 2693262067, + 1831565814 + ] + }, + { + "name": "seed-1-step-2", + "args": [ + 1831565814 + ], + "expected": [ + 11749833, + 3663131627 + ] + }, + { + "name": "seed-1-step-3", + "args": [ + 3663131627 + ], + "expected": [ + 2265367787, + 1199730144 + ] + }, + { + "name": "seed-1-step-4", + "args": [ + 1199730144 + ], + "expected": [ + 4213581821, + 3031295957 + ] + }, + { + "name": "seed-1-step-5", + "args": [ + 3031295957 + ], + "expected": [ + 4159151403, + 567894474 + ] + }, + { + "name": "seed-1-step-6", + "args": [ + 567894474 + ], + "expected": [ + 1207330352, + 2399460287 + ] + }, + { + "name": "seed-1-step-7", + "args": [ + 2399460287 + ], + "expected": [ + 2632122864, + 4231026100 + ] + }, + { + "name": "seed-1-step-8", + "args": [ + 4231026100 + ], + "expected": [ + 3095568220, + 1767624617 + ] + }, + { + "name": "seed-1-step-9", + "args": [ + 1767624617 + ], + "expected": [ + 1828783984, + 3599190430 + ] + }, + { + "name": "seed-1-step-10", + "args": [ + 3599190430 + ], + "expected": [ + 4272732017, + 1135788947 + ] + }, + { + "name": "seed-4294967295-step-1", + "args": [ + 4294967295 + ], + "expected": [ + 3850105811, + 1831565812 + ] + }, + { + "name": "seed-4294967295-step-2", + "args": [ + 1831565812 + ], + "expected": [ + 813802916, + 3663131625 + ] + }, + { + "name": "seed-4294967295-step-3", + "args": [ + 3663131625 + ], + "expected": [ + 3073704848, + 1199730142 + ] + }, + { + "name": "seed-4294967295-step-4", + "args": [ + 1199730142 + ], + "expected": [ + 4054706436, + 3031295955 + ] + }, + { + "name": "seed-4294967295-step-5", + "args": [ + 3031295955 + ], + "expected": [ + 3630262831, + 567894472 + ] + }, + { + "name": "seed-4294967295-step-6", + "args": [ + 567894472 + ], + "expected": [ + 2315588663, + 2399460285 + ] + }, + { + "name": "seed-4294967295-step-7", + "args": [ + 2399460285 + ], + "expected": [ + 2922715533, + 4231026098 + ] + }, + { + "name": "seed-4294967295-step-8", + "args": [ + 4231026098 + ], + "expected": [ + 2042566601, + 1767624615 + ] + }, + { + "name": "seed-4294967295-step-9", + "args": [ + 1767624615 + ], + "expected": [ + 583504547, + 3599190428 + ] + }, + { + "name": "seed-4294967295-step-10", + "args": [ + 3599190428 + ], + "expected": [ + 4245337221, + 1135788945 + ] + } + ] +} diff --git a/fixtures/m04-spawn.json b/fixtures/m04-spawn.json new file mode 100644 index 0000000..68668d4 --- /dev/null +++ b/fixtures/m04-spawn.json @@ -0,0 +1,452 @@ +{ + "function": "spawnApple", + "cases": [ + { + "name": "spawn-picks-a-free-cell-and-advances-seed", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 1, + "y": 0 + }, + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 0, + "score": 0, + "status": "Playing", + "frame": 0 + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 1, + "y": 0 + }, + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 1, + "y": 1 + }, + "seed": 1831565813, + "score": 0, + "status": "Playing", + "frame": 0 + } + }, + { + "name": "spawn-other-seed-picks-other-cell", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 1, + "y": 0 + }, + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 1, + "score": 0, + "status": "Playing", + "frame": 0 + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 1, + "y": 0 + }, + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 1831565814, + "score": 0, + "status": "Playing", + "frame": 0 + } + }, + { + "name": "spawn-with-one-free-cell", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 0 + }, + { + "x": 2, + "y": 0 + }, + { + "x": 2, + "y": 1 + }, + { + "x": 1, + "y": 1 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 7, + "score": 0, + "status": "Playing", + "frame": 0 + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 0 + }, + { + "x": 2, + "y": 0 + }, + { + "x": 2, + "y": 1 + }, + { + "x": 1, + "y": 1 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 0, + "y": 1 + }, + "seed": 1831565820, + "score": 0, + "status": "Playing", + "frame": 0 + } + }, + { + "name": "spawn-on-full-board", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 0 + }, + { + "x": 2, + "y": 0 + }, + { + "x": 2, + "y": 1 + }, + { + "x": 1, + "y": 1 + }, + { + "x": 0, + "y": 1 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 5, + "score": 0, + "status": "Playing", + "frame": 0 + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 0 + }, + { + "x": 2, + "y": 0 + }, + { + "x": 2, + "y": 1 + }, + { + "x": 1, + "y": 1 + }, + { + "x": 0, + "y": 1 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 5, + "score": 0, + "status": "Won", + "frame": 0 + } + }, + { + "name": "spawn-apple-present-unchanged", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 1, + "y": 0 + }, + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 5, + "score": 0, + "status": "Playing", + "frame": 0 + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 1, + "y": 0 + }, + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": { + "x": 2, + "y": 1 + }, + "seed": 5, + "score": 0, + "status": "Playing", + "frame": 0 + } + }, + { + "name": "spawn-paused-unchanged", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 1, + "y": 0 + }, + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 5, + "score": 0, + "status": "Paused", + "frame": 0 + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 1, + "y": 0 + }, + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 5, + "score": 0, + "status": "Paused", + "frame": 0 + } + }, + { + "name": "spawn-gameover-unchanged", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 1, + "y": 0 + }, + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 5, + "score": 0, + "status": "GameOver", + "frame": 0 + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 1, + "y": 0 + }, + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 5, + "score": 0, + "status": "GameOver", + "frame": 0 + } + }, + { + "name": "spawn-won-unchanged", + "args": [ + { + "width": 3, + "height": 2, + "snake": [ + { + "x": 1, + "y": 0 + }, + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 5, + "score": 0, + "status": "Won", + "frame": 0 + } + ], + "expected": { + "width": 3, + "height": 2, + "snake": [ + { + "x": 1, + "y": 0 + }, + { + "x": 0, + "y": 0 + } + ], + "dir": "Right", + "nextDir": "Right", + "apple": null, + "seed": 5, + "score": 0, + "status": "Won", + "frame": 0 + } + } + ] +} diff --git a/js/solutions/random.js b/js/solutions/random.js new file mode 100644 index 0000000..55f6902 --- /dev/null +++ b/js/solutions/random.js @@ -0,0 +1,31 @@ +// m04 random: a tiny "state monad" for randomness. A Random is a function seed -> [A, nextSeed], +// so every draw is a pure function and the seed is passed along explicitly instead of hidden. + +/** mulberry32: a small, fast generator. `imul` is 32-bit integer multiplication (plain `*` would lose bits). */ +export const nextUint32 = (seed) => { + const s = (seed + 0x6D2B79F5) >>> 0 // `>>> 0` keeps numbers unsigned 32-bit + const t1 = Math.imul(s ^ (s >>> 15), s | 1) + const t2 = t1 ^ (t1 + Math.imul(t1 ^ (t1 >>> 7), t1 | 61)) + return [(t2 ^ (t2 >>> 14)) >>> 0, s] +} + +/** A Random that always gives `a` and leaves the seed untouched. */ +export const pure = (a) => (seed) => [a, seed] + +/** Transform the value a Random produces; the seed threading is unchanged. */ +export const map = (r, f) => (seed) => { + const [a, next] = r(seed) + return [f(a), next] +} + +/** Run `r`, let `f` pick the next Random from its value, and run that with the seed `r` left behind. */ +export const flatMap = (r, f) => (seed) => { + const [a, next] = r(seed) + return f(a)(next) +} + +/** A Random giving a number in `[0, n)`. */ +export const intBelow = (n) => map(nextUint32, (v) => v % n) + +/** Run a Random from a seed: `[value, nextSeed]`. */ +export const run = (r, seed) => r(seed) diff --git a/js/solutions/spawn.js b/js/solutions/spawn.js new file mode 100644 index 0000000..0a23a03 --- /dev/null +++ b/js/solutions/spawn.js @@ -0,0 +1,26 @@ +// m04 spawn: placing apples with the Random from random.js. +import { range, contains } from './util.js' +import { point, pointEquals, initialState } from './model.js' +import { advance } from './rules.js' +import { intBelow, run } from './random.js' + +/** Every board cell not covered by the snake, row-major (y outer, x inner). */ +export const freeCells = (state) => + range(state.height) + .flatMap((y) => range(state.width).map((x) => point(x, y))) + .filter((p) => !contains(state.snake, p, pointEquals)) + +/** Put an apple on a random free cell; a full board means the player has won. */ +export const spawnApple = (state) => { + if (state.apple !== null || state.status !== 'Playing') return state + const cells = freeCells(state) + if (cells.length === 0) return { ...state, status: 'Won' } + const [i, seed] = run(intBelow(cells.length), state.seed) + return { ...state, apple: cells[i], seed } +} + +/** One game step: move the snake, then make sure there is an apple. */ +export const tick = (state) => spawnApple(advance(state)) + +/** A fresh game with its first apple already placed. */ +export const newGame = (seed) => spawnApple(initialState(seed)) diff --git a/js/src/random.js b/js/src/random.js new file mode 100644 index 0000000..5b16047 --- /dev/null +++ b/js/src/random.js @@ -0,0 +1,30 @@ +import { todo } from './todo.js' + +/** + * A `Random` is a plain function from a seed to a pair: `seed => [value, nextSeed]`. + * Seeds and values are unsigned 32-bit integers. Nothing is hidden or mutated: the caller carries the seed. + */ + +/** + * The generator (mulberry32). With `s = (seed + 0x6D2B79F5) mod 2^32`: + * t = imul(s ^ (s >>> 15), s | 1) + * t = t ^ (t + imul(t ^ (t >>> 7), t | 61)) + * value = (t ^ (t >>> 14)) >>> 0 + * Returns `[value, s]`: the new seed is `s`. + */ +export const nextUint32 = (seed) => todo('m04') + +/** A Random that always gives `a` and leaves the seed untouched. */ +export const pure = (a) => todo('m04') + +/** Transform the value a Random produces; the seed threading is unchanged. */ +export const map = (r, f) => todo('m04') + +/** Run `r`, feed its value to `f` to pick the next Random, and run that with the seed `r` left behind. */ +export const flatMap = (r, f) => todo('m04') + +/** A Random giving a number in `[0, n)`: `nextUint32` value modulo `n`. */ +export const intBelow = (n) => todo('m04') + +/** Run a Random from a seed: `[value, nextSeed]`. */ +export const run = (r, seed) => todo('m04') diff --git a/js/src/spawn.js b/js/src/spawn.js new file mode 100644 index 0000000..e14f0d3 --- /dev/null +++ b/js/src/spawn.js @@ -0,0 +1,17 @@ +import { todo } from './todo.js' + +/** Every board cell not covered by the snake, row-major (y outer loop, x inner loop). The apple does not count as occupied. */ +export const freeCells = (state) => todo('m04') + +/** + * Put an apple on a random free cell, drawing from `state.seed` (use `intBelow` and store the new seed). + * Unchanged if there already is an apple or the status is not 'Playing'. + * If no cell is free the board is full: status becomes 'Won' (seed unchanged, no apple). + */ +export const spawnApple = (state) => todo('m04') + +/** One game step: `advance`, then make sure there is an apple. */ +export const tick = (state) => todo('m04') + +/** A fresh game (`initialState`) with its first apple already placed. */ +export const newGame = (seed) => todo('m04') diff --git a/js/test/m04-random.test.js b/js/test/m04-random.test.js new file mode 100644 index 0000000..673c7a5 --- /dev/null +++ b/js/test/m04-random.test.js @@ -0,0 +1,52 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { nextUint32, pure, map, flatMap, intBelow, run } from '../src/random.js' +import { loadFixture, freeze } from './helpers.js' + +const fixtureTests = (name, f) => { + const fx = loadFixture(name) + fx.cases.forEach((c) => + test(`m04 ${fx.function}: ${c.name}`, () => assert.deepStrictEqual(f(...freeze(c.args)), c.expected))) +} + +fixtureTests('m04-random', (seed) => run(nextUint32, seed)) +fixtureTests('m04-int-below', (n, seed) => run(intBelow(n), seed)) + +test('m04 the recorded steps chain: each step starts from the previous step\'s seed', () => { + const cases = loadFixture('m04-random').cases + cases.forEach((c, i) => { + if (!c.name.endsWith('-step-1')) assert.equal(c.args[0], cases[i - 1].expected[1]) + }) +}) + +test('m04 pure returns its value and leaves the seed alone', () => + assert.deepStrictEqual(run(pure('x'), 99), ['x', 99])) + +test('m04 map applies f to the value and keeps the seed from the underlying step', () => { + const [v, s] = run(nextUint32, 5) + assert.deepStrictEqual(run(map(nextUint32, (x) => x + 1), 5), [v + 1, s]) +}) + +test('m04 map with the identity function changes nothing', () => + assert.deepStrictEqual(run(map(nextUint32, (x) => x), 5), run(nextUint32, 5))) + +test('m04 flatMap threads the seed through both steps', () => { + const [a, s1] = run(nextUint32, 5) + const [b, s2] = run(nextUint32, s1) + assert.deepStrictEqual(run(flatMap(nextUint32, (x) => map(nextUint32, (y) => [x, y])), 5), [[a, b], s2]) +}) + +test('m04 flatMap with pure on the left or right changes nothing', () => { + assert.deepStrictEqual(run(flatMap(pure(3), (x) => map(nextUint32, (v) => v + x)), 5), run(map(nextUint32, (v) => v + 3), 5)) + assert.deepStrictEqual(run(flatMap(nextUint32, pure), 5), run(nextUint32, 5)) +}) + +test('m04 intBelow always lands in [0, n)', () => { + const draws = (n, seed, count) => + count === 0 ? [] : ((([v, s]) => [v, ...draws(n, s, count - 1)])(run(intBelow(n), seed))) + ;[1, 2, 7, 400].forEach((n) => + draws(n, 123, 50).forEach((v) => assert.ok(Number.isInteger(v) && v >= 0 && v < n, `${v} not in [0, ${n})`))) +}) + +test('m04 running a Random does not mutate anything: same seed, same answer', () => + assert.deepStrictEqual(run(nextUint32, 8), run(nextUint32, 8))) diff --git a/js/test/m04-spawn.test.js b/js/test/m04-spawn.test.js new file mode 100644 index 0000000..b2fc45d --- /dev/null +++ b/js/test/m04-spawn.test.js @@ -0,0 +1,37 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { freeCells, spawnApple, tick, newGame } from '../src/spawn.js' +import { advance } from '../src/rules.js' +import { loadFixture, freeze } from './helpers.js' + +const fixtureTests = (name, f) => { + const fx = loadFixture(name) + fx.cases.forEach((c) => + test(`m04 ${fx.function}: ${c.name}`, () => assert.deepStrictEqual(f(...freeze(c.args)), c.expected))) +} + +fixtureTests('m04-free-cells', freeCells) +fixtureTests('m04-spawn', spawnApple) +fixtureTests('m04-new-game', newGame) + +// 3x2 board, one-cell snake, about to eat the apple at (1,0) +const eating = freeze({ + width: 3, height: 2, snake: [{ x: 0, y: 0 }], dir: 'Right', nextDir: 'Right', + apple: { x: 1, y: 0 }, seed: 0, score: 0, status: 'Playing', frame: 0, +}) + +test('m04 tick: eating the apple spawns a new one in the same step', () => { + // snake becomes [(1,0),(0,0)]; free cells are (2,0),(0,1),(1,1),(2,1); seed 0 draws 1144304738 % 4 = 2 -> (1,1) + assert.deepStrictEqual(tick(eating), { + ...eating, snake: [{ x: 1, y: 0 }, { x: 0, y: 0 }], apple: { x: 1, y: 1 }, score: 1, seed: 1831565813, + }) +}) + +test('m04 tick is spawnApple after advance', () => assert.deepStrictEqual(tick(eating), spawnApple(advance(eating)))) + +test('m04 tick leaves the seed alone when no apple needs spawning', () => { + const s = freeze({ ...eating, apple: { x: 2, y: 1 } }) + assert.equal(tick(s).seed, 0) +}) + +test('m04 newGame is reproducible: same seed, same game', () => assert.deepStrictEqual(newGame(42), newGame(42)))