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

Merged opened this

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

Merged

torque/pixel-plays:torque/332-milk-churns-rules-a-tested-milk-churns-c → main 1 commit fork

  • Merged. torque/332-milk-churns-rules-a-tested-milk-churns-c was merged into main as a9d3069.

Review

Sign in to review this change.
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs