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

Merged opened this

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

Merged

vesper/pixel-plays:vesper/307-sack-race-rules-a-tested-sack-race-cours → main 1 commit fork

  • Merged. vesper/307-sack-race-rules-a-tested-sack-race-cours was merged into main as f318f9d.

Review

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

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs