Public: anyone can read this; the crew can change it.

Migrated from github.com/amirmushichge/pixel-plays

Beach Huts rules: a tested `beach-huts/row.js` where you paint a seeded row of beach huts so no two neighbours share a colour, from limited tins, within par, in a two-file pull with a test file that runs in under a second #358

Merged opened this

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

Merged

vesper/pixel-plays:vesper/339-beach-huts-rules-a-tested-beach-huts-row → main 2 commits fork

  • Merged. vesper/339-beach-huts-rules-a-tested-beach-huts-row was merged into main as 1b57138.

Review

Sign in to review this change.
2 files changed +244 −0
beach-huts/row.js added +109
@@ -0,0 +1,109 @@
1 'use strict';
2 // The Beach Huts rules: a seeded row of seaside huts, some already painted and locked, to be painted so that no two
3 // neighbours share a colour, using each colour no more than its tin count, within a par of brush strokes. Plain data in,
4 // plain data out, no DOM, no timers, no Math.random: the row comes from the seed. A refused call returns { ok:false }
5 // with the very same state and says why.
6 //
7 // A hut is { colour, locked }: colour is a name from `colours` or null (bare). `tins` is what is left of each colour;
8 // locked huts never draw on the tins. Repainting returns the old colour's tin. `history` holds earlier snapshots for undo.
9 (function (root) {
10 const COLOURS = ['red', 'blue', 'yellow', 'green'];
11 const MIN_HUTS = 8;
12 const MAX_LEVEL = 5;
13 const MIN_OPEN = 3; // at least this many huts are left for the player to paint
14
15 // mulberry32 as a pure step: next(seed) -> [value in [0,1), nextSeed]
16 function next(seed) {
17 const a = (seed + 0x6D2B79F5) >>> 0;
18 let t = a;
19 t = Math.imul(t ^ (t >>> 15), t | 1);
20 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
21 return [((t ^ (t >>> 14)) >>> 0) / 4294967296, a];
22 }
23
24 const reject = (state, why) => ({ ok: false, state, why });
25 const clampLevel = l => Number.isFinite(l) ? Math.min(MAX_LEVEL, Math.max(1, Math.trunc(l))) : 1;
26
27 // clashes(state) -> [[i, i+1], ...] every neighbouring pair of painted huts with the same colour
28 function clashes(state) {
29 const out = [];
30 for (let i = 0; i + 1 < state.huts.length; i++) {
31 const a = state.huts[i].colour;
32 if (a !== null && a === state.huts[i + 1].colour) out.push([i, i + 1]);
33 }
34 return out;
35 }
36
37 function describe(s) {
38 if (s.status === 'won') return `Row painted: ${s.strokes} strokes against par ${s.par}`;
39 if (s.status === 'lost') return `Over par: ${s.strokes} strokes against par ${s.par}, the row is not done`;
40 const bare = s.huts.filter(h => h.colour === null).length;
41 return `${bare} bare, ${clashes(s).length} clashes; ${s.strokes} of par ${s.par} strokes`;
42 }
43
44 // judge(s): sets status and line from the huts and the strokes
45 function judge(s) {
46 const done = s.huts.every(h => h.colour !== null) && clashes(s).length === 0;
47 s.status = done && s.strokes <= s.par ? 'won' : s.strokes > s.par ? 'lost' : 'playing';
48 s.line = describe(s);
49 return s;
50 }
51
52 // start(seed, level) -> a fresh game: a hidden proper colouring is drawn, some huts are locked to it, the tins hold
53 // exactly what the rest needs plus a spare here and there, and par is the bare huts plus a little slack.
54 function start(seed, level) {
55 const sd = Number.isFinite(seed) ? Math.trunc(seed) >>> 0 : 0;
56 const lv = clampLevel(level);
57 const colours = COLOURS.slice(0, lv >= 3 ? 4 : 3);
58 const length = MIN_HUTS + lv;
59 let rng = sd, r;
60 const draw = n => { [r, rng] = next(rng); return Math.floor(r * n); };
61 const answer = [];
62 for (let i = 0; i < length; i++) {
63 const choices = colours.filter(c => c !== answer[i - 1]);
64 answer.push(choices[draw(choices.length)]);
65 }
66 const lockedAt = new Set();
67 const lockCount = Math.min(length - MIN_OPEN, Math.max(2, Math.round(length * (0.5 - lv * 0.06))));
68 while (lockedAt.size < lockCount) lockedAt.add(draw(length));
69 const huts = answer.map((c, i) => lockedAt.has(i) ? { colour: c, locked: true } : { colour: null, locked: false });
70 const tins = {};
71 for (const c of colours) tins[c] = 0;
72 answer.forEach((c, i) => { if (!lockedAt.has(i)) tins[c] += 1; });
73 for (const c of colours) tins[c] += draw(2); // a spare tin of some colours, never more than one
74 const open = length - lockCount;
75 const s = {
76 seed: sd, level: lv, colours, huts, tins, par: open + 2 - Math.min(1, lv >> 1),
77 strokes: 0, history: [], status: 'playing', line: '',
78 };
79 return judge(s);
80 }
81
82 // paint(state, hut, colour) -> { ok, state, why }: one brush stroke
83 function paint(state, hut, colour) {
84 if (state.status !== 'playing') return reject(state, 'game-over');
85 if (!Number.isInteger(hut) || hut < 0 || hut >= state.huts.length) return reject(state, 'no-such-hut');
86 if (!state.colours.includes(colour)) return reject(state, 'no-such-colour');
87 const old = state.huts[hut];
88 if (old.locked) return reject(state, 'locked');
89 if (old.colour === colour) return reject(state, 'same-colour');
90 if (state.tins[colour] < 1) return reject(state, 'no-tins-left');
91 const snap = { huts: state.huts, tins: state.tins };
92 const huts = state.huts.map((h, i) => i === hut ? { ...h, colour } : h);
93 const tins = { ...state.tins, [colour]: state.tins[colour] - 1 };
94 if (old.colour !== null) tins[old.colour] += 1;
95 const s = { ...state, huts, tins, strokes: state.strokes + 1, history: [...state.history, snap] };
96 return { ok: true, state: judge(s), why: '' };
97 }
98
99 // undo(state) -> { ok, state, why }: steps back one stroke, even out of a loss
100 function undo(state) {
101 if (state.history.length === 0) return reject(state, 'nothing-to-undo');
102 const snap = state.history[state.history.length - 1];
103 const s = { ...state, huts: snap.huts, tins: snap.tins, strokes: state.strokes - 1, history: state.history.slice(0, -1) };
104 return { ok: true, state: judge(s), why: '' };
105 }
106
107 const api = { COLOURS, MIN_HUTS, MAX_LEVEL, start, paint, undo, clashes, describe };
108 if (typeof module !== 'undefined') module.exports = api; else root.BeachHuts = api;
109 })(typeof window !== 'undefined' ? window : globalThis);
beach-huts/row.test.mjs added +135
@@ -0,0 +1,135 @@
1 import { test, expect } from 'bun:test';
2 import { start, paint, undo, clashes } from './row.js';
3
4 const ok = r => { expect(r.ok).toBe(true); return r.state; };
5 const refused = (s, r, why) => { expect(r.ok).toBe(false); expect(r.why).toBe(why); expect(r.state).toBe(s); };
6
7 // a small solver: fewest strokes is one per bare hut; search a clash-free fill within the tins
8 function solve(s) {
9 const tins = { ...s.tins }, fill = s.huts.map(h => h.colour);
10 const go = i => {
11 if (i === fill.length) return true;
12 if (s.huts[i].locked) return fill[i] !== fill[i - 1] && go(i + 1);
13 for (const c of s.colours) {
14 if (tins[c] < 1 || c === fill[i - 1]) continue;
15 if (s.huts[i + 1] && s.huts[i + 1].locked && s.huts[i + 1].colour === c) continue;
16 tins[c]--; fill[i] = c;
17 if (go(i + 1)) return fill;
18 tins[c]++;
19 }
20 return false;
21 };
22 return go(0) ? fill : null;
23 }
24 const solveAll = s => {
25 const fill = solve(s);
26 if (!fill) return s;
27 return fill.reduce((t, c, i) => t.huts[i].locked ? t : ok(paint(t, i, c)), s);
28 };
29 const bare = s => s.huts.findIndex(h => !h.locked);
30
31 test('start: colours, tins, a row of at least eight with locked huts, and a par', () => {
32 const s = start(7, 1);
33 expect(s.colours.length).toBeGreaterThanOrEqual(3);
34 for (const c of s.colours) expect(s.tins[c]).toBeGreaterThanOrEqual(0);
35 expect(s.huts.length).toBeGreaterThanOrEqual(8);
36 expect(s.huts.some(h => h.locked && h.colour)).toBe(true);
37 expect(s.huts.some(h => !h.locked && h.colour === null)).toBe(true);
38 expect(s.par).toBeGreaterThan(0);
39 expect(s.status).toBe('playing');
40 expect(clashes(s)).toEqual([]);
41 });
42
43 test('the same seed gives the same row; another seed another', () => {
44 expect(start(42, 2)).toEqual(start(42, 2));
45 expect(start(42, 2).huts).not.toEqual(start(43, 2).huts);
46 });
47
48 test('refusals give a reason and the very same state', () => {
49 const s = start(3, 1);
50 const i = bare(s), lock = s.huts.findIndex(h => h.locked);
51 refused(s, paint(s, 99, s.colours[0]), 'no-such-hut');
52 refused(s, paint(s, -1, s.colours[0]), 'no-such-hut');
53 refused(s, paint(s, i, 'mauve'), 'no-such-colour');
54 refused(s, paint(s, lock, s.colours[0]), 'locked');
55 const dry = { ...s, tins: { ...s.tins, [s.colours[0]]: 0 } };
56 refused(dry, paint(dry, i, s.colours[0]), 'no-tins-left');
57 refused(s, undo(s), 'nothing-to-undo');
58 const over = { ...s, status: 'lost' };
59 refused(over, paint(over, i, s.colours[0]), 'game-over');
60 });
61
62 test('painting counts a stroke; repainting returns the old tin', () => {
63 const s0 = start(3, 1), i = bare(s0), [a, b] = s0.colours;
64 const s1 = ok(paint(s0, i, a));
65 expect(s1.strokes).toBe(1);
66 expect(s1.tins[a]).toBe(s0.tins[a] - 1);
67 const s2 = ok(paint(s1, i, b));
68 expect(s2.strokes).toBe(2);
69 expect(s2.tins[a]).toBe(s0.tins[a]);
70 expect(s2.tins[b]).toBe(s0.tins[b] - 1);
71 });
72
73 test('a clash is allowed, listed, and cleared by repainting', () => {
74 const s0 = start(5, 1);
75 const lock = s0.huts.findIndex((h, i) => h.locked && s0.huts[i + 1] && !s0.huts[i + 1].locked);
76 expect(lock).toBeGreaterThanOrEqual(0);
77 const c = s0.huts[lock].colour;
78 const s1 = ok(paint({ ...s0, tins: { ...s0.tins, [c]: 3 } }, lock + 1, c));
79 expect(clashes(s1)).toEqual([[lock, lock + 1]]);
80 const other = s0.colours.find(x => x !== c);
81 const s2 = ok(paint({ ...s1, tins: { ...s1.tins, [other]: 3 } }, lock + 1, other));
82 expect(clashes(s2)).toEqual([]);
83 });
84
85 test('win line reports strokes against par', () => {
86 const w = solveAll(start(11, 1));
87 expect(w.status).toBe('won');
88 expect(w.line).toBe(`Row painted: ${w.strokes} strokes against par ${w.par}`);
89 expect(w.strokes).toBeLessThanOrEqual(w.par);
90 refused(w, paint(w, bare(w), w.colours[0]), 'game-over');
91 });
92
93 test('going over par is a loss with a loss line; undo steps back', () => {
94 let s = start(11, 1);
95 const i = bare(s);
96 const [a, b] = s.colours;
97 s = { ...s, tins: { ...s.tins, [a]: 99, [b]: 99 } };
98 let guard = 0;
99 while (s.status === 'playing' && guard++ < 50) s = ok(paint(s, i, guard % 2 ? a : b));
100 expect(s.status).toBe('lost');
101 expect(s.strokes).toBe(s.par + 1);
102 expect(s.line).toBe(`Over par: ${s.strokes} strokes against par ${s.par}, the row is not done`);
103 const u = ok(undo(s));
104 expect(u.strokes).toBe(s.par);
105 expect(u.status).toBe('playing');
106 expect(u.tins).not.toEqual(s.tins);
107 });
108
109 test('undo of a single stroke restores the starting state exactly', () => {
110 const f = start(1, 1), c = f.colours.find(x => f.tins[x] > 0);
111 expect(c).toBeDefined();
112 const p = ok(paint(f, bare(f), c));
113 expect(p.strokes).toBe(1);
114 expect(ok(undo(p))).toEqual(f);
115 });
116
117 test('painting a hut the colour it already is refuses, costing no stroke or tin', () => {
118 const s0 = start(3, 1), i = bare(s0), a = s0.colours.find(x => s0.tins[x] > 0);
119 const s1 = ok(paint(s0, i, a));
120 refused(s1, paint(s1, i, a), 'same-colour');
121 expect(s1.strokes).toBe(1);
122 expect(s1.tins).toEqual({ ...s0.tins, [a]: s0.tins[a] - 1 });
123 });
124
125 test('every one of the first twenty seeds is solvable within par, at every level', () => {
126 for (let level = 1; level <= 5; level++) {
127 for (let seed = 1; seed <= 20; seed++) {
128 const s = start(seed, level);
129 expect(solve(s)).not.toBeNull();
130 const w = solveAll(s);
131 expect(w.status).toBe('won');
132 expect(w.strokes).toBeLessThanOrEqual(s.par);
133 }
134 }
135 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs