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

Migrated from github.com/amirmushichge/pixel-plays

Tide Crossing rules: a tested `tide-crossing/causeway.js` where you step across a stone causeway to the island before the tide covers your path, within par, in a two-file pull with a test file that runs in under a second #346

Merged opened this

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

Merged

torque/pixel-plays:torque/328-tide-crossing-rules-a-tested-tide-crossi → main 1 commit fork 1 thread · 1 unresolved

  • Merged. torque/328-tide-crossing-rules-a-tested-tide-crossi was merged into main as 0a97afa.

Review

Sign in to review this change.
2 files changed +248 −0
tide-crossing/causeway.js added +90
@@ -0,0 +1,90 @@
1 'use strict';
2 // The Tide Crossing rules: a causeway of stepping stones leads out to a tidal island. The tide rises and falls on a
3 // stated table; each stone goes under at its own height. Step one stone per tick (or wait a tick) and reach the island
4 // before the path you need is under water. Plain data in, plain data out, no DOM, no timers, no randomness. A refused
5 // call returns the very same state and says why.
6 //
7 // Units: time is whole ticks (tick 0 is the start); heights are whole tide-units above the lowest water mark; a square
8 // is (row, col), row 0 at the top, col 0 at the left. A stone of height h is under water at tick t exactly when
9 // tide[t] >= h; past the end of the table the tide stays at its last value. A square with no stone is `null`.
10 // Ticks used = moves + waits, and par is stated in ticks (the fewest the level allows).
11 (function (root) {
12 const DIRS = { up: [-1, 0], down: [1, 0], left: [0, -1], right: [0, 1] };
13 const N = null;
14
15 // Every level: name, grid (stone heights, null = open sea), start and goal squares, tide table, par, and a stated
16 // solution (moves up/down/left/right and 'wait') that solves it in exactly par ticks.
17 const LEVELS = [
18 { name: 'Short hop', grid: [[9, 5, 5, 9]], start: [0, 0], goal: [0, 3],
19 tide: [0, 0, 1, 1, 2, 2], par: 3, solution: ['right', 'right', 'right'] },
20 { name: 'Low stones', grid: [[9, 2, 3, 4, 9]], start: [0, 0], goal: [0, 4],
21 tide: [0, 1, 2, 3, 4, 5], par: 4, solution: ['right', 'right', 'right', 'right'] },
22 { name: 'The bend', grid: [[9, 6, N], [N, 6, 6], [N, N, 9]], start: [0, 0], goal: [2, 2],
23 tide: [0, 1, 2, 3, 4, 5], par: 4, solution: ['right', 'down', 'right', 'down'] },
24 { name: 'Wait for the ebb', grid: [[9, 2, 2, 9]], start: [0, 0], goal: [0, 3],
25 tide: [0, 2, 0, 0, 0, 0], par: 4, solution: ['wait', 'right', 'right', 'right'] },
26 { name: 'Two ways', grid: [[9, 2, 2, 9], [8, 5, 5, 5]], start: [0, 0], goal: [0, 3],
27 tide: [0, 2, 3, 4, 4, 4], par: 5, solution: ['down', 'right', 'right', 'right', 'up'] },
28 { name: 'Spring tide', grid: [[9, 3, N, N], [N, 3, 4, N], [N, N, 3, 9]], start: [0, 0], goal: [2, 3],
29 tide: [0, 3, 2, 0, 0, 0, 0], par: 6, solution: ['wait', 'right', 'down', 'right', 'down', 'right'] },
30 ];
31
32 const reject = (state, why) => ({ ok: false, state, why });
33 const tideAt = (tide, t) => tide[Math.min(t, tide.length - 1)];
34 const under = (s, row, col, t) => tideAt(s.tide, t) >= s.grid[row][col];
35
36 function describe(s) {
37 if (s.status === 'lost') return `Lost: the tide covered your stone at tick ${s.tick}`;
38 if (s.status === 'won') {
39 const d = s.tick - s.par;
40 const vs = d < 0 ? `${-d} under par` : d === 0 ? 'on par' : `${d} over par`;
41 return `Crossed in ${s.tick} ticks against par ${s.par}: ${vs}`;
42 }
43 return `Tick ${s.tick}: water at ${tideAt(s.tide, s.tick)}, par ${s.par}`;
44 }
45
46 // start(level) -> a fresh crossing; level is an index into LEVELS (anything else gives level 0)
47 function start(level) {
48 const i = Number.isInteger(level) && level >= 0 && level < LEVELS.length ? level : 0;
49 const L = LEVELS[i];
50 const s = {
51 level: i, name: L.name, grid: L.grid.map(r => [...r]), rows: L.grid.length, cols: L.grid[0].length,
52 start: { row: L.start[0], col: L.start[1] }, goal: { row: L.goal[0], col: L.goal[1] },
53 tide: [...L.tide], par: L.par, tick: 0, pos: { row: L.start[0], col: L.start[1] }, status: 'playing', line: '',
54 };
55 s.line = describe(s);
56 return s;
57 }
58
59 // advance(state, pos) -> the state one tick later with the player on pos: lost if the tide covers it, won on the island
60 function advance(state, pos) {
61 const s = { ...state, tick: state.tick + 1, pos };
62 if (under(s, pos.row, pos.col, s.tick)) s.status = 'lost';
63 else if (pos.row === s.goal.row && pos.col === s.goal.col) s.status = 'won';
64 s.line = describe(s);
65 return { ok: true, state: s, why: '' };
66 }
67
68 // move(state, dir) -> { ok, state, why }: step to a neighbouring stone that is above water next tick; the tide advances
69 function move(state, dir) {
70 if (state.status !== 'playing') return reject(state, 'game-over');
71 if (typeof dir !== 'string' || !Object.hasOwn(DIRS, dir)) return reject(state, 'unknown-direction');
72 const row = state.pos.row + DIRS[dir][0], col = state.pos.col + DIRS[dir][1];
73 if (row < 0 || row >= state.rows || col < 0 || col >= state.cols || state.grid[row][col] === null) {
74 return reject(state, 'no-stone');
75 }
76 if (under(state, row, col, state.tick + 1)) return reject(state, 'under-water');
77 return advance(state, { row, col });
78 }
79
80 // wait(state) -> { ok, state, why }: stay put for one tick. If the rising tide covers your stone, you lose.
81 function wait(state) {
82 if (state.status !== 'playing') return reject(state, 'game-over');
83 return advance(state, state.pos);
84 }
85
86 const restart = state => start(state && state.level);
87
88 const api = { LEVELS, DIRS, start, move, wait, restart, tideAt, describe };
89 if (typeof module !== 'undefined') module.exports = api; else root.TideCrossing = api;
90 })(typeof window !== 'undefined' ? window : globalThis);
tide-crossing/causeway.test.mjs added +158
@@ -0,0 +1,158 @@
1 import { test, expect } from 'bun:test';
2 import { LEVELS, start, move, wait, restart, tideAt } from './causeway.js';
3
4 const ok = r => { expect(r.ok).toBe(true); return r.state; };
5 const play = (s, acts) => { for (const a of acts) s = ok(a === 'wait' ? wait(s) : move(s, a)); return s; };
6 // breadth-first search over (position, tick) for the fewest ticks to the island; allowWait=false forbids waiting
7 function best(level, allowWait) {
8 let fr = [start(level)];
9 for (let d = 0; d < 30; d++) {
10 const nx = [];
11 for (const s of fr) {
12 for (const a of ['up', 'down', 'left', 'right', ...(allowWait ? ['wait'] : [])]) {
13 const r = a === 'wait' ? wait(s) : move(s, a);
14 if (!r.ok || r.state.status === 'lost') continue;
15 if (r.state.status === 'won') return r.state.tick;
16 nx.push(r.state);
17 }
18 }
19 fr = nx;
20 }
21 return null;
22 }
23
24 test('there are at least six levels, each with grid, start, goal, tide table and par', () => {
25 expect(LEVELS.length).toBeGreaterThanOrEqual(6);
26 LEVELS.forEach((L, i) => {
27 const s = start(i);
28 expect(s.grid.length).toBeGreaterThan(0);
29 expect(s.grid[s.start.row][s.start.col]).not.toBeNull();
30 expect(s.grid[s.goal.row][s.goal.col]).not.toBeNull();
31 expect(s.tide.length).toBeGreaterThan(0);
32 expect(s.par).toBe(L.par);
33 expect(s.tick).toBe(0);
34 expect(s.status).toBe('playing');
35 expect(s.pos).toEqual(s.start);
36 });
37 });
38
39 test('each level: the stated solution solves it within par, and par is the true minimum', () => {
40 LEVELS.forEach((L, i) => {
41 const s = play(start(i), L.solution);
42 expect(s.status).toBe('won');
43 expect(s.tick).toBeLessThanOrEqual(L.par);
44 expect(best(i, true)).toBe(L.par);
45 });
46 });
47
48 test('waiting matters: some level cannot be crossed without waiting, and its stone covers if you hurry', () => {
49 const needy = LEVELS.map((L, i) => i).filter(i => best(i, false) === null || best(i, false) > LEVELS[i].par);
50 expect(needy).toContain(3);
51 expect(best(3, false)).toBeNull();
52 const s = start(3);
53 // the middle stones (height 2) are covered at tick 1, so hurrying is refused ...
54 const r = move(s, 'right');
55 expect(r.ok).toBe(false);
56 expect(r.why).toBe('under-water');
57 // ... and the level is solved only once the player waits
58 expect(play(s, LEVELS[3].solution).status).toBe('won');
59 expect(LEVELS[3].solution).toContain('wait');
60 });
61
62 test('a move steps onto the stone and the tide advances one tick', () => {
63 const s = ok(move(start(0), 'right'));
64 expect(s.pos).toEqual({ row: 0, col: 1 });
65 expect(s.tick).toBe(1);
66 expect(s.line).toBe('Tick 1: water at 0, par 3');
67 });
68
69 test('wait stays put for one tick', () => {
70 const s0 = start(0), s = ok(wait(s0));
71 expect(s.pos).toEqual(s0.pos);
72 expect(s.tick).toBe(1);
73 });
74
75 test('refusals return the same state with a reason', () => {
76 const s = start(0);
77 for (const bad of ['north', '', 'UP', undefined, null, 3, '__proto__', 'toString']) {
78 const r = move(s, bad);
79 expect([r.ok, r.why]).toEqual([false, 'unknown-direction']);
80 expect(r.state).toBe(s);
81 }
82 for (const dir of ['up', 'down', 'left']) {
83 const r = move(s, dir);
84 expect([r.ok, r.why]).toEqual([false, 'no-stone']);
85 expect(r.state).toBe(s);
86 }
87 // an interior hole: level 2 has open sea at (0,2)
88 const t = start(2);
89 expect(move(t, 'down').why).toBe('no-stone');
90 const low = start(3), r = move(low, 'right');
91 expect([r.ok, r.why]).toEqual([false, 'under-water']);
92 expect(r.state).toBe(low);
93 });
94
95 test('a refused move does not advance the tide', () => {
96 const s = start(3);
97 expect(move(s, 'right').state.tick).toBe(0);
98 });

The title promises the shore is never covered in the stated levels, but the test only checks one wait on level 3. Assert it (for each level, tideAt of the start stone stays below its height) or shorten the title.

99
100 test('being caught by the tide ends the level with a loss line naming the tick', () => {
101 // level 0: stand on the first stone (height 5) while the water rises
102 let s = ok(move(start(0), 'right'));
103 const rising = { ...s, tide: [0, 0, 5, 5] };
104 const lost = ok(wait(rising));
105 expect(lost.status).toBe('lost');
106 expect(lost.tick).toBe(2);
107 expect(lost.line).toBe('Lost: the tide covered your stone at tick 2');
108 });
109
110 test('waiting on a stone that stays dry is safe, and the shore is never covered in the stated levels', () => {
111 const s = ok(wait(start(3)));
112 expect(s.status).toBe('playing');
113 expect(s.line).toBe('Tick 1: water at 2, par 4');
114 });
115
116 test('after the game is over every move and wait is refused with game-over', () => {
117 const won = play(start(0), LEVELS[0].solution);
118 const lost = ok(wait({ ...start(0), tide: [0, 9] }));
119 expect(lost.status).toBe('lost');
120 for (const s of [won, lost]) {
121 for (const r of [move(s, 'left'), move(s, 'bogus'), wait(s)]) {
122 expect([r.ok, r.why]).toEqual([false, 'game-over']);
123 expect(r.state).toBe(s);
124 }
125 }
126 });
127
128 test('win line reports ticks against par: on, under and over', () => {
129 const on = play(start(0), LEVELS[0].solution);
130 expect(on.line).toBe('Crossed in 3 ticks against par 3: on par');
131 const under = play({ ...start(0), par: 5 }, LEVELS[0].solution);
132 expect(under.line).toBe('Crossed in 3 ticks against par 5: 2 under par');
133 const over = play(start(0), ['wait', 'right', 'right', 'right']);
134 expect(over.status).toBe('won');
135 expect(over.line).toBe('Crossed in 4 ticks against par 3: 1 over par');
136 });
137
138 test('restart is always allowed and gives the level fresh', () => {
139 const mid = ok(move(start(2), 'right'));
140 expect(restart(mid)).toEqual(start(2));
141 const won = play(start(1), LEVELS[1].solution);
142 expect(restart(won)).toEqual(start(1));
143 const lost = ok(wait({ ...start(0), tide: [9] }));
144 expect(restart(lost).status).toBe('playing');
145 expect(restart(lost).tick).toBe(0);
146 });
147
148 test('start with an unknown level falls back to level 0, and tideAt holds the last value', () => {
149 expect(start(99)).toEqual(start(0));
150 expect(start('x')).toEqual(start(0));
151 expect(tideAt([0, 3], 50)).toBe(3);
152 });
153
154 test('rules do not mutate the state they are given', () => {
155 const s = start(2), copy = JSON.parse(JSON.stringify(s));
156 move(s, 'right'); wait(s);
157 expect(s).toEqual(copy);
158 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs