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