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

Migrated from github.com/amirmushichge/pixel-plays

Pumpkin Patch rules: a tested `pumpkin-patch/plots.js` where each pour waters a plot and its neighbours to bring every plot to exactly its need within par, in a two-file pull with a test file that runs in under a second #326

Merged opened this

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

Merged

torque/pixel-plays:torque/319-pumpkin-patch-rules-a-tested-pumpkin-pat → main 1 commit fork

  • Merged. torque/319-pumpkin-patch-rules-a-tested-pumpkin-pat was merged into main as 79372dd.

Review

Sign in to review this change.
2 files changed +182 −0
pumpkin-patch/plots.js added +93
@@ -0,0 +1,93 @@
1 'use strict';
2 // The Pumpkin Patch rules: pour the watering can on a plot to water it and its four neighbours by one each,
3 // until every plot has exactly the waterings its pumpkin needs. Plain data in, plain data out, no DOM, no timers,
4 // no randomness. A refused call returns the very same state and says why.
5 //
6 // Units: needs and waterings are whole numbers of waterings; a plot is its row-major index, row * cols + col.
7 // Rule: plot p after the pours is water(p) = Σ over pours q of [q = p or q is a grid neighbour of p] (stone never counts).
8 // A plot rots when water(p) > need(p); the level is solved when water(p) = need(p) for every non-stone plot.
9 (function (root) {
10 // Each level: grid size, need per plot (null = stone: takes no water, skipped by pours), a stated solution
11 // (plots poured on, in any order, since waterings only add) and par = the solution's pours.
12 const LEVELS = [
13 { name: 'First drop', rows: 3, cols: 3, need: [0, 1, 0, 1, 1, 1, 0, 1, 0], solution: [4] },
14 { name: 'Diagonal', rows: 3, cols: 3, need: [1, 2, 0, 2, 1, 2, 0, 2, 1], solution: [0, 4, 8] },
15 { name: 'Well stone', rows: 3, cols: 3, need: [2, 1, 2, 1, null, 1, 2, 1, 2], solution: [1, 3, 5, 7] },
16 { name: 'Wide field', rows: 3, cols: 4, need: [2, 2, 3, 0, 0, 3, 2, 1, 0, 1, 2, null], solution: [1, 1, 6, 10] },
17 { name: 'Square plot', rows: 4, cols: 4, need: [1, 2, 1, 0, 2, 3, 3, 1, 1, 3, 3, 1, 0, 1, 1, null], solution: [5, 6, 9, 10, 0] },
18 { name: 'Four corners', rows: 4, cols: 4, need: [1, 2, 1, 1, 2, 1, null, 1, 1, null, 1, 2, 1, 1, 2, 1], solution: [0, 3, 12, 15, 5, 10] },
19 ].map(l => ({ ...l, par: l.solution.length }));
20
21 const reject = (state, why) => ({ ok: false, state, why });
22 const isStone = (s, p) => s.need[p] === null;
23 const inGrid = (s, p) => Number.isInteger(p) && p >= 0 && p < s.rows * s.cols;
24
25 // reach(state, plot) -> the plots a pour on `plot` waters: itself and its in-grid, non-stone neighbours
26 function reach(s, p) {
27 const r = Math.floor(p / s.cols), c = p % s.cols, out = [p];
28 if (r > 0) out.push(p - s.cols);
29 if (r < s.rows - 1) out.push(p + s.cols);
30 if (c > 0) out.push(p - 1);
31 if (c < s.cols - 1) out.push(p + 1);
32 return out.filter(q => !isStone(s, q));
33 }
34
35 const name = (s, p) => `row ${Math.floor(p / s.cols) + 1}, column ${(p % s.cols) + 1}`;
36 const pl = n => `${n} ${n === 1 ? 'pour' : 'pours'}`;
37
38 function describe(s) {
39 if (s.status === 'lost') return `You lose: the pumpkin at ${name(s, s.rotten)} rotted from too much water`;
40 if (s.status === 'won') {
41 const d = s.pours - s.par;
42 const vs = d === 0 ? 'at par' : d < 0 ? `${-d} under par` : `${d} over par`;
43 return `You win: every pumpkin has its water in ${pl(s.pours)} against par ${s.par}, ${vs}`;
44 }
45 return `${pl(s.pours)} of par ${s.par}`;
46 }
47
48 // judge(s): sets rotten, status and line from the waterings
49 function judge(s) {
50 const bad = s.water.findIndex((w, p) => !isStone(s, p) && w > s.need[p]);
51 s.rotten = bad < 0 ? null : bad;
52 s.status = bad >= 0 ? 'lost' : s.water.every((w, p) => isStone(s, p) || w === s.need[p]) ? 'won' : 'playing';
53 s.line = describe(s);
54 return s;
55 }
56
57 // start(level) -> a fresh state for level index 0..LEVELS.length-1; anything else starts level 0
58 function start(level) {
59 const i = Number.isInteger(level) && level >= 0 && level < LEVELS.length ? level : 0;
60 const l = LEVELS[i];
61 const s = {
62 level: i, name: l.name, rows: l.rows, cols: l.cols, par: l.par, need: [...l.need],
63 water: l.need.map(() => 0), history: [], pours: 0, rotten: null, status: 'playing', line: '',
64 };
65 return judge(s);
66 }
67
68 // pour(state, plot) -> { ok, state, why }
69 function pour(state, plot) {
70 if (state.status !== 'playing') return reject(state, 'game-over');
71 if (!inGrid(state, plot)) return reject(state, 'off-grid');
72 if (isStone(state, plot)) return reject(state, 'stone');
73 const s = { ...state, water: [...state.water], history: [...state.history, plot] };
74 for (const q of reach(s, plot)) s.water[q] += 1;
75 s.pours += 1;
76 return { ok: true, state: judge(s), why: '' };
77 }
78
79 // undo(state) -> { ok, state, why }: takes back the last pour (also the one that rotted a pumpkin)
80 function undo(state) {
81 if (!state.history.length) return reject(state, 'nothing-to-undo');
82 const s = { ...state, water: [...state.water], history: state.history.slice(0, -1) };
83 for (const q of reach(s, state.history[state.history.length - 1])) s.water[q] -= 1;
84 s.pours -= 1;
85 return { ok: true, state: judge(s), why: '' };
86 }
87
88 // restart(state) -> the same level from the beginning; always allowed
89 const restart = state => start(state.level);
90
91 const api = { LEVELS, start, pour, undo, restart, reach, describe };
92 if (typeof module !== 'undefined') module.exports = api; else root.PumpkinPatch = api;
93 })(typeof window !== 'undefined' ? window : globalThis);
pumpkin-patch/plots.test.mjs added +89
@@ -0,0 +1,89 @@
1 import { test, expect } from 'bun:test';
2 import { LEVELS, start, pour, undo, restart, reach } from './plots.js';
3
4 const ok = r => { expect(r.ok).toBe(true); return r.state; };
5 const solve = i => LEVELS[i].solution.reduce((s, p) => ok(pour(s, p)), start(i));
6
7 test('at least six levels with size, needs, stone plots and par', () => {
8 expect(LEVELS.length).toBeGreaterThanOrEqual(6);
9 LEVELS.forEach((l, i) => {
10 const s = start(i);
11 expect(s.need.length).toBe(l.rows * l.cols);
12 expect(s.par).toBe(l.solution.length);
13 expect([s.pours, s.status, s.water.every(w => w === 0)]).toEqual([0, 'playing', true]);
14 });
15 expect(LEVELS.some(l => l.need.includes(null))).toBe(true);
16 });
17
18 test('each level: the stated solution solves it within par', () => {
19 LEVELS.forEach((l, i) => {
20 const s = solve(i);
21 expect(s.status).toBe('won');
22 expect(s.pours).toBeLessThanOrEqual(s.par);
23 });
24 });
25
26 test('centre pour waters five plots; edge and corner touch only in-grid neighbours', () => {
27 const s = start(0); // 3x3
28 expect(ok(pour(s, 4)).water).toEqual([0, 1, 0, 1, 1, 1, 0, 1, 0]);
29 expect(ok(pour(s, 1)).water).toEqual([1, 1, 1, 0, 1, 0, 0, 0, 0]); // top edge
30 expect(ok(pour(s, 0)).water).toEqual([1, 1, 0, 1, 0, 0, 0, 0, 0]); // corner
31 expect(ok(pour(s, 8)).water).toEqual([0, 0, 0, 0, 0, 1, 0, 1, 1]);
32 });
33
34 test('stone plots take no water and are skipped by pours', () => {
35 const s = start(2); // stone at 4
36 expect(reach(s, 1)).toEqual([1, 0, 2]);
37 const t = ok(pour(s, 1));
38 expect(t.water[4]).toBe(0);
39 expect(t.water).toEqual([1, 1, 1, 0, 0, 0, 0, 0, 0]);
40 });
41
42 test('refusals return the same state with a reason', () => {
43 const s = start(2);
44 for (const p of [-1, 9, 1.5, NaN, '0', undefined]) {
45 const r = pour(s, p); expect([r.ok, r.why, r.state]).toEqual([false, 'off-grid', s]);
46 }
47 const st = pour(s, 4); expect([st.ok, st.why, st.state]).toEqual([false, 'stone', s]);
48 const u = undo(s); expect([u.ok, u.why, u.state]).toEqual([false, 'nothing-to-undo', s]);
49 const won = solve(0);
50 const g = pour(won, 0); expect([g.ok, g.why, g.state]).toEqual([false, 'game-over', won]);
51 });
52
53 test('pour does not mutate its input', () => {
54 const s = start(0), before = JSON.stringify(s);
55 ok(pour(s, 4));
56 expect(JSON.stringify(s)).toBe(before);
57 });
58
59 test('undo restores the exact previous state', () => {
60 const a = start(4), b = ok(pour(a, 5)), c = ok(pour(b, 6));
61 expect(ok(undo(c))).toEqual(b);
62 expect(ok(undo(b))).toEqual(a);
63 });
64
65 test('a rot ends the level, names the plot, and undo takes it back', () => {
66 const a = start(0), b = ok(pour(a, 0)); // plot 0 needs 0
67 expect(b.status).toBe('lost');
68 expect(b.rotten).toBe(0);
69 expect(b.line).toBe('You lose: the pumpkin at row 1, column 1 rotted from too much water');
70 const g = pour(b, 4); expect([g.ok, g.why]).toEqual([false, 'game-over']);
71 expect(ok(undo(b))).toEqual(a);
72 });
73
74 test('win line reports pours against par: at, under and over', () => {
75 expect(solve(1).line).toBe('You win: every pumpkin has its water in 3 pours against par 3, at par');
76 const under = { ...start(1), par: 5 };
77 expect(LEVELS[1].solution.reduce((s, p) => ok(pour(s, p)), under).line).toContain('2 under par');
78 // level 3: pouring 1 twice, 6, 10 — an extra undo/redo pair adds pours
79 let s = start(1);
80 s = ok(pour(s, 0)); s = ok(undo(s)); s = ok(pour(s, 0));
81 s = ok(pour(s, 4)); s = ok(pour(s, 8));
82 expect(s.status).toBe('won');
83 const over = { ...start(0), par: 0 };
84 expect(ok(pour(over, 4)).line).toBe('You win: every pumpkin has its water in 1 pour against par 0, 1 over par');
85 });
86
87 test('restart is always allowed and gives the level fresh', () => {
88 for (const s of [solve(3), ok(pour(start(0), 0)), start(5)]) expect(restart(s)).toEqual(start(s.level));
89 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs