Public: anyone can read this; the crew can change it.
Migrated from github.com/amirmushichge/pixel-plays
Milk Churns rules: a tested `milk-churns/churns.js` where you pour milk between three churns to leave an exact amount within par, in a two-file pull with a test file that runs in under a second #353
Check · Review · Land · Live done — Landed and live in production.
torque/pixel-plays:torque/332-milk-churns-rules-a-tested-milk-churns-c → main 1 commit fork
2 files
changed +188
−0
milk-churns/churns.js added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | 'use strict'; | |
| 2 | // The Milk Churns rules: three churns of stated capacity, the largest full. Pour one into another until the source | |
| 3 | // is empty or the target is full; leave exactly the target amount in any churn, within par. Plain data in, plain | |
| 4 | // data out, no DOM, no timers, no randomness. A refused call returns the very same state and says why. | |
| 5 | // | |
| 6 | // Units: litres, whole numbers. Rule: a pour from f to t moves m = min(fill[f], cap[t] - fill[t]) litres, | |
| 7 | // so the total Σ fill never changes. Won when some fill[i] === target. | |
| 8 | (function (root) { | |
| 9 | // Each level: name, capacities, starting fill, target litres and a stated solution ([from, to] churn indexes). | |
| 10 | // Par = the solution's length (each solution is a shortest one, found by breadth-first search). | |
| 11 | const LEVELS = [ | |
| 12 | { name: 'Quick measure', caps: [3, 5, 8], fill: [0, 0, 8], target: 2, solution: [[2, 1], [1, 0]] }, | |
| 13 | { name: 'Spare pail', caps: [5, 8, 13], fill: [0, 0, 13], target: 3, solution: [[2, 1], [1, 0]] }, | |
| 14 | { name: 'Big batch', caps: [6, 9, 15], fill: [0, 0, 15], target: 12, solution: [[2, 1], [1, 0], [0, 2]] }, | |
| 15 | { name: 'Single litre', caps: [3, 5, 8], fill: [0, 0, 8], target: 1, solution: [[2, 0], [0, 1], [2, 0], [0, 1]] }, | |
| 16 | { name: 'Half and half', caps: [3, 5, 8], fill: [0, 0, 8], target: 4, solution: [[2, 1], [1, 0], [0, 2], [1, 0], [2, 1], [1, 0]] }, | |
| 17 | { name: 'Market stall', caps: [4, 7, 11], fill: [0, 0, 11], target: 6, solution: [[2, 1], [1, 0], [0, 2], [1, 0], [2, 1], [1, 0]] }, | |
| 18 | { name: 'Long pour', caps: [3, 7, 10], fill: [0, 0, 10], target: 5, solution: [[2, 1], [1, 0], [0, 2], [1, 0], [0, 2], [1, 0], [2, 1], [1, 0]] }, | |
| 19 | ].map(l => ({ ...l, par: l.solution.length })); | |
| 20 | ||
| 21 | const reject = (state, why) => ({ ok: false, state, why }); | |
| 22 | const isChurn = (s, i) => Number.isInteger(i) && i >= 0 && i < s.caps.length; | |
| 23 | const pl = n => `${n} ${n === 1 ? 'pour' : 'pours'}`; | |
| 24 | ||
| 25 | function describe(s) { | |
| 26 | if (s.status === 'won') { | |
| 27 | const d = s.pours - s.par; | |
| 28 | const vs = d === 0 ? 'at par' : d < 0 ? `${-d} under par` : `${d} over par`; | |
| 29 | return `You win: ${s.target} L left in a churn in ${pl(s.pours)} against par ${s.par}, ${vs}`; | |
| 30 | } | |
| 31 | return `${pl(s.pours)} of par ${s.par}, aiming for ${s.target} L`; | |
| 32 | } | |
| 33 | ||
| 34 | const judge = s => { | |
| 35 | s.status = s.fill.some(f => f === s.target) ? 'won' : 'playing'; | |
| 36 | s.line = describe(s); | |
| 37 | return s; | |
| 38 | }; | |
| 39 | ||
| 40 | // start(level) -> a fresh state for level index 0..LEVELS.length-1; anything else starts level 0 | |
| 41 | function start(level) { | |
| 42 | const i = Number.isInteger(level) && level >= 0 && level < LEVELS.length ? level : 0; | |
| 43 | const l = LEVELS[i]; | |
| 44 | return judge({ | |
| 45 | level: i, name: l.name, caps: [...l.caps], fill: [...l.fill], target: l.target, par: l.par, | |
| 46 | history: [], pours: 0, status: 'playing', line: '', | |
| 47 | }); | |
| 48 | } | |
| 49 | ||
| 50 | // pour(state, from, to) -> { ok, state, why } | |
| 51 | function pour(state, from, to) { | |
| 52 | if (state.status === 'won') return reject(state, 'game-over'); | |
| 53 | if (!isChurn(state, from) || !isChurn(state, to)) return reject(state, 'unknown-churn'); | |
| 54 | if (from === to) return reject(state, 'same-churn'); | |
| 55 | if (state.fill[from] === 0) return reject(state, 'empty-source'); | |
| 56 | if (state.fill[to] === state.caps[to]) return reject(state, 'full-target'); | |
| 57 | const m = Math.min(state.fill[from], state.caps[to] - state.fill[to]); | |
| 58 | const fill = [...state.fill]; | |
| 59 | fill[from] -= m; fill[to] += m; | |
| 60 | return { ok: true, state: judge({ ...state, fill, history: [...state.history, { from, to, moved: m }], pours: state.pours + 1 }), why: '' }; | |
| 61 | } | |
| 62 | ||
| 63 | // undo(state) -> { ok, state, why }: takes back the last pour (also the winning one) | |
| 64 | function undo(state) { | |
| 65 | if (!state.history.length) return reject(state, 'nothing-to-undo'); | |
| 66 | const { from, to, moved } = state.history[state.history.length - 1]; | |
| 67 | const fill = [...state.fill]; | |
| 68 | fill[from] += moved; fill[to] -= moved; | |
| 69 | return { ok: true, state: judge({ ...state, fill, history: state.history.slice(0, -1), pours: state.pours - 1 }), why: '' }; | |
| 70 | } | |
| 71 | ||
| 72 | // restart(state) -> the same level from the beginning; always allowed | |
| 73 | const restart = state => start(state.level); | |
| 74 | ||
| 75 | const api = { LEVELS, start, pour, undo, restart, describe }; | |
| 76 | if (typeof module !== 'undefined') module.exports = api; else root.MilkChurns = api; | |
| 77 | })(typeof window !== 'undefined' ? window : globalThis); |
milk-churns/churns.test.mjs added +111
| @@ -0,0 +1,111 @@ | ||
| 1 | import { test, expect } from 'bun:test'; | |
| 2 | import { LEVELS, start, pour, undo, restart } from './churns.js'; | |
| 3 | ||
| 4 | const ok = r => { expect(r.ok).toBe(true); return r.state; }; | |
| 5 | const run = (s, pours) => pours.reduce((t, [f, g]) => ok(pour(t, f, g)), s); | |
| 6 | const solve = i => run(start(i), LEVELS[i].solution); | |
| 7 | const total = s => s.fill.reduce((a, b) => a + b, 0); | |
| 8 | ||
| 9 | test('at least six levels: name, three capacities, largest full, target, solution, par', () => { | |
| 10 | expect(LEVELS.length).toBeGreaterThanOrEqual(6); | |
| 11 | LEVELS.forEach((l, i) => { | |
| 12 | const s = start(i); | |
| 13 | expect(typeof l.name).toBe('string'); | |
| 14 | expect(l.caps.length).toBe(3); | |
| 15 | expect(l.fill.length).toBe(3); | |
| 16 | expect(l.fill).toEqual(l.caps.map(c => (c === Math.max(...l.caps) ? c : 0))); | |
| 17 | expect(l.fill.includes(l.target)).toBe(false); | |
| 18 | expect(s.par).toBe(l.solution.length); | |
| 19 | expect([s.pours, s.status]).toEqual([0, 'playing']); | |
| 20 | }); | |
| 21 | }); | |
| 22 | ||
| 23 | test('each level: the stated solution wins within par', () => { | |
| 24 | LEVELS.forEach((l, i) => { | |
| 25 | const s = solve(i); | |
| 26 | expect(s.status).toBe('won'); | |
| 27 | expect(s.fill.includes(l.target)).toBe(true); | |
| 28 | expect(s.pours).toBeLessThanOrEqual(s.par); | |
| 29 | }); | |
| 30 | }); | |
| 31 | ||
| 32 | test('a pour stops when the source is empty', () => { | |
| 33 | const s = start(0); // caps 3,5,8, fill 0,0,8 | |
| 34 | const a = ok(pour(s, 2, 1)); // 8 -> 5 pail: target fills at 5, 3 remain | |
| 35 | expect(a.fill).toEqual([0, 5, 3]); | |
| 36 | const b = ok(pour(a, 2, 0)); // 3 into empty 3-churn: source empties exactly | |
| 37 | expect(b.fill).toEqual([3, 5, 0]); | |
| 38 | const c = ok(pour(ok(pour(start(1), 2, 0)), 0, 1)); // 5 into the 8: source empties, target not full | |
| 39 | expect(c.fill).toEqual([0, 5, 8]); | |
| 40 | }); | |
| 41 | ||
| 42 | test('a pour stops when the target is full', () => { | |
| 43 | const a = ok(pour(start(0), 2, 0)); | |
| 44 | expect(a.fill).toEqual([3, 0, 5]); | |
| 45 | }); | |
| 46 | ||
| 47 | test('total milk is conserved after every pour', () => { | |
| 48 | LEVELS.forEach((l, i) => { | |
| 49 | let s = start(i); | |
| 50 | const t = total(s); | |
| 51 | for (const [f, g] of l.solution) { | |
| 52 | s = ok(pour(s, f, g)); | |
| 53 | expect(total(s)).toBe(t); | |
| 54 | s.fill.forEach((x, k) => { expect(x).toBeGreaterThanOrEqual(0); expect(x).toBeLessThanOrEqual(s.caps[k]); }); | |
| 55 | } | |
| 56 | }); | |
| 57 | }); | |
| 58 | ||
| 59 | test('refusals return the same state with a reason', () => { | |
| 60 | const s = start(0); | |
| 61 | for (const [f, g] of [[-1, 0], [0, 3], [1.5, 0], ['0', 1], [undefined, 1], [0, NaN]]) { | |
| 62 | const r = pour(s, f, g); expect([r.ok, r.why, r.state]).toEqual([false, 'unknown-churn', s]); | |
| 63 | } | |
| 64 | let r = pour(s, 1, 1); expect([r.ok, r.why, r.state]).toEqual([false, 'same-churn', s]); | |
| 65 | r = pour(s, 0, 1); expect([r.ok, r.why, r.state]).toEqual([false, 'empty-source', s]); | |
| 66 | const full = ok(pour(s, 2, 1)); // churn 1 now full | |
| 67 | r = pour(full, 2, 1); expect([r.ok, r.why, r.state]).toEqual([false, 'full-target', full]); | |
| 68 | r = undo(s); expect([r.ok, r.why, r.state]).toEqual([false, 'nothing-to-undo', s]); | |
| 69 | const won = solve(0); | |
| 70 | r = pour(won, 2, 0); expect([r.ok, r.why, r.state]).toEqual([false, 'game-over', won]); | |
| 71 | }); | |
| 72 | ||
| 73 | test('pour does not mutate its input', () => { | |
| 74 | const s = start(0), before = JSON.stringify(s); | |
| 75 | ok(pour(s, 2, 1)); | |
| 76 | expect(JSON.stringify(s)).toBe(before); | |
| 77 | }); | |
| 78 | ||
| 79 | test('undo restores the exact previous state', () => { | |
| 80 | const a = start(4), b = ok(pour(a, 2, 1)), c = ok(pour(b, 1, 0)); | |
| 81 | expect(ok(undo(c))).toEqual(b); | |
| 82 | expect(ok(undo(b))).toEqual(a); | |
| 83 | const won = solve(0); | |
| 84 | expect(ok(undo(won)).status).toBe('playing'); | |
| 85 | expect(ok(undo(won))).toEqual(run(start(0), LEVELS[0].solution.slice(0, -1))); | |
| 86 | }); | |
| 87 | ||
| 88 | test('win line reports pours against par: at, under and over', () => { | |
| 89 | const at = solve(0); | |
| 90 | expect(at.line).toContain('at par'); | |
| 91 | expect(at.line).toContain('2 pours against par 2'); | |
| 92 | // under par: a hand-made level state whose par is above the pours taken | |
| 93 | const under = { ...at, par: at.pours + 1 }; | |
| 94 | expect(require('./churns.js').describe(under)).toContain('1 under par'); | |
| 95 | // over par: wander first, then win (3-churn: 8->5 pail, back, then the solution) | |
| 96 | const wander = run(start(0), [[2, 1], [1, 2]]); | |
| 97 | const over = run(wander, LEVELS[0].solution); | |
| 98 | expect(over.status).toBe('won'); | |
| 99 | expect(over.pours).toBe(4); | |
| 100 | expect(over.line).toContain('2 over par'); | |
| 101 | }); | |
| 102 | ||
| 103 | test('restart gives the level fresh, from any state', () => { | |
| 104 | const mid = run(start(3), LEVELS[3].solution.slice(0, 2)); | |
| 105 | expect(restart(mid)).toEqual(start(3)); | |
| 106 | expect(restart(solve(2))).toEqual(start(2)); | |
| 107 | }); | |
| 108 | ||
| 109 | test('start falls back to level 0 for an unknown level', () => { | |
| 110 | for (const x of [-1, 99, 1.5, 'a', undefined]) expect(start(x)).toEqual(start(0)); | |
| 111 | }); |
new above
offline