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

Migrated from github.com/amirmushichge/pixel-plays

Bumper Cars rules: a tested `bumper-cars/floor.js` where you steer a car on a walled floor and shove three seeded rivals into the wall without a head-on bump, from plain data, in a two-file pull with a test file that runs in under a second #336

Merged opened this

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

Merged

torque/pixel-plays:torque/320-bumper-cars-rules-a-tested-bumper-cars-f → main 2 commits fork

  • Merged. torque/320-bumper-cars-rules-a-tested-bumper-cars-f was merged into main as 8728d37.

Review

Sign in to review this change.
2 files changed +380 −0
bumper-cars/floor.js added +159
@@ -0,0 +1,159 @@
1 'use strict';
2 // The Bumper Cars rules: steer a car on a walled floor and shove three seeded rivals into the wall without a head-on bump.
3 // Plain data in, plain data out, no DOM, no timers, no Math.random: every random choice comes from the seed
4 // (state.rng is the generator's state). A refused call returns the very same state and says why.
5 //
6 // Units: squares and ticks. The floor is SIZE x SIZE squares, x to the right, y downwards, (0,0) top-left.
7 // Headings are 0 north (0,-1), 1 east (1,0), 2 south (0,1), 3 west (-1,0); a right turn is +1, a left turn -1 (mod 4).
8 // One tick, in this order: your car (the turn you steered, then one square), then rivals 1, 2, 3 (each its next pattern turn, then one square).
9 // Wall: a car that would leave the floor stays put and turns right instead.
10 // You move into a rival:
11 // head-on (its heading is opposite yours) -> you lose a life, stay, and turn about;
12 // back or side -> it is shoved one square the way you were going and you take its square.
13 // Shoved off the floor (into the wall): SHOVE_SCORE points, a wall shove is counted, and it is placed again
14 // at a seeded free square. Shoved into another car: no shove, you stay.
15 // A rival moving into a car: into you head-on costs you a life; any other blocked move leaves it put, turning right.
16 // The round ends when lives run out (a loss) or after TICKS ticks (a win, reporting score and wall shoves).
17 // mulberry32 is the generator (public-domain construction by Tommy Ettinger).
18 (function (root) {
19 const SIZE = 9;
20 const RIVALS = 3;
21 const LIVES = 3;
22 const TICKS = 60;
23 const SHOVE_SCORE = 10;
24 const PATTERN = 4; // turns in a rival's repeating pattern
25 const DIRS = [[0, -1], [1, 0], [0, 1], [-1, 0]];
26 const TURNS = { left: -1, straight: 0, right: 1 };
27 const START = { x: 4, y: 8, h: 0 }; // bottom middle, facing north
28
29 // mulberry32 as a pure step: next(state) -> [value in [0,1), nextState]
30 function next(r) {
31 r = (r + 0x6D2B79F5) >>> 0;
32 let t = r;
33 t = Math.imul(t ^ (t >>> 15), t | 1);
34 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
35 return [((t ^ (t >>> 14)) >>> 0) / 4294967296, r];
36 }
37
38 const turnBy = (h, d) => (h + d + 4) % 4;
39 const inside = (x, y) => x >= 0 && y >= 0 && x < SIZE && y < SIZE;
40 const carAt = (s, x, y) => {
41 if (s.player.x === x && s.player.y === y) return 0;
42 const i = s.rivals.findIndex(r => r.x === x && r.y === y);
43 return i < 0 ? -1 : i + 1;
44 };
45
46 // freeSquare(state, rng) -> [{x,y}, rng]: a seeded square with no car on it
47 function freeSquare(s, rng) {
48 const free = [];
49 for (let y = 0; y < SIZE; y++) for (let x = 0; x < SIZE; x++) if (carAt(s, x, y) < 0) free.push({ x, y });
50 let v; [v, rng] = next(rng);
51 return [free[Math.floor(v * free.length)], rng];
52 }
53
54 function describe(s) {
55 if (s.status === 'over') {
56 const r = s.result;
57 return r.won
58 ? `Round won: score ${r.score}, ${r.shoves} wall shove${r.shoves === 1 ? '' : 's'}`
59 : `Round lost: out of lives at tick ${r.tick}, score ${r.score}`;
60 }
61 return `Tick ${s.tick} of ${s.ticks}: score ${s.score}, ${s.lives} li${s.lives === 1 ? 'fe' : 'ves'}, ${s.shoves} wall shove${s.shoves === 1 ? '' : 's'}`;
62 }
63
64 // start(seed) -> a fresh round: floor, your car, three seeded rivals with headings and turn patterns
65 function start(seed) {
66 const sd = Number.isFinite(seed) ? Math.trunc(seed) >>> 0 : 0;
67 let rng = sd;
68 const s = {
69 seed: sd, rng, status: 'playing', tick: 0, ticks: TICKS, size: SIZE, lives: LIVES,
70 score: 0, shoves: 0, turn: 'straight',
71 player: { ...START }, rivals: [], result: null, line: '',
72 };
73 for (let i = 0; i < RIVALS; i++) {
74 let sq, v;
75 [sq, rng] = freeSquare(s, rng);
76 [v, rng] = next(rng); const h = Math.floor(v * 4);
77 const pattern = [];
78 for (let k = 0; k < PATTERN; k++) { [v, rng] = next(rng); pattern.push(['left', 'straight', 'right'][Math.floor(v * 3)]); }
79 s.rivals.push({ x: sq.x, y: sq.y, h, pattern, at: 0 });
80 }
81 s.rng = rng;
82 s.line = describe(s);
83 return s;
84 }
85
86 const reject = (state, why) => ({ ok: false, state, why });
87 const clone = s => ({ ...s, player: { ...s.player }, rivals: s.rivals.map(r => ({ ...r })) });
88
89 // steer(state, turn) -> { ok, state, why }: your next turn, 'left', 'right' or 'straight'
90 function steer(state, turn) {
91 if (!state || state.status !== 'playing') return reject(state, 'round-over');
92 if (typeof turn !== 'string' || !Object.hasOwn(TURNS, turn)) return reject(state, 'unknown-turn');
93 const s = clone(state);
94 s.turn = turn;
95 return { ok: true, state: s, why: '' };
96 }
97
98 // step(state) -> { ok, state, why, events }: one tick in the stated order
99 function step(state) {
100 if (!state || state.status !== 'playing') return reject(state, 'round-over');
101 const s = clone(state), p = s.player, events = [];
102 s.tick += 1;
103
104 // your car
105 p.h = turnBy(p.h, TURNS[s.turn]); s.turn = 'straight';
106 const [dx, dy] = DIRS[p.h], nx = p.x + dx, ny = p.y + dy;
107 if (!inside(nx, ny)) { p.h = turnBy(p.h, 1); events.push({ kind: 'wall', car: 0 }); }
108 else {
109 const hit = carAt(s, nx, ny);
110 if (hit < 0) { p.x = nx; p.y = ny; }
111 else {
112 const r = s.rivals[hit - 1];
113 if (r.h === turnBy(p.h, 2)) {
114 s.lives -= 1; p.h = turnBy(p.h, 2);
115 events.push({ kind: 'head-on', car: hit });
116 } else {
117 const sx = r.x + dx, sy = r.y + dy;
118 if (!inside(sx, sy)) {
119 s.score += SHOVE_SCORE; s.shoves += 1;
120 p.x = r.x; p.y = r.y;
121 let sq; [sq, s.rng] = freeSquare(s, s.rng); // r still stands on its old square, now yours: not free
122 r.x = sq.x; r.y = sq.y;
123 events.push({ kind: 'wall-shove', car: hit, score: SHOVE_SCORE });
124 } else if (carAt(s, sx, sy) < 0) {
125 r.x = sx; r.y = sy; p.x = nx; p.y = ny;
126 events.push({ kind: 'shove', car: hit });
127 } else events.push({ kind: 'blocked', car: hit });
128 }
129 }
130 }
131
132 // rivals
133 s.rivals.forEach((r, i) => {
134 if (s.lives <= 0) return;
135 r.h = turnBy(r.h, TURNS[r.pattern[r.at % r.pattern.length]]); r.at += 1;
136 const [ex, ey] = DIRS[r.h], tx = r.x + ex, ty = r.y + ey;
137 if (!inside(tx, ty)) { r.h = turnBy(r.h, 1); return; }
138 const hit = carAt(s, tx, ty);
139 if (hit < 0) { r.x = tx; r.y = ty; return; }
140 if (hit === 0 && p.h === turnBy(r.h, 2)) {
141 s.lives -= 1; events.push({ kind: 'head-on', car: i + 1 });
142 }
143 r.h = turnBy(r.h, 1);
144 });
145
146 if (s.lives <= 0) {
147 s.lives = 0; s.status = 'over';
148 s.result = { won: false, tick: s.tick, score: s.score, shoves: s.shoves };
149 } else if (s.tick >= s.ticks) {
150 s.status = 'over';
151 s.result = { won: true, tick: s.tick, score: s.score, shoves: s.shoves };
152 }
153 s.line = describe(s);
154 return { ok: true, state: s, why: '', events };
155 }
156
157 const api = { SIZE, RIVALS, LIVES, TICKS, SHOVE_SCORE, START, start, steer, step, describe };
158 if (typeof module !== 'undefined') module.exports = api; else root.BumperCars = api;
159 })(typeof window !== 'undefined' ? window : globalThis);
bumper-cars/floor.test.mjs added +221
@@ -0,0 +1,221 @@
1 import { test, expect } from 'bun:test';
2 import { SIZE, LIVES, TICKS, SHOVE_SCORE, START, start, steer, step } from './floor.js';
3
4 const ok = r => { expect(r.ok).toBe(true); return r.state; };
5 // Rivals that are not part of a test wait here, one slot each, facing the wall they stand against (so they
6 // turn in place on the first tick and never come near the middle of the floor).
7 const PARKED = [{ x: 0, y: 0, h: 0 }, { x: 8, y: 0, h: 0 }, { x: 8, y: 8, h: 2 }];
8 // set(rivals, player): a floor where rivals[i] replaces slot i; the others are parked. Rival patterns default to straight.
9 const set = (rivals = [], player = {}) => {
10 const s = start(1);
11 s.player = { ...START, ...player };
12 s.rivals = PARKED.map((p, i) => ({ pattern: ['straight'], at: 0, ...(rivals[i] || p) }));
13 return s;
14 };
15 const pos = c => [c.x, c.y];
16 const kinds = r => r.events.map(e => e.kind);
17
18 test('start: stated floor, lives, ticks, your car and three seeded rivals; same seed same rivals', () => {
19 const a = start(7), b = start(7), c = start(8);
20 expect([a.size, a.lives, a.ticks, a.status]).toEqual([SIZE, LIVES, TICKS, 'playing']);
21 expect(a.player).toEqual(START);
22 expect(a.rivals.length).toBe(3);
23 expect(a.rivals).toEqual(b.rivals);
24 expect(a.rivals).not.toEqual(c.rivals);
25 for (let seed = 0; seed < 50; seed++) {
26 const s = start(seed), seen = new Set(['4,8']);
27 for (const r of s.rivals) {
28 expect(r.x >= 0 && r.x < SIZE && r.y >= 0 && r.y < SIZE).toBe(true);
29 expect(r.pattern.length).toBe(4);
30 seen.add(r.x + ',' + r.y);
31 }
32 expect(seen.size).toBe(4);
33 }
34 expect(start(NaN).seed).toBe(0);
35 });
36
37 test('the same seed and inputs give the same round', () => {
38 const run = seed => {
39 let s = start(seed);
40 for (let i = 0; s.status === 'playing'; i++) {
41 s = ok(steer(s, ['left', 'straight', 'right'][i % 3]));
42 s = ok(step(s));
43 }
44 return s;
45 };
46 expect(run(3)).toEqual(run(3));
47 expect(run(3).rivals).not.toEqual(run(4).rivals);
48 });
49
50 test('steer sets the next turn, which step applies once', () => {
51 let s = ok(steer(start(1), 'right'));
52 expect(s.turn).toBe('right');
53 s.rivals.forEach(r => { r.pattern = ['straight']; });
54 const before = start(1);
55 s = ok(step(s));
56 expect(s.player.h).toBe(1);
57 expect(s.turn).toBe('straight');
58 expect(before.player.h).toBe(0);
59 });
60
61 test('refusals return the same state with a reason', () => {
62 const s = start(1);
63 for (const t of ['up', '', 5, null, undefined, 'toString', '__proto__']) {
64 const r = steer(s, t);
65 expect(r.ok).toBe(false); expect(r.why).toBe('unknown-turn'); expect(r.state).toBe(s);
66 }
67 const over = { ...s, status: 'over' };
68 const a = steer(over, 'left'), b = step(over);
69 expect([a.ok, a.why, a.state]).toEqual([false, 'round-over', over]);
70 expect([b.ok, b.why, b.state]).toEqual([false, 'round-over', over]);
71 });
72
73 test('a car at the wall stops and turns right', () => {
74 const s = set([], { x: 4, y: 0, h: 0 });
75 const t = ok(step(s));
76 expect([t.player.x, t.player.y, t.player.h]).toEqual([4, 0, 1]);
77 // a rival too
78 const u = set([{ x: 8, y: 5, h: 1 }]);
79 const v = ok(step(u));
80 expect([v.rivals[0].x, v.rivals[0].y, v.rivals[0].h]).toEqual([8, 5, 2]);
81 });
82
83 test('a back bump shoves the rival one square the way you were going', () => {
84 const s = set([{ x: 4, y: 7, h: 0 }]); // ahead of you, facing the same way
85 s.rivals[0].pattern = ['straight'];
86 const t = ok(step(s));
87 expect([t.player.x, t.player.y]).toEqual([4, 7]);
88 // shoved to (4,6), then it drives north one square
89 expect([t.rivals[0].x, t.rivals[0].y]).toEqual([4, 5]);
90 expect(t.lives).toBe(LIVES);
91 });
92
93 test('a side bump shoves the rival one square the way you were going', () => {
94 const s = set([{ x: 4, y: 7, h: 1 }]); // facing east across your path
95 const t = ok(step(s));
96 expect([t.player.x, t.player.y]).toEqual([4, 7]);
97 // shoved north first, then it drives east one square
98 expect([t.rivals[0].x, t.rivals[0].y]).toEqual([5, 6]);
99 expect(t.lives).toBe(LIVES);
100 });
101
102 test('a shove into the wall scores and the rival is placed again at a free square', () => {
103 const s = set([{ x: 4, y: 1, h: 0 }], { x: 4, y: 2, h: 0 });
104 let t = ok(step(s));
105 expect([t.player.x, t.player.y]).toEqual([4, 1]);
106 expect([t.rivals[0].x, t.rivals[0].y]).toEqual([4, 0]);
107 t = ok(step(t)); // now the rival stands at (4,0); you at (4,1) bump it into the wall
108 expect(t.score).toBe(SHOVE_SCORE);
109 expect(t.shoves).toBe(1);
110 const r = t.rivals[0];
111 expect(r.y === 0 && r.x === 4).toBe(false);
112 const taken = [t.player, t.rivals[1], t.rivals[2]].map(c => c.x + ',' + c.y);
113 expect(taken).not.toContain(r.x + ',' + r.y);
114 expect(t.line).toContain('score 10');
115 expect(ok(step(set([{ x: 4, y: 0, h: 0 }], { x: 4, y: 1, h: 0 }))).score).toBe(SHOVE_SCORE);
116 });
117
118 test('a head-on bump costs a life', () => {
119 const s = set([{ x: 4, y: 7, h: 2 }]); // facing you
120 const t = ok(step(s));
121 expect(t.lives).toBe(LIVES - 1);
122 expect(t.score).toBe(0);
123 expect(t.player.h).toBe(2);
124 });
125
126 test('the loss line when lives run out', () => {
127 let s = set([{ x: 4, y: 7, h: 2 }]);
128 s.lives = 1;
129 s = ok(step(s));
130 expect(s.status).toBe('over');
131 expect(s.result.won).toBe(false);
132 expect(s.line).toBe('Round lost: out of lives at tick 1, score 0');
133 expect(step(s).why).toBe('round-over');
134 });
135
136 test('the win line reports score and wall shoves when the ticks run out', () => {
137 let s = set([], { x: 4, y: 4, h: 0 });
138 s.tick = TICKS - 1; s.score = 20; s.shoves = 2;
139 s = ok(step(s));
140 expect(s.status).toBe('over');
141 expect(s.result).toEqual({ won: true, tick: TICKS, score: 20, shoves: 2 });
142 expect(s.line).toBe('Round won: score 20, 2 wall shoves');
143 });
144
145 test('the input state is never changed', () => {
146 const s = start(5), copy = JSON.parse(JSON.stringify(s));
147 step(steer(s, 'left').state);
148 expect(s).toEqual(copy);
149 });
150
151 test('step reports events: wall, shove, wall-shove and head-on', () => {
152 expect(step(set([], { x: 4, y: 0, h: 0 })).events).toEqual([{ kind: 'wall', car: 0 }]);
153 expect(kinds(step(set([{ x: 4, y: 7, h: 0 }])))).toEqual(['shove']);
154 expect(step(set([{ x: 4, y: 0, h: 0 }], { x: 4, y: 1, h: 0 })).events)
155 .toEqual([{ kind: 'wall-shove', car: 1, score: SHOVE_SCORE }]);
156 expect(step(set([{ x: 4, y: 7, h: 2 }])).events).toEqual([{ kind: 'head-on', car: 1 }]);
157 expect(step(set([], { x: 4, y: 4 })).events).toEqual([]);
158 });
159
160 test('a rival driving head-on into your car costs a life', () => {
161 // you advance to (4,3); the rival from (4,2) faces south and drives into you
162 const r = step(set([{ x: 4, y: 2, h: 2 }], { x: 4, y: 4, h: 0 }));
163 expect(r.state.lives).toBe(LIVES - 1);
164 expect(r.events).toEqual([{ kind: 'head-on', car: 1 }]);
165 expect(pos(r.state.rivals[0])).toEqual([4, 2]); // it stayed put and turned right
166 expect(r.state.rivals[0].h).toBe(3);
167 expect(pos(r.state.player)).toEqual([4, 3]);
168 });
169
170 test('a rival head-on bump on your last life ends the round with the loss line', () => {
171 const s = set([{ x: 4, y: 2, h: 2 }], { x: 4, y: 4, h: 0 });
172 s.lives = 1;
173 const t = ok(step(s));
174 expect([t.status, t.lives, t.result.won]).toEqual(['over', 0, false]);
175 expect(t.line).toBe('Round lost: out of lives at tick 1, score 0');
176 });
177
178 test('a rival blocked by your side or back does not cost a life; it stays and turns right', () => {
179 // you advance to (4,3); the rival at (3,3) faces east into your side
180 const t = ok(step(set([{ x: 3, y: 3, h: 1 }], { x: 4, y: 4, h: 0 })));
181 expect(t.lives).toBe(LIVES);
182 expect(pos(t.rivals[0])).toEqual([3, 3]);
183 expect(t.rivals[0].h).toBe(2);
184 });
185
186 test('a rival blocked by another rival stays and turns right', () => {
187 const t = ok(step(set([{ x: 1, y: 1, h: 1 }, { x: 2, y: 1, h: 1 }])));
188 expect(pos(t.rivals[0])).toEqual([1, 1]);
189 expect(t.rivals[0].h).toBe(2);
190 expect(pos(t.rivals[1])).toEqual([3, 1]);
191 });
192
193 test('a shove into another car is refused: you stay put and a blocked event is returned', () => {
194 // rival 1 sits across your path at (4,3); the shove would push it north onto rival 2 at (4,2)
195 const r = step(set([{ x: 4, y: 3, h: 1 }, { x: 4, y: 2, h: 1 }], { x: 4, y: 4, h: 0 }));
196 expect(r.events).toEqual([{ kind: 'blocked', car: 1 }]);
197 expect(pos(r.state.player)).toEqual([4, 4]);
198 expect(r.state.score).toBe(0);
199 expect(r.state.lives).toBe(LIVES);
200 });
201
202 test('rivals stop moving once lives hit 0 within the tick', () => {
203 const s = set([{ x: 4, y: 3, h: 2 }, { x: 1, y: 1, h: 1 }], { x: 4, y: 4, h: 0 });
204 s.lives = 1;
205 const t = ok(step(s));
206 expect(t.status).toBe('over');
207 expect(pos(t.rivals[1])).toEqual([1, 1]);
208 expect(t.rivals[1].at).toBe(0);
209 // with a life to spare the same rival does move
210 const u = ok(step(set([{ x: 4, y: 3, h: 2 }, { x: 1, y: 1, h: 1 }], { x: 4, y: 4, h: 0 })));
211 expect(pos(u.rivals[1])).toEqual([2, 1]);
212 });
213
214 test('lines use singular wording for one life and one wall shove', () => {
215 const s = set([], { x: 4, y: 4, h: 0 });
216 s.lives = 1; s.shoves = 1; s.score = SHOVE_SCORE;
217 expect(ok(step(s)).line).toBe('Tick 1 of 60: score 10, 1 life, 1 wall shove');
218 const w = set(); w.tick = TICKS - 1; w.shoves = 1; w.score = SHOVE_SCORE;
219 expect(ok(step(w)).line).toBe('Round won: score 10, 1 wall shove');
220 expect(start(1).line).toBe('Tick 0 of 60: score 0, 3 lives, 0 wall shoves');
221 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs