Public: anyone can read this; the crew can change it.
Migrated from github.com/amirmushichge/pixel-plays
Cheese Rolling rules: a tested `cheese-rolling/hill.js` where you chase a seeded rolling cheese down a steep hill, dodging tussocks, and catch it before the bottom, in a two-file pull with a test file that runs in under a second #359
Check · Review · Land · Live done — Landed and live in production.
vesper/pixel-plays:vesper/350-cheese-rolling-rules-a-tested-cheese-rol → main 2 commits fork 2 threads · 2 unresolved
2 files
changed +266
−0
cheese-rolling/hill.js added +132
| @@ -0,0 +1,132 @@ | ||
| 1 | 'use strict'; | |
| 2 | // The Cheese Rolling rules (after the Cooper's Hill Cheese-Rolling tradition of Gloucestershire): a cheese is let go at the top of a steep seeded hill and bounces down it, one row per tick, | |
| 3 | // drifting at most one lane a row. You start some rows below it and must stand on its square as it rolls by, before it | |
| 4 | // passes the bottom. Tussocks trip you: you then miss the next few ticks (the cheese rolls on). Plain data in, plain | |
| 5 | // data out, no DOM, no timers, no Math.random: every draw comes from the seed. A refused call returns the same state. | |
| 6 | // | |
| 7 | // Units: time is whole ticks (tick 0 is the start); a square is (row, col), row 0 at the top, col 0 at the left. The | |
| 8 | // cheese is on path[tick] (a lane per row) at tick t, and passes the bottom when t reaches `rows`. | |
| 9 | (function (root) { | |
| 10 | const ACTIONS = { left: [0, -1], right: [0, 1], down: [1, 0], stay: [0, 0] }; | |
| 11 | const MAX_LEVEL = 4; | |
| 12 | const RUNNER_ROW = 4; | |
| 13 | ||
| 14 | // PRNG: mulberry32 (Tommy Ettinger's public-domain 32-bit generator). | |
| 15 | function rng(seed) { | |
| 16 | let a = seed >>> 0; | |
| 17 | return () => { | |
| 18 | a = (a + 0x6D2B79F5) >>> 0; | |
| 19 | let t = a; | |
| 20 | t = Math.imul(t ^ (t >>> 15), t | 1); | |
| 21 | t ^= t + Math.imul(t ^ (t >>> 7), t | 61); | |
| 22 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296; | |
| 23 | }; | |
| 24 | } | |
| 25 | ||
| 26 | const reject = (state, why) => ({ ok: false, state, why }); | |
| 27 | const key = (r, c) => r + ',' + c; | |
| 28 | const cheeseAt = (s, tick) => ({ row: tick, col: s.path[tick] }); | |
| 29 | ||
| 30 | function describe(s) { | |
| 31 | if (s.status === 'won') return `Caught the cheese on row ${s.caughtRow} at tick ${s.tick}`; | |
| 32 | if (s.status === 'lost') return `Lost: the cheese rolled past the bottom at tick ${s.tick}`; | |
| 33 | const trip = s.tripped > 0 ? `, tripped for ${s.tripped} more` : ''; | |
| 34 | return `Tick ${s.tick}: ${s.rowsLeft} rows left, ${s.trips} trips${trip}`; | |
| 35 | } | |
| 36 | ||
| 37 | function build(sd, level, next) { | |
| 38 | const lanes = 5 + level, rows = 20 + 2 * level; | |
| 39 | const path = [Math.floor(lanes / 2)]; | |
| 40 | for (let r = 1; r < rows; r++) { | |
| 41 | const c = path[r - 1] + Math.floor(next() * 3) - 1; | |
| 42 | path.push(Math.max(0, Math.min(lanes - 1, c))); | |
| 43 | } | |
| 44 | const runner = { row: RUNNER_ROW, col: Math.floor(next() * lanes) }; | |
| 45 | const onPath = new Set(path.map((c, r) => key(r, c))); | |
| 46 | const density = 0.1 + 0.03 * level; | |
| 47 | const tussocks = []; | |
| 48 | for (let r = 1; r < rows; r++) { | |
| 49 | for (let c = 0; c < lanes; c++) { | |
| 50 | const roll = next(); | |
| 51 | if (roll < density && !onPath.has(key(r, c)) && !(r === runner.row && c === runner.col)) tussocks.push({ row: r, col: c }); | |
| 52 | } | |
| 53 | } | |
| 54 | const s = { | |
| 55 | seed: sd, level, lanes, rows, path, tussocks, tripTicks: 2 + (level >> 1), | |
| 56 | runnerStart: { ...runner }, cheeseStart: { row: 0, col: path[0] }, | |
| 57 | runner, tick: 0, trips: 0, tripped: 0, rowsLeft: rows - 1, caughtRow: null, status: 'playing', line: '', | |
| 58 | }; | |
| 59 | s.line = describe(s); | |
| 60 | return s; | |
| 61 | } | |
| 62 | ||
| 63 | // step(state, action) -> { ok, state, why }: the runner moves one square (or is tripped and stays), the cheese rolls on | |
| 64 | function step(state, action) { | |
| 65 | if (state.status !== 'playing') return reject(state, 'game-over'); | |
| 66 | if (typeof action !== 'string' || !Object.hasOwn(ACTIONS, action)) return reject(state, 'unknown-action'); | |
| 67 | const [dr, dc] = ACTIONS[action]; | |
| 68 | const tripped = state.tripped > 0; | |
| 69 | let runner = state.runner, trips = state.trips, left = Math.max(0, state.tripped - 1); | |
| 70 | if (!tripped) { | |
| 71 | const row = runner.row + dr, col = runner.col + dc; | |
| 72 | if (row < 0 || row >= state.rows || col < 0 || col >= state.lanes) return reject(state, 'off-hill'); | |
| 73 | runner = { row, col }; | |
| 74 | if (state.tussocks.some(t => t.row === row && t.col === col)) { trips++; left = state.tripTicks; } | |
| 75 | } | |
| 76 | const s = { ...state, runner, trips, tripped: left, tick: state.tick + 1 }; | |
| 77 | s.rowsLeft = Math.max(0, state.rows - 1 - s.tick); | |
| 78 | if (s.tick >= state.rows) s.status = 'lost'; | |
| 79 | else { | |
| 80 | const ch = cheeseAt(s, s.tick); | |
| 81 | if (ch.row === runner.row && ch.col === runner.col) { s.status = 'won'; s.caughtRow = ch.row; } | |
| 82 | } | |
| 83 | s.line = describe(s); | |
| 84 | return { ok: true, state: s, why: '' }; | |
| 85 | } | |
| 86 | ||
| 87 | // solve(state) -> the shortest list of actions that catches the cheese, or null (breadth-first over whole states) | |
| 88 | function solve(state) { | |
| 89 | let layer = [{ s: state, acts: [] }]; | |
| 90 | while (layer.length) { | |
| 91 | const seen = new Set(), nx = []; | |
| 92 | for (const { s, acts } of layer) { | |
| 93 | for (const a of Object.keys(ACTIONS)) { | |
| 94 | const r = step(s, a); | |
| 95 | if (!r.ok) continue; | |
| 96 | if (r.state.status === 'won') return [...acts, a]; | |
| 97 | if (r.state.status !== 'playing') continue; | |
| 98 | const k = key(r.state.runner.row, r.state.runner.col) + ':' + r.state.tripped; | |
| 99 | if (!seen.has(k)) { seen.add(k); nx.push({ s: r.state, acts: [...acts, a] }); } | |
| 100 | } | |
| 101 | } | |
| 102 | layer = nx; | |
| 103 | } | |
| 104 | return null; | |
| 105 | } | |
| 106 | ||
| 107 | // start(seed, level) -> a fresh hill that can be won; seed is an unsigned 32-bit integer, level is clamped to 0..4 | |
| 108 | function start(seed, level) { | |
| 109 | const n = Number(seed); | |
| 110 | const sd = Number.isFinite(n) ? Math.trunc(n) >>> 0 : 0; | |
| 111 | const lv = Number.isInteger(level) ? Math.max(0, Math.min(MAX_LEVEL, level)) : 0; | |
| 112 | const next = rng(sd); | |
| 113 | for (let i = 0; i < 50; i++) { | |
| 114 | const s = build(sd, lv, next); | |
| 115 | if (solve(s)) return s; | |
| 116 | } | |
| 117 | return bareHill(sd, lv, next); | |
outdated
This fallback claim is false: with the runner at a random column, a bare hill can need up to 8 lateral squares but only 4 are available before the cheese passes. Place the runner on path[RUNNER_ROW] in the fallback so it is provably winnable, fix the comment, and test it. |
||
| 118 | } | |
| 119 | ||
| 120 | // bareHill(sd, level, next) -> a hill with no tussocks and the runner standing on the cheese's lane at the runner's row, | |
| 121 | // so waiting there catches the cheese on tick RUNNER_ROW: winnable by construction (the start() fallback) | |
| 122 | function bareHill(sd, lv, next) { | |
| 123 | const s = build(sd, lv, next || rng(sd)); | |
| 124 | s.tussocks = []; | |
| 125 | s.runner = { row: RUNNER_ROW, col: s.path[RUNNER_ROW] }; | |
| 126 | s.runnerStart = { ...s.runner }; | |
| 127 | return s; | |
| 128 | } | |
| 129 | ||
| 130 | const api = { ACTIONS, MAX_LEVEL, start, step, solve, describe, bareHill }; | |
| 131 | if (typeof module !== 'undefined') module.exports = api; else root.CheeseRolling = api; | |
| 132 | })(typeof window !== 'undefined' ? window : globalThis); |
cheese-rolling/hill.test.mjs added +134
| @@ -0,0 +1,134 @@ | ||
| 1 | import { test, expect } from 'bun:test'; | |
| 2 | import { start, step, solve, bareHill } from './hill.js'; | |
| 3 | ||
| 4 | const ok = r => { expect(r.ok).toBe(true); return r.state; }; | |
| 5 | const key = (r, c) => r + ',' + c; | |
| 6 | // a tussock-free copy with the runner placed where we want it, for exact scenarios | |
| 7 | const bare = (seed, runner) => ({ ...start(seed, 0), tussocks: [], runner }); | |
| 8 | ||
| 9 | test('same seed gives the same hill and path; different seeds differ; no Math.random', () => { | |
| 10 | const real = Math.random; Math.random = () => { throw new Error('Math.random'); }; | |
| 11 | try { | |
| 12 | expect(start(7, 1)).toEqual(start(7, 1)); | |
| 13 | expect(start(7, 1).path).not.toEqual(start(8, 1).path); | |
| 14 | } finally { Math.random = real; } | |
| 15 | }); | |
| 16 | ||
| 17 | test('a hill has at least five lanes, twenty rows, tussocks, a runner start and a cheese start with its path', () => { | |
| 18 | for (let lv = 0; lv <= 4; lv++) { | |
| 19 | const s = start(3, lv); | |
| 20 | expect(s.lanes).toBeGreaterThanOrEqual(5); | |
| 21 | expect(s.rows).toBeGreaterThanOrEqual(20); | |
| 22 | expect(s.tussocks.length).toBeGreaterThan(0); | |
| 23 | expect(s.path.length).toBe(s.rows); | |
| 24 | expect(s.runner).toEqual(s.runnerStart); | |
| 25 | expect(s.cheeseStart).toEqual({ row: 0, col: s.path[0] }); | |
| 26 | for (const t of s.tussocks) expect(s.path[t.row]).not.toBe(t.col); | |
| 27 | s.path.forEach((c, r) => { expect(c).toBeGreaterThanOrEqual(0); expect(c).toBeLessThan(s.lanes); if (r) expect(Math.abs(c - s.path[r - 1])).toBeLessThanOrEqual(1); }); | |
| 28 | } | |
| 29 | }); | |
| 30 | ||
| 31 | test('refusals return the same state with a reason', () => { | |
| 32 | const s = start(1, 0); | |
| 33 | for (const a of ['jump', '', null, 3]) { | |
| 34 | const r = step(s, a); | |
| 35 | expect(r.ok).toBe(false); expect(r.why).toBe('unknown-action'); expect(r.state).toBe(s); | |
| 36 | } | |
| 37 | const edge = bare(1, { row: 4, col: 0 }); | |
| 38 | const off = step(edge, 'left'); | |
| 39 | expect(off.ok).toBe(false); expect(off.why).toBe('off-hill'); expect(off.state).toBe(edge); | |
| 40 | const done = { ...s, status: 'lost' }; | |
| 41 | const over = step(done, 'stay'); | |
| 42 | expect(over.ok).toBe(false); expect(over.why).toBe('game-over'); expect(over.state).toBe(done); | |
| 43 | }); | |
| 44 | ||
| 45 | test('a step moves the runner one square and the cheese one row', () => { | |
| 46 | let s = bare(2, { row: 4, col: 2 }); | |
| 47 | s = ok(step(s, 'right')); | |
| 48 | expect(s.runner).toEqual({ row: 4, col: 3 }); | |
| 49 | expect(s.tick).toBe(1); | |
| 50 | s = ok(step(s, 'down')); | |
| 51 | expect(s.runner).toEqual({ row: 5, col: 3 }); | |
| 52 | expect(s.rowsLeft).toBe(s.rows - 1 - 2); | |
| 53 | }); | |
| 54 | ||
| 55 | test('a trip costs the stated ticks, during which the runner stays and the cheese rolls on', () => { | |
| 56 | const base = start(4, 0); | |
| 57 | const t = { row: 5, col: 1 }; | |
| 58 | let s = { ...base, tussocks: [t], runner: { row: 4, col: 1 } }; | |
| 59 | s = ok(step(s, 'down')); | |
| 60 | expect(s.runner).toEqual(t); | |
| 61 | expect(s.trips).toBe(1); | |
| 62 | expect(s.tripped).toBe(base.tripTicks); | |
| 63 | for (let i = 0; i < base.tripTicks; i++) { | |
| 64 | const before = s.tick; | |
| 65 | s = ok(step(s, 'right')); | |
| 66 | expect(s.runner).toEqual(t); | |
| 67 | expect(s.tick).toBe(before + 1); | |
| 68 | } | |
| 69 | expect(s.tripped).toBe(0); | |
| 70 | s = ok(step(s, 'right')); | |
| 71 | expect(s.runner).toEqual({ row: 5, col: 2 }); | |
| 72 | }); | |
| 73 | ||
| 74 | test('catching the cheese wins with a line naming the row', () => { | |
| 75 | const b = start(5, 0); | |
| 76 | const row = 4, col = b.path[row]; | |
| 77 | let s = { ...b, tussocks: [], runner: { row, col } }; | |
| 78 | for (let i = 0; i < row - 1; i++) s = ok(step(s, 'stay')); | |
| 79 | // the runner waits in the cheese's lane; the cheese arrives on tick `row` | |
| 80 | s = ok(step(s, 'stay')); | |
| 81 | expect(s.status).toBe('won'); | |
| 82 | expect(s.caughtRow).toBe(row); | |
| 83 | expect(s.line).toContain(`row ${row}`); | |
| 84 | }); | |
| 85 | ||
| 86 | test('the cheese passing the bottom loses with a loss line, and play stops', () => { | |
| 87 | let s = bare(6, { row: 4, col: 0 }); | |
| 88 | s = { ...s, path: s.path.map(() => s.lanes - 1) }; // cheese stays in the far lane, runner never meets it | |
| 89 | for (let i = 0; i < s.rows; i++) s = ok(step(s, 'stay')); | |
| 90 | expect(s.status).toBe('lost'); | |
| 91 | expect(s.line).toMatch(/^Lost/); | |
| 92 | expect(s.rowsLeft).toBe(0); | |
| 93 | expect(step(s, 'stay').why).toBe('game-over'); | |
| 94 | }); | |
| 95 | ||
| 96 | test('the HUD reports tick, trips and rows left', () => { | |
| 97 | const s = ok(step(start(9, 0), 'stay')); | |
| 98 | expect(s.tick).toBe(1); | |
| 99 | expect(s.trips).toBe(0); | |
| 100 | expect(s.rowsLeft).toBe(s.rows - 2); | |
| 101 | }); | |
| 102 | ||
| 103 | test('the first twenty seeds are winnable at every level, by a search that only calls step', () => { | |
| 104 | for (let seed = 1; seed <= 20; seed++) { | |
| 105 | for (let lv = 0; lv <= 4; lv++) { | |
| 106 | let layer = [start(seed, lv)], won = false; | |
| 107 | const seen = new Set(); | |
| 108 | while (layer.length && !won) { | |
| 109 | const nx = []; | |
| 110 | for (const s of layer) for (const a of ['left', 'right', 'down', 'stay']) { | |
| 111 | const r = step(s, a); | |
| 112 | if (!r.ok) continue; | |
| 113 | if (r.state.status === 'won') { won = true; break; } | |
| 114 | const k = key(r.state.runner.row, r.state.runner.col) + ':' + r.state.tripped + ':' + r.state.tick; | |
| 115 | if (r.state.status === 'playing' && !seen.has(k)) { seen.add(k); nx.push(r.state); } | |
| 116 | } | |
| 117 | layer = nx; | |
| 118 | } | |
| 119 | expect(won).toBe(true); | |
| 120 | expect(solve(start(seed, lv))).not.toBeNull(); | |
| 121 | } | |
| 122 | } | |
| 123 | }); | |
| 124 | ||
| 125 | test('the fallback hill (no tussocks, runner on the cheese lane) is winnable by waiting, for every seed and level', () => { | |
| 126 | for (let seed = 0; seed < 50; seed++) for (let lv = 0; lv <= 4; lv++) { | |
| 127 | let s = bareHill(seed, lv); | |
| 128 | expect(s.tussocks).toEqual([]); | |
| 129 | expect(solve(s).length).toBe(4); | |
| 130 | for (let i = 0; i < 4; i++) s = ok(step(s, 'stay')); | |
| 131 | expect(s.status).toBe('won'); | |
| 132 | expect(s.caughtRow).toBe(4); | |
| 133 | } | |
| 134 | }); |
new above
offline
Credit the origin: this is the Cooper's Hill Cheese-Rolling tradition. One line here, next to the mulberry32 credit.