Public: anyone can read this; the crew can change it.
Migrated from github.com/amirmushichge/pixel-plays
Carrot Pull rules: a tested `carrot-pull/bed.js` where you pull carrots when ripe before a seeded rabbit nibbles them, in a two-file pull with a test file that runs in under a second #340
Check · Review · Land · Live done — Landed and live in production.
torque/pixel-plays:torque/327-carrot-pull-rules-a-tested-carrot-pull-b → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +272
−0
carrot-pull/bed.js added +127
| @@ -0,0 +1,127 @@ | ||
| 1 | 'use strict'; | |
| 2 | // The Carrot Pull rules: a vegetable bed of carrots that sprout and grow on a seeded schedule. Walk the gardener | |
| 3 | // and pull each carrot while it is ripe, before the seeded rabbit reaches it. Plain data in, plain data out, no DOM, | |
| 4 | // no timers, no Math.random: sowing times and the rabbit's whole path come from the seed. A refused call returns the | |
| 5 | // very same state and says why. | |
| 6 | // | |
| 7 | // Units: time is whole ticks; a square is (row, col), row 0 at the top, col 0 at the left. A carrot sown at tick `sowAt` | |
| 8 | // has age = tick - sowAt; before age 0 the square is empty soil. | |
| 9 | (function (root) { | |
| 10 | const ROWS = 4; | |
| 11 | const COLS = 6; | |
| 12 | const DAY = 40; // ticks in a day | |
| 13 | const SOW_MAX = 9; // each carrot is sown at a tick in [0, SOW_MAX] | |
| 14 | // Growth stages in order with their durations in ticks; the last (woody) lasts for the rest of the day. | |
| 15 | const STAGES = [['seed', 3], ['sprout', 4], ['ripe', 5], ['woody', Infinity]]; | |
| 16 | const SCORE = { seed: 0, sprout: 0, ripe: 5, woody: 1 }; | |
| 17 | const DIRS = { up: [-1, 0], down: [1, 0], left: [0, -1], right: [0, 1] }; | |
| 18 | const ROW_HOP = 0.2; // chance per tick that the rabbit hops to a neighbouring row instead of along | |
| 19 | ||
| 20 | // mulberry32 as a pure step: next(seed) -> [value in [0,1), nextSeed] | |
| 21 | function next(seed) { | |
| 22 | const a = (seed + 0x6D2B79F5) >>> 0; | |
| 23 | let t = a; | |
| 24 | t = Math.imul(t ^ (t >>> 15), t | 1); | |
| 25 | t ^= t + Math.imul(t ^ (t >>> 7), t | 61); | |
| 26 | return [((t ^ (t >>> 14)) >>> 0) / 4294967296, a]; | |
| 27 | } | |
| 28 | ||
| 29 | const reject = (state, why) => ({ ok: false, state, why }); | |
| 30 | ||
| 31 | // stageOf(sowAt, tick) -> 'seed' | 'sprout' | 'ripe' | 'woody', or null before the carrot is sown | |
| 32 | function stageOf(sowAt, tick) { | |
| 33 | let age = tick - sowAt; | |
| 34 | if (age < 0) return null; | |
| 35 | for (const [name, len] of STAGES) { | |
| 36 | if (age < len) return name; | |
| 37 | age -= len; | |
| 38 | } | |
| 39 | return 'woody'; | |
| 40 | } | |
| 41 | ||
| 42 | const carrotAt = (s, row, col) => s.carrots[row * s.cols + col]; | |
| 43 | ||
| 44 | function describe(s) { | |
| 45 | if (s.status === 'over') { | |
| 46 | return `Day over: score ${s.score}, ${s.ripePulled} carrots pulled ripe, ${s.lost} lost to the rabbit`; | |
| 47 | } | |
| 48 | return `Tick ${s.tick} of ${s.day}: score ${s.score}, ${s.ripePulled} pulled ripe, ${s.lost} lost to the rabbit`; | |
| 49 | } | |
| 50 | ||
| 51 | // start(seed) -> a fresh day | |
| 52 | function start(seed) { | |
| 53 | const sd = Number.isFinite(seed) ? Math.trunc(seed) >>> 0 : 0; | |
| 54 | let rng = sd, r; | |
| 55 | const draw = (lo, hi) => { [r, rng] = next(rng); return lo + Math.floor(r * (hi - lo + 1)); }; | |
| 56 | const carrots = []; | |
| 57 | for (let row = 0; row < ROWS; row++) { | |
| 58 | for (let col = 0; col < COLS; col++) carrots.push({ row, col, sowAt: draw(0, SOW_MAX), fate: null }); | |
| 59 | } | |
| 60 | // the rabbit's whole path, one square per tick, hopping along its row and now and then to a neighbouring row | |
| 61 | let row = draw(0, ROWS - 1), col = draw(0, COLS - 1), dir = draw(0, 1) ? 1 : -1; | |
| 62 | const path = [[row, col]]; | |
| 63 | for (let t = 0; t < DAY; t++) { | |
| 64 | [r, rng] = next(rng); | |
| 65 | if (r < ROW_HOP) { | |
| 66 | const up = row === 0 ? false : row === ROWS - 1 ? true : draw(0, 1) === 0; | |
| 67 | row += up ? -1 : 1; | |
| 68 | } else { | |
| 69 | if (col + dir < 0 || col + dir >= COLS) dir = -dir; | |
| 70 | col += dir; | |
| 71 | } | |
| 72 | path.push([row, col]); | |
| 73 | } | |
| 74 | const s = { | |
| 75 | seed: sd, rows: ROWS, cols: COLS, day: DAY, | |
| 76 | stages: STAGES.map(([name, len]) => ({ name, ticks: Number.isFinite(len) ? len : null })), | |
| 77 | scores: { ...SCORE }, tick: 0, carrots, gardener: { row: 0, col: 0 }, rabbitPath: path, | |
| 78 | rabbit: { row: path[0][0], col: path[0][1] }, | |
| 79 | score: 0, ripePulled: 0, lost: 0, status: 'playing', line: '', | |
| 80 | }; | |
| 81 | s.line = describe(s); | |
| 82 | return s; | |
| 83 | } | |
| 84 | ||
| 85 | // step(state) -> { ok, state, why }: one tick; carrots grow, the rabbit hops and nibbles a ripe or woody carrot | |
| 86 | function step(state) { | |
| 87 | if (state.status !== 'playing') return reject(state, 'day-over'); | |
| 88 | const tick = state.tick + 1; | |
| 89 | const [row, col] = state.rabbitPath[tick]; | |
| 90 | const s = { ...state, tick, carrots: [...state.carrots], rabbit: { row, col } }; | |
| 91 | const c = carrotAt(s, row, col); | |
| 92 | const stage = c.fate === null ? stageOf(c.sowAt, tick) : null; | |
| 93 | if (stage === 'ripe' || stage === 'woody') { | |
| 94 | s.carrots[row * s.cols + col] = { ...c, fate: 'nibbled' }; | |
| 95 | s.lost += 1; | |
| 96 | } | |
| 97 | if (tick >= s.day) s.status = 'over'; | |
| 98 | s.line = describe(s); | |
| 99 | return { ok: true, state: s, why: '' }; | |
| 100 | } | |
| 101 | ||
| 102 | // walk(state, dir) -> { ok, state, why }: the gardener moves one square (up, down, left or right) | |
| 103 | function walk(state, dir) { | |
| 104 | if (state.status !== 'playing') return reject(state, 'day-over'); | |
| 105 | if (typeof dir !== 'string' || !Object.hasOwn(DIRS, dir)) return reject(state, 'unknown-direction'); | |
| 106 | const row = state.gardener.row + DIRS[dir][0], col = state.gardener.col + DIRS[dir][1]; | |
| 107 | if (row < 0 || row >= state.rows || col < 0 || col >= state.cols) return reject(state, 'off-bed'); | |
| 108 | return { ok: true, state: { ...state, gardener: { row, col } }, why: '' }; | |
| 109 | } | |
| 110 | ||
| 111 | // pull(state) -> { ok, state, why }: pull the carrot on the gardener's square; scores by its stage | |
| 112 | function pull(state) { | |
| 113 | if (state.status !== 'playing') return reject(state, 'day-over'); | |
| 114 | const { row, col } = state.gardener; | |
| 115 | const c = carrotAt(state, row, col); | |
| 116 | const stage = c.fate === null ? stageOf(c.sowAt, state.tick) : null; | |
| 117 | if (!stage) return reject(state, 'nothing-to-pull'); | |
| 118 | const s = { ...state, carrots: [...state.carrots], score: state.score + state.scores[stage] }; | |
| 119 | s.carrots[row * s.cols + col] = { ...c, fate: 'pulled' }; | |
| 120 | if (stage === 'ripe') s.ripePulled += 1; | |
| 121 | s.line = describe(s); | |
| 122 | return { ok: true, state: s, why: '' }; | |
| 123 | } | |
| 124 | ||
| 125 | const api = { ROWS, COLS, DAY, SOW_MAX, STAGES, SCORE, start, step, walk, pull, stageOf, describe }; | |
| 126 | if (typeof module !== 'undefined') module.exports = api; else root.CarrotPull = api; | |
| 127 | })(typeof window !== 'undefined' ? window : globalThis); |
carrot-pull/bed.test.mjs added +145
| @@ -0,0 +1,145 @@ | ||
| 1 | import { test, expect } from 'bun:test'; | |
| 2 | import { ROWS, COLS, DAY, SOW_MAX, STAGES, SCORE, start, step, walk, pull, stageOf } from './bed.js'; | |
| 3 | ||
| 4 | const ok = r => { expect(r.ok).toBe(true); return r.state; }; | |
| 5 | const ticks = (s, n) => { for (let i = 0; i < n; i++) s = ok(step(s)); return s; }; | |
| 6 | // a state with the rabbit parked far away (row 3, col 5 forever) and every carrot but the parked-on one sown at tick `sowAt` | |
| 7 | const rig = (sowAt = 0, extra = {}) => { | |
| 8 | const s = start(1); | |
| 9 | return { | |
| 10 | ...s, carrots: s.carrots.map((c, i) => ({ ...c, sowAt: i === ROWS * COLS - 1 ? 999 : sowAt })), rabbitPath: s.rabbitPath.map(() => [ROWS - 1, COLS - 1]), | |
| 11 | rabbit: { row: ROWS - 1, col: COLS - 1 }, ...extra, | |
| 12 | }; | |
| 13 | }; | |
| 14 | ||
| 15 | test('start: stated bed, stages, day, gardener, rabbit and sowing times', () => { | |
| 16 | const s = start(7); | |
| 17 | expect([s.rows, s.cols, s.day]).toEqual([ROWS, COLS, DAY]); | |
| 18 | expect(s.carrots.length).toBe(ROWS * COLS); | |
| 19 | for (const c of s.carrots) { expect(c.sowAt).toBeGreaterThanOrEqual(0); expect(c.sowAt).toBeLessThanOrEqual(SOW_MAX); } | |
| 20 | expect(s.stages.map(g => g.name)).toEqual(['seed', 'sprout', 'ripe', 'woody']); | |
| 21 | expect(s.stages.map(g => g.ticks)).toEqual([3, 4, 5, null]); | |
| 22 | expect(s.gardener).toEqual({ row: 0, col: 0 }); | |
| 23 | expect(s.rabbitPath.length).toBe(DAY + 1); | |
| 24 | expect(s.rabbit).toEqual({ row: s.rabbitPath[0][0], col: s.rabbitPath[0][1] }); | |
| 25 | expect(s.status).toBe('playing'); | |
| 26 | }); | |
| 27 | ||
| 28 | test('the same seed gives the same sowing, rabbit path and day; another seed another', () => { | |
| 29 | expect(start(42)).toEqual(start(42)); | |
| 30 | expect(start(42).carrots).not.toEqual(start(43).carrots); | |
| 31 | expect(start(42).rabbitPath).not.toEqual(start(43).rabbitPath); | |
| 32 | const run = seed => { let s = start(seed); while (s.status === 'playing') s = ok(step(s)); return s; }; | |
| 33 | expect(run(5)).toEqual(run(5)); | |
| 34 | expect(start(42).seed).toBe(42); | |
| 35 | }); | |
| 36 | ||
| 37 | test('the rabbit hops one square a tick and stays on the bed', () => { | |
| 38 | const p = start(9).rabbitPath; | |
| 39 | for (let i = 0; i < p.length; i++) { | |
| 40 | expect(p[i][0]).toBeGreaterThanOrEqual(0); expect(p[i][0]).toBeLessThan(ROWS); | |
| 41 | expect(p[i][1]).toBeGreaterThanOrEqual(0); expect(p[i][1]).toBeLessThan(COLS); | |
| 42 | if (i) expect(Math.abs(p[i][0] - p[i - 1][0]) + Math.abs(p[i][1] - p[i - 1][1])).toBe(1); | |
| 43 | } | |
| 44 | }); | |
| 45 | ||
| 46 | test('each growth stage at its stated tick', () => { | |
| 47 | // sown at 2: seed ticks 2-4, sprout 5-8, ripe 9-13, woody from 14 | |
| 48 | const at = t => stageOf(2, t); | |
| 49 | expect([0, 1].map(at)).toEqual([null, null]); | |
| 50 | expect([2, 4].map(at)).toEqual(['seed', 'seed']); | |
| 51 | expect([5, 8].map(at)).toEqual(['sprout', 'sprout']); | |
| 52 | expect([9, 13].map(at)).toEqual(['ripe', 'ripe']); | |
| 53 | expect([14, 40].map(at)).toEqual(['woody', 'woody']); | |
| 54 | expect(STAGES.map(g => g[0])).toEqual(['seed', 'sprout', 'ripe', 'woody']); | |
| 55 | }); | |
| 56 | ||
| 57 | test('a ripe pull scores the stated amount and empties the square', () => { | |
| 58 | const s = ticks(rig(0), 3 + 4); // age 7: ripe | |
| 59 | expect(stageOf(0, s.tick)).toBe('ripe'); | |
| 60 | const t = ok(pull(s)); | |
| 61 | expect([t.score, t.ripePulled]).toEqual([SCORE.ripe, 1]); | |
| 62 | expect(SCORE.ripe).toBe(5); | |
| 63 | expect(t.carrots[0].fate).toBe('pulled'); | |
| 64 | expect(pull(t)).toEqual({ ok: false, state: t, why: 'nothing-to-pull' }); | |
| 65 | expect(s.score).toBe(0); // input not mutated | |
| 66 | }); | |
| 67 | ||
| 68 | test('a sprout pull scores nothing and leaves the square empty', () => { | |
| 69 | const s = ticks(rig(0), 4); // age 4: sprout | |
| 70 | expect(stageOf(0, s.tick)).toBe('sprout'); | |
| 71 | const t = ok(pull(s)); | |
| 72 | expect([t.score, t.ripePulled]).toEqual([0, 0]); | |
| 73 | expect(pull(t).why).toBe('nothing-to-pull'); | |
| 74 | }); | |
| 75 | ||
| 76 | test('a woody pull scores the stated lesser amount', () => { | |
| 77 | const s = ticks(rig(0), 13); // age 13: woody | |
| 78 | expect(stageOf(0, s.tick)).toBe('woody'); | |
| 79 | const t = ok(pull(s)); | |
| 80 | expect(t.score).toBe(SCORE.woody); | |
| 81 | expect(SCORE.woody).toBeGreaterThan(0); | |
| 82 | expect(SCORE.woody).toBeLessThan(SCORE.ripe); | |
| 83 | expect(t.ripePulled).toBe(0); | |
| 84 | }); | |
| 85 | ||
| 86 | test('the rabbit nibbles a ripe carrot and passes over a sprout', () => { | |
| 87 | const base = rig(0); | |
| 88 | // rabbit at square (1,1) on tick 7 (carrot age 7: ripe), and at (2,2) on tick 4 (age 4: sprout) | |
| 89 | const path = base.rabbitPath.map(() => [3, 5]); | |
| 90 | path[4] = [2, 2]; path[7] = [1, 1]; | |
| 91 | const s = ticks({ ...base, rabbitPath: path }, 7); | |
| 92 | expect(s.carrots[1 * COLS + 1].fate).toBe('nibbled'); | |
| 93 | expect(s.carrots[2 * COLS + 2].fate).toBe(null); | |
| 94 | expect(s.lost).toBe(1); | |
| 95 | // a nibbled carrot cannot be pulled | |
| 96 | const g = { ...s, gardener: { row: 1, col: 1 } }; | |
| 97 | expect(pull(g)).toEqual({ ok: false, state: g, why: 'nothing-to-pull' }); | |
| 98 | }); | |
| 99 | ||
| 100 | test('the rabbit also nibbles a woody carrot, not twice', () => { | |
| 101 | const base = rig(0); | |
| 102 | const path = base.rabbitPath.map(() => [3, 5]); | |
| 103 | path[13] = [0, 0]; path[14] = [0, 0]; | |
| 104 | const s = ticks({ ...base, rabbitPath: path }, 14); | |
| 105 | expect(s.carrots[0].fate).toBe('nibbled'); | |
| 106 | expect(s.lost).toBe(1); | |
| 107 | }); | |
| 108 | ||
| 109 | test('walk moves one square; refusals give a reason and return the same state', () => { | |
| 110 | const s = rig(); | |
| 111 | const unsown = rig(5); // nothing sown yet at tick 0 | |
| 112 | const t = ok(walk(ok(walk(s, 'right')), 'down')); | |
| 113 | expect(t.gardener).toEqual({ row: 1, col: 1 }); | |
| 114 | expect(s.gardener).toEqual({ row: 0, col: 0 }); | |
| 115 | for (const [r, why] of [ | |
| 116 | [walk(s, 'diagonal'), 'unknown-direction'], [walk(s, undefined), 'unknown-direction'], | |
| 117 | [walk(s, '__proto__'), 'unknown-direction'], | |
| 118 | [walk(s, 'up'), 'off-bed'], [walk(s, 'left'), 'off-bed'], | |
| 119 | [pull(unsown), 'nothing-to-pull'], | |
| 120 | ]) { expect(r.ok).toBe(false); expect(r.why).toBe(why); } | |
| 121 | expect(walk(s, 'up').state).toBe(s); | |
| 122 | expect(pull(unsown).state).toBe(unsown); | |
| 123 | const far = { ...s, gardener: { row: ROWS - 1, col: COLS - 1 } }; | |
| 124 | expect(walk(far, 'down').why).toBe('off-bed'); | |
| 125 | expect(walk(far, 'right').why).toBe('off-bed'); | |
| 126 | }); | |
| 127 | ||
| 128 | test('a day already over refuses every call', () => { | |
| 129 | const over = ticks(rig(), DAY); | |
| 130 | expect(over.status).toBe('over'); | |
| 131 | for (const r of [step(over), walk(over, 'right'), pull(over)]) { | |
| 132 | expect([r.ok, r.why, r.state]).toEqual([false, 'day-over', over]); | |
| 133 | } | |
| 134 | }); | |
| 135 | ||
| 136 | test('the day ends when the ticks run out, with an end line reporting score, ripe pulls and losses', () => { | |
| 137 | let s = ticks(rig(0), 7); | |
| 138 | s = ok(pull(s)); // 5 points, one ripe | |
| 139 | s = ticks(s, DAY - 7); | |
| 140 | expect(s.tick).toBe(DAY); | |
| 141 | expect(s.status).toBe('over'); | |
| 142 | expect(s.line).toBe('Day over: score 5, 1 carrots pulled ripe, 0 lost to the rabbit'); | |
| 143 | const mid = ticks(rig(), DAY - 1); | |
| 144 | expect(mid.status).toBe('playing'); | |
| 145 | }); |
new above
offline
Nit: '1 carrots' is ungrammatical; pluralise on the count.