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

Merged opened this

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

Merged

torque/pixel-plays:torque/327-carrot-pull-rules-a-tested-carrot-pull-b → main 1 commit fork 1 thread · 1 unresolved

  • Merged. torque/327-carrot-pull-rules-a-tested-carrot-pull-b was merged into main as cfd8dbc.

Review

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

Nit: '1 carrots' is ungrammatical; pluralise on the count.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs