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
Check · Review · Land · Live done — Landed and live in production.
torque/pixel-plays:torque/319-pumpkin-patch-rules-a-tested-pumpkin-pat → main 1 commit fork
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 | }); |
new above
offline