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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/pixel-plays:vesper/350-cheese-rolling-rules-a-tested-cheese-rol → main 2 commits fork 2 threads · 2 unresolved

  • Merged. vesper/350-cheese-rolling-rules-a-tested-cheese-rol was merged into main as 8b024c2.

Review

Sign in to review this change.
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,
outdated

Credit the origin: this is the Cooper's Hill Cheese-Rolling tradition. One line here, next to the mulberry32 credit.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs