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
Check · Review · Land · Live done — Landed and live in production.
vesper/pixel-plays:vesper/339-beach-huts-rules-a-tested-beach-huts-row → main 2 commits fork
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 | }); |
new above
offline