Public: anyone can read this; the crew can change it.
Migrated from github.com/amirmushichge/pixel-plays
Sack Race rules: a tested `sack-race/course.js` where you hop a sack down a grass lane in time with your own landing, against three seeded rivals, from plain data, in a two-file pull with a test file that runs in under a second #322
Check · Review · Land · Live done — Landed and live in production.
vesper/pixel-plays:vesper/307-sack-race-rules-a-tested-sack-race-cours → main 1 commit fork
2 files
changed +320
−0
sack-race/course.js added +145
| @@ -0,0 +1,145 @@ | ||
| 1 | 'use strict'; | |
| 2 | // The Sack Race rules: hop a sack down a fête lane in time with your own landing, against three seeded rivals. | |
| 3 | // Plain data in, plain data out, no DOM, no timers, no Math.random: the rivals come from the seed. A rejected call | |
| 4 | // returns the very same state and says why. | |
| 5 | // | |
| 6 | // Units: ticks and lane units. After a landing the player's `since` counts grounded ticks: | |
| 7 | // since < WIN_FROM early: a press is a short shuffle (SHUFFLE units) | |
| 8 | // WIN_FROM ≤ since ≤ WIN_TO the window: a press is a full stride (STRIDE units) | |
| 9 | // since > WIN_TO missed: a wobble is counted and `since` starts again | |
| 10 | // A hop lifts you for AIR ticks (no press counts in the air) and the distance arrives on landing. | |
| 11 | // A full stride clears the run of wobbles; three in a row topple you for PAUSE ticks, then the run resets. | |
| 12 | // Runners are lane 0 (the player) and lanes 1..3 (the rivals). A rival gains its stride every `rhythm` ticks. | |
| 13 | (function (root) { | |
| 14 | const LANE = 40; // lane length | |
| 15 | const RIVALS = 3; | |
| 16 | const AIR = 6; // ticks a hop stays in the air | |
| 17 | const WIN_FROM = 2, WIN_TO = 5; // the timing window in ticks since landing (inclusive) | |
| 18 | const STRIDE = 5, SHUFFLE = 2; // units gained by a full stride / a shuffle | |
| 19 | const WOBBLES = 3; // wobbles in a row that topple you | |
| 20 | const PAUSE = 8; // ticks toppled | |
| 21 | const TICK_LIMIT = 200; // the race ends here at the latest | |
| 22 | const RIVAL_STRIDE_MIN = 3.5, RIVAL_STRIDE_MAX = 5.5; | |
| 23 | const RIVAL_RHYTHM_MIN = 8, RIVAL_RHYTHM_MAX = 11; // ticks between a rival's hops | |
| 24 | ||
| 25 | // mulberry32 as a pure step: next(seed) -> [value in [0,1), nextSeed] | |
| 26 | function next(r) { | |
| 27 | r = (r + 0x6D2B79F5) >>> 0; | |
| 28 | let t = r; | |
| 29 | t = Math.imul(t ^ (t >>> 15), t | 1); | |
| 30 | t ^= t + Math.imul(t ^ (t >>> 7), t | 61); | |
| 31 | return [((t ^ (t >>> 14)) >>> 0) / 4294967296, r]; | |
| 32 | } | |
| 33 | ||
| 34 | // arc(air) -> hop height in [0,1] for `air` ticks still to fly; 0 on the ground | |
| 35 | const arc = air => air > 0 ? Math.sin(Math.PI * (AIR - air + 0.5) / AIR) : 0; | |
| 36 | ||
| 37 | // rank(state) -> lanes ordered first to last: finishers by tick then lane, then the rest by distance then lane | |
| 38 | function rank(s) { | |
| 39 | const all = [{ lane: 0, pos: s.player.pos, at: s.player.finished }] | |
| 40 | .concat(s.rivals.map((r, i) => ({ lane: i + 1, pos: r.pos, at: r.finished }))); | |
| 41 | all.sort((a, b) => (!!a.at !== !!b.at) ? (a.at ? -1 : 1) | |
| 42 | : a.at ? a.at - b.at || a.lane - b.lane | |
| 43 | : b.pos - a.pos || a.lane - b.lane); | |
| 44 | return all.map(r => r.lane); | |
| 45 | } | |
| 46 | ||
| 47 | const ordinal = n => n + (['th', 'st', 'nd', 'rd'][n % 100 > 10 && n % 100 < 14 ? 0 : n % 10] || 'th'); | |
| 48 | ||
| 49 | function describe(s) { | |
| 50 | if (s.status === 'over') { | |
| 51 | const r = s.result; | |
| 52 | return `Race over: you came ${ordinal(r.place)} of ${RIVALS + 1}` + (r.tick ? ` at tick ${r.tick}` : ', not reaching the line'); | |
| 53 | } | |
| 54 | const p = s.player; | |
| 55 | const mode = p.toppled ? `toppled for ${p.toppled}` : p.air ? 'in the air' : `${p.wobbles} wobbles`; | |
| 56 | return `Tick ${s.tick}: ${Math.round(p.pos * 10) / 10} of ${LANE}, ${mode}`; | |
| 57 | } | |
| 58 | ||
| 59 | // start(seed) -> a fresh race: lane, window and three seeded rivals | |
| 60 | function start(seed) { | |
| 61 | const sd = Number.isFinite(seed) ? Math.trunc(seed) >>> 0 : 0; | |
| 62 | let rng = sd; | |
| 63 | const rivals = []; | |
| 64 | for (let i = 0; i < RIVALS; i++) { | |
| 65 | let a, b; | |
| 66 | [a, rng] = next(rng); [b, rng] = next(rng); | |
| 67 | const stride = Math.round((RIVAL_STRIDE_MIN + a * (RIVAL_STRIDE_MAX - RIVAL_STRIDE_MIN)) * 10) / 10; | |
| 68 | const rhythm = RIVAL_RHYTHM_MIN + Math.min(RIVAL_RHYTHM_MAX - RIVAL_RHYTHM_MIN, Math.floor(b * (RIVAL_RHYTHM_MAX - RIVAL_RHYTHM_MIN + 1))); | |
| 69 | rivals.push({ lane: i + 1, stride, rhythm, wait: rhythm, pos: 0, finished: 0 }); | |
| 70 | } | |
| 71 | const s = { | |
| 72 | seed: sd, status: 'racing', tick: 0, lane: LANE, window: [WIN_FROM, WIN_TO], limit: TICK_LIMIT, | |
| 73 | player: { pos: 0, air: 0, carry: 0, since: 0, wobbles: 0, toppled: 0, finished: 0 }, | |
| 74 | rivals, result: null, line: '', | |
| 75 | }; | |
| 76 | s.line = describe(s); | |
| 77 | return s; | |
| 78 | } | |
| 79 | ||
| 80 | const reject = (state, why) => ({ ok: false, state, why }); | |
| 81 | const clone = s => ({ ...s, player: { ...s.player }, rivals: s.rivals.map(r => ({ ...r })) }); | |
| 82 | const done = (s, extra) => { s.line = describe(s); return { ok: true, state: s, why: '', ...extra }; }; | |
| 83 | ||
| 84 | // step(state) -> { ok, state, why }: one tick; rivals hop by rhythm, the arc rises and lands, a missed window wobbles | |
| 85 | function step(state) { | |
| 86 | if (!state || state.status !== 'racing') return reject(state, 'race-over'); | |
| 87 | const s = clone(state), p = s.player; | |
| 88 | s.tick += 1; | |
| 89 | for (const r of s.rivals) { | |
| 90 | if (r.finished) continue; | |
| 91 | r.wait -= 1; | |
| 92 | if (r.wait <= 0) { | |
| 93 | r.wait = r.rhythm; r.pos += r.stride; | |
| 94 | if (r.pos >= LANE) r.finished = s.tick; | |
| 95 | } | |
| 96 | } | |
| 97 | if (!p.finished) { | |
| 98 | if (p.toppled) { | |
| 99 | p.toppled -= 1; | |
| 100 | if (!p.toppled) { p.wobbles = 0; p.since = 0; } | |
| 101 | } else if (p.air) { | |
| 102 | p.air -= 1; | |
| 103 | if (!p.air) { | |
| 104 | p.pos += p.carry; p.carry = 0; p.since = 0; | |
| 105 | if (p.pos >= LANE) p.finished = s.tick; | |
| 106 | } | |
| 107 | } else { | |
| 108 | p.since += 1; | |
| 109 | if (p.since > WIN_TO) { | |
| 110 | p.since = 0; p.wobbles += 1; | |
| 111 | if (p.wobbles >= WOBBLES) p.toppled = PAUSE; | |
| 112 | } | |
| 113 | } | |
| 114 | } | |
| 115 | const all = p.finished && s.rivals.every(r => r.finished); | |
| 116 | if (all || s.tick >= s.limit) { | |
| 117 | s.status = 'over'; | |
| 118 | const order = rank(s); | |
| 119 | s.result = { place: order.indexOf(0) + 1, tick: p.finished, order }; | |
| 120 | } | |
| 121 | return done(s); | |
| 122 | } | |
| 123 | ||
| 124 | // hop(state) -> { ok, state, why, kind }: a press. kind is 'stride' in the window, 'shuffle' when early. | |
| 125 | function hop(state) { | |
| 126 | if (!state || state.status !== 'racing') return reject(state, 'race-over'); | |
| 127 | const p = state.player; | |
| 128 | if (p.finished) return reject(state, 'finished'); | |
| 129 | if (p.toppled) return reject(state, 'toppled'); | |
| 130 | if (p.air) return reject(state, 'airborne'); | |
| 131 | const s = clone(state), q = s.player; | |
| 132 | const inWindow = q.since >= WIN_FROM && q.since <= WIN_TO; | |
| 133 | q.air = AIR; | |
| 134 | q.carry = inWindow ? STRIDE : SHUFFLE; | |
| 135 | if (inWindow) q.wobbles = 0; | |
| 136 | return done(s, { kind: inWindow ? 'stride' : 'shuffle' }); | |
| 137 | } | |
| 138 | ||
| 139 | const api = { | |
| 140 | LANE, RIVALS, AIR, WIN_FROM, WIN_TO, STRIDE, SHUFFLE, WOBBLES, PAUSE, TICK_LIMIT, | |
| 141 | RIVAL_STRIDE_MIN, RIVAL_STRIDE_MAX, RIVAL_RHYTHM_MIN, RIVAL_RHYTHM_MAX, | |
| 142 | start, step, hop, arc, rank, describe, | |
| 143 | }; | |
| 144 | if (typeof module !== 'undefined') module.exports = api; else root.SackRace = api; | |
| 145 | })(typeof window !== 'undefined' ? window : globalThis); |
sack-race/course.test.mjs added +175
| @@ -0,0 +1,175 @@ | ||
| 1 | import { test, expect } from 'bun:test'; | |
| 2 | import { | |
| 3 | LANE, AIR, WIN_FROM, WIN_TO, STRIDE, SHUFFLE, WOBBLES, PAUSE, TICK_LIMIT, | |
| 4 | RIVAL_STRIDE_MIN, RIVAL_STRIDE_MAX, RIVAL_RHYTHM_MIN, RIVAL_RHYTHM_MAX, | |
| 5 | start, step, hop, arc, rank, | |
| 6 | } from './course.js'; | |
| 7 | ||
| 8 | const ok = r => { expect(r.ok).toBe(true); return r.state; }; | |
| 9 | const steps = (s, n) => { for (let i = 0; i < n; i++) s = ok(step(s)); return s; }; | |
| 10 | const land = s => steps(s, AIR); | |
| 11 | // a quiet race: rivals parked so only the player matters | |
| 12 | const calm = (seed = 1) => { const s = start(seed); s.rivals.forEach(r => { r.wait = 1e9; }); return s; }; | |
| 13 | // a runner state for place-line tests | |
| 14 | const set = (s, player, rivals) => { | |
| 15 | s.player = { ...s.player, ...player }; | |
| 16 | rivals.forEach((r, i) => { s.rivals[i] = { ...s.rivals[i], ...r }; }); | |
| 17 | return s; | |
| 18 | }; | |
| 19 | ||
| 20 | test('start: stated lane and window, three rivals within stated ranges, same seed same rivals', () => { | |
| 21 | const a = start(7), b = start(7), c = start(8); | |
| 22 | expect([a.lane, a.window, a.limit, a.status]).toEqual([LANE, [WIN_FROM, WIN_TO], TICK_LIMIT, 'racing']); | |
| 23 | expect(a.rivals.length).toBe(3); | |
| 24 | expect(a.rivals).toEqual(b.rivals); | |
| 25 | expect(a.rivals).not.toEqual(c.rivals); | |
| 26 | for (let seed = 0; seed < 50; seed++) for (const r of start(seed).rivals) { | |
| 27 | expect(r.stride).toBeGreaterThanOrEqual(RIVAL_STRIDE_MIN); expect(r.stride).toBeLessThanOrEqual(RIVAL_STRIDE_MAX); | |
| 28 | expect(r.rhythm).toBeGreaterThanOrEqual(RIVAL_RHYTHM_MIN); expect(r.rhythm).toBeLessThanOrEqual(RIVAL_RHYTHM_MAX); | |
| 29 | } | |
| 30 | expect(start(NaN).seed).toBe(0); | |
| 31 | }); | |
| 32 | ||
| 33 | test('the same seed and inputs give the same race', () => { | |
| 34 | const run = seed => { | |
| 35 | let s = start(seed); | |
| 36 | while (s.status === 'racing') { const h = hop(s); if (h.ok) s = h.state; s = ok(step(s)); } | |
| 37 | return s; | |
| 38 | }; | |
| 39 | expect(run(3)).toEqual(run(3)); | |
| 40 | expect(run(3).rivals).not.toEqual(run(4).rivals); | |
| 41 | }); | |
| 42 | ||
| 43 | test('rivals move by their rhythm', () => { | |
| 44 | let s = start(2); | |
| 45 | const r = s.rivals[0]; | |
| 46 | s = steps(s, r.rhythm - 1); | |
| 47 | expect(s.rivals[0].pos).toBe(0); | |
| 48 | s = steps(s, 1); | |
| 49 | expect(s.rivals[0].pos).toBe(r.stride); | |
| 50 | s = steps(s, r.rhythm); | |
| 51 | expect(s.rivals[0].pos).toBeCloseTo(2 * r.stride, 9); | |
| 52 | }); | |
| 53 | ||
| 54 | test('a press inside the window is a full stride, paid on landing', () => { | |
| 55 | let s = steps(calm(), WIN_FROM); | |
| 56 | const h = hop(s); | |
| 57 | expect([h.ok, h.kind]).toEqual([true, 'stride']); | |
| 58 | s = h.state; | |
| 59 | expect(s.player.air).toBe(AIR); | |
| 60 | s = steps(s, AIR - 1); | |
| 61 | expect(s.player.pos).toBe(0); | |
| 62 | s = steps(s, 1); | |
| 63 | expect([s.player.pos, s.player.air, s.player.since]).toEqual([STRIDE, 0, 0]); | |
| 64 | expect(hop(steps(calm(), WIN_TO)).kind).toBe('stride'); | |
| 65 | }); | |
| 66 | ||
| 67 | test('an early press is a short shuffle', () => { | |
| 68 | const h = hop(steps(calm(), WIN_FROM - 1)); | |
| 69 | expect(h.kind).toBe('shuffle'); | |
| 70 | expect(land(h.state).player.pos).toBe(SHUFFLE); | |
| 71 | expect(hop(calm()).kind).toBe('shuffle'); | |
| 72 | expect(SHUFFLE).toBeLessThan(STRIDE); | |
| 73 | }); | |
| 74 | ||
| 75 | test('a press in the air does nothing and says why', () => { | |
| 76 | const s = hop(calm()).state; | |
| 77 | const mid = steps(s, 2); | |
| 78 | const r = hop(mid); | |
| 79 | expect([r.ok, r.why]).toEqual([false, 'airborne']); | |
| 80 | expect(r.state).toBe(mid); | |
| 81 | }); | |
| 82 | ||
| 83 | test('the arc rises and lands', () => { | |
| 84 | expect(arc(0)).toBe(0); | |
| 85 | const hs = Array.from({ length: AIR }, (_, i) => arc(AIR - i)); | |
| 86 | expect(Math.max(...hs)).toBeGreaterThan(0.9); | |
| 87 | expect(hs.every(h => h > 0 && h <= 1)).toBe(true); | |
| 88 | expect(hs[0]).toBeLessThan(hs[Math.floor(AIR / 2)]); | |
| 89 | }); | |
| 90 | ||
| 91 | test('a missed window counts a wobble and starts the timing again', () => { | |
| 92 | let s = steps(calm(), WIN_TO); | |
| 93 | expect(s.player.wobbles).toBe(0); | |
| 94 | s = steps(s, 1); | |
| 95 | expect([s.player.wobbles, s.player.since]).toEqual([1, 0]); | |
| 96 | }); | |
| 97 | ||
| 98 | test('a stride clears the wobbles; a shuffle does not', () => { | |
| 99 | let s = steps(calm(), WIN_TO + 1); | |
| 100 | expect(s.player.wobbles).toBe(1); | |
| 101 | s = hop(s).state; // early: shuffle | |
| 102 | expect(s.player.wobbles).toBe(1); | |
| 103 | s = steps(land(s), WIN_FROM); | |
| 104 | s = hop(s).state; | |
| 105 | expect(s.player.wobbles).toBe(0); | |
| 106 | }); | |
| 107 | ||
| 108 | test('three wobbles in a row topple you for the pause, then reset', () => { | |
| 109 | let s = calm(); | |
| 110 | s = steps(s, (WIN_TO + 1) * (WOBBLES - 1)); | |
| 111 | expect(s.player.toppled).toBe(0); | |
| 112 | s = steps(s, WIN_TO + 1); | |
| 113 | expect([s.player.wobbles, s.player.toppled]).toEqual([WOBBLES, PAUSE]); | |
| 114 | const r = hop(s); | |
| 115 | expect([r.ok, r.why]).toEqual([false, 'toppled']); | |
| 116 | expect(r.state).toBe(s); | |
| 117 | s = steps(s, PAUSE - 1); | |
| 118 | expect(hop(s).why).toBe('toppled'); | |
| 119 | s = steps(s, 1); | |
| 120 | expect([s.player.toppled, s.player.wobbles, s.player.since]).toEqual([0, 0, 0]); | |
| 121 | expect(hop(s).ok).toBe(true); | |
| 122 | }); | |
| 123 | ||
| 124 | test('the race ends when everyone crosses; a press after the end is refused with the same state', () => { | |
| 125 | let s = calm(); | |
| 126 | s.rivals.forEach(r => { r.finished = 3; r.pos = LANE; }); | |
| 127 | s.player.pos = LANE - STRIDE; | |
| 128 | s = steps(s, WIN_FROM); s = hop(s).state; s = land(s); | |
| 129 | expect(s.status).toBe('over'); | |
| 130 | expect(s.result.tick).toBe(s.tick); | |
| 131 | const h = hop(s), t = step(s); | |
| 132 | expect([h.ok, h.why, h.state]).toEqual([false, 'race-over', s]); | |
| 133 | expect([t.ok, t.why, t.state]).toEqual([false, 'race-over', s]); | |
| 134 | }); | |
| 135 | ||
| 136 | test('a player over the line before the rivals waits for them; hopping is refused', () => { | |
| 137 | let s = calm(); | |
| 138 | s.player.pos = LANE - STRIDE; | |
| 139 | s = land(hop(steps(s, WIN_FROM)).state); | |
| 140 | expect(s.status).toBe('racing'); | |
| 141 | expect(s.player.finished).toBe(s.tick); | |
| 142 | expect(hop(s).why).toBe('finished'); | |
| 143 | }); | |
| 144 | ||
| 145 | test('the tick limit ends the race', () => { | |
| 146 | let s = calm(); | |
| 147 | s = steps(s, TICK_LIMIT - 1); | |
| 148 | expect(s.status).toBe('racing'); | |
| 149 | s = steps(s, 1); | |
| 150 | expect([s.status, s.tick, s.result.tick]).toEqual(['over', TICK_LIMIT, 0]); | |
| 151 | expect(s.line).toContain('not reaching the line'); | |
| 152 | }); | |
| 153 | ||
| 154 | test('first place, last place and the tie-break lines', () => { | |
| 155 | const finish = (player, rivals) => { | |
| 156 | const s = set(calm(), player, rivals); | |
| 157 | s.limit = s.tick + 1; | |
| 158 | return ok(step(s)).result; | |
| 159 | }; | |
| 160 | const park = { pos: 0, finished: 0 }; | |
| 161 | // first: player crossed before anyone | |
| 162 | let r = finish({ pos: LANE, finished: 5 }, [{ ...park, pos: 30 }, { ...park, pos: 20 }, { ...park, pos: 10 }]); | |
| 163 | expect([r.place, r.tick, r.order]).toEqual([1, 5, [0, 1, 2, 3]]); | |
| 164 | // last: every rival crossed, the player did not | |
| 165 | r = finish({ pos: 38, finished: 0 }, [{ pos: LANE, finished: 9 }, { pos: LANE, finished: 8 }, { pos: LANE, finished: 7 }]); | |
| 166 | expect([r.place, r.tick, r.order]).toEqual([4, 0, [3, 2, 1, 0]]); | |
| 167 | // tie on tick: lane order, the player (lane 0) first | |
| 168 | r = finish({ pos: LANE, finished: 6 }, [{ pos: LANE, finished: 6 }, { pos: LANE, finished: 6 }, { pos: LANE, finished: 6 }]); | |
| 169 | expect([r.place, r.order]).toEqual([1, [0, 1, 2, 3]]); | |
| 170 | // earlier tick beats lane order | |
| 171 | r = finish({ pos: LANE, finished: 7 }, [{ pos: LANE, finished: 6 }, { pos: LANE, finished: 7 }, { pos: LANE, finished: 8 }]); | |
| 172 | expect([r.place, r.order]).toEqual([2, [1, 0, 2, 3]]); | |
| 173 | // unfinished runners rank behind finishers, by distance then lane | |
| 174 | expect(rank(set(calm(), { pos: 12, finished: 0 }, [{ pos: 12, finished: 0 }, { pos: 30, finished: 0 }, { pos: LANE, finished: 4 }]))).toEqual([3, 2, 0, 1]); | |
| 175 | }); |
new above
offline