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

World: motes, momentum, and a current field only other players write #20

Merged opened this

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

Merged

vesper/drift:vesper/5-world-motes-momentum-and-a-current-field → main 4 commits fork 3 threads · 2 unresolved

  • Merged. vesper/5-world-motes-momentum-and-a-current-field was merged into main as b783656.

Review

Sign in to review this change.
3 files changed +922 −0
README.md +10
@@ -24,6 +24,15 @@ them; the regions are shells with honest empty states, and the round loop,
2424 the canvas and the ladder fill them in the issues that follow. `POST /mcp`
2525 answers and lists no tool.
2626
27 The sea, on the other hand, is real arithmetic that nothing is plugged into
28 yet. `src/world.ts` advances one tick — motes, momentum, the current field,
29 the rim and the gate — with no clock, no database and no network, and the
30 same world always yields the same next one because every source of chance is
31 the seed in the state. `bun test` drives it by hand and proves all five
32 invariants of the record against it, each named by its number. The loop that
33 calls `step` twenty times a second and the socket that carries the frames are
34 the issues after this one.
35
2736 ## The game
2837
2938 The only terrain is the wake other players leave. Your thruster is feeble;
@@ -44,6 +53,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
4453 | file | what it is |
4554 | ---------------------------------- | ----------------------------------------------------------------- |
4655 | `src/actions.ts` | every operation, and who may — empty until #DATA |
56 | `src/world.ts` | the simulation: motes, momentum and the current field |
4757 | `src/protocol.ts` | the wire: one frame down, one steer up, quantised and sized |
4858 | `src/who.ts` | who is asking, from the forge's signed headers |
4959 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
src/world.test.ts added +413
@@ -0,0 +1,413 @@
1 /**
2 * The five invariants from the concept (#WAKE), proven by driving ticks by
3 * hand, plus the rules the rest of the app will lean on. Each invariant test
4 * is named by its number in `INVARIANTS` (`src/concept.ts`, the record in
5 * `docs/wake.md`), so a failure says which promise broke, and the round it
6 * gets to do it in is that file's number rather than another copy of it.
7 *
8 * Every invariant test is written so that it fails if the current field is
9 * taken away — both halves of it, the writing and the drag: each asserts a
10 * floor and a ceiling, so a world where nothing carries anything is as wrong
11 * as one where everything does.
12 */
13
14 import { expect, test } from "bun:test";
15 import { ROUND_SECONDS } from "./concept.ts";
16 import {
17 ARENA_RADIUS,
18 DRAG,
19 FIELD_CELL,
20 FIELD_CELLS,
21 FIELD_MAX,
22 GATE_HALF_ANGLE,
23 SCATTER_SPEED,
24 STUN_TICKS,
25 THRUST_MAX,
26 TICK_HZ,
27 addMote,
28 applyInput,
29 createWorld,
30 fieldMagnitude,
31 moteById,
32 ownWake,
33 removeMote,
34 sampleField,
35 step,
36 withinGate,
37 type Body,
38 type World,
39 } from "./world.ts";
40
41 const ticks = (seconds: number): number => Math.round(seconds * TICK_HZ);
42 const speed = (b: Body): number => Math.hypot(b.vx, b.vy);
43
44 /** Steer a mote at a point, the way a hand on a screen would. */
45 function aim(w: World, b: Body, x: number, y: number): void {
46 const dx = x - b.x;
47 const dy = y - b.y;
48 const d = Math.hypot(dx, dy) || 1;
49 applyInput(w, b.id, { x: dx / d, y: dy / d });
50 }
51
52 /** A mote placed exactly where a test wants it, rather than where the seed put it. */
53 function place(w: World, name: string, x: number, y: number): Body {
54 const m = addMote(w, name);
55 m.x = x;
56 m.y = y;
57 return m;
58 }
59
60 // ── The five invariants ────────────────────────────────────────────────
61
62 test("invariant 1: a lone mote at full thrust cannot cross the arena inside 45 seconds", () => {
63 const w = createWorld({ seed: 11 });
64 const m = place(w, "solo", -ARENA_RADIUS + 20, 0);
65 applyInput(w, m.id, { x: 1, y: 0 });
66 for (let t = 0; t < ticks(45); t++) step(w);
67
68 const travelled = m.x - (-ARENA_RADIUS + 20);
69 // Far from the other side: the sea is flat when you are alone in it.
70 expect(travelled).toBeLessThan(2 * ARENA_RADIUS);
71 // But moving: a mote that never started would pass a bare ceiling test.
72 // The ceiling is the drag half of the field — deposits alone off, drag
73 // toward a still sea still holds this down; take the drag away too and the
74 // thruster runs away with it long before 45 s.
75 expect(travelled).toBeGreaterThan(400);
76 // Its cruising speed is what the thruster and the drag agree on.
77 expect(speed(m)).toBeGreaterThan(0.75 * (THRUST_MAX / DRAG));
78 expect(speed(m)).toBeLessThan(1.4 * (THRUST_MAX / DRAG));
79 });
80
81 test("invariant 2: a mote in a lane two others built crosses the arena in under 10 seconds", () => {
82 const lane = createWorld({ seed: 11 });
83 const start = -ARENA_RADIUS + 60;
84 const builders = [
85 place(lane, "b1", start, -5),
86 place(lane, "b2", start, 5),
87 ];
88 const rider = place(lane, "rider", start, 0);
89 let crossed = -1;
90 for (let t = 0; t < ticks(20) && crossed < 0; t++) {
91 for (const b of builders) {
92 // A builder steers out of the lane before the rim can scatter it.
93 if (b.x > ARENA_RADIUS - 160) aim(lane, b, b.x, b.y + 400);
94 else applyInput(lane, b.id, { x: 1, y: 0 });
95 }
96 applyInput(lane, rider.id, { x: 1, y: 0 });
97 step(lane);
98 if (rider.x >= ARENA_RADIUS - 20) crossed = t;
99 }
100 expect(crossed).toBeGreaterThanOrEqual(0);
101 expect(crossed).toBeLessThan(ticks(10));
102
103 // The same ten seconds alone, for the same thruster: the lane is the
104 // difference, so with no field this test has nothing left to prove.
105 const alone = createWorld({ seed: 11 });
106 const solo = place(alone, "solo", start, 0);
107 applyInput(alone, solo.id, { x: 1, y: 0 });
108 for (let t = 0; t < ticks(10); t++) step(alone);
109 expect(solo.x - start).toBeLessThan((rider.x - start) / 4);
110 });
111
112 test("invariant 3: one mote thrusting at the bloom for ten seconds moves it, but less than one cell", () => {
113 const w = createWorld({ seed: 11 });
114 const m = place(w, "pusher", -80, 0);
115 applyInput(w, m.id, { x: 1, y: 0 });
116 for (let t = 0; t < ticks(10); t++) step(w);
117
118 const moved = Math.hypot(w.bloom.x, w.bloom.y);
119 // It is the current that reaches the bloom, never the mote: no contact.
120 expect(moved).toBeGreaterThan(0);
121 expect(moved).toBeLessThan(FIELD_CELL);
122 expect(w.crossedAt).toBeNull();
123 });
124
125 test("invariant 4: three motes braiding one lane put the bloom through the gate, and one alone cannot", () => {
126 expect(braid(3)).toBeLessThan(ticks(ROUND_SECONDS));
127 expect(braid(1)).toBeNull();
128 });
129
130 /** Swim a crew up and down one lane aimed at the gate; the tick the bloom scores, or null. */
131 function braid(crew: number): number | null {
132 const w = createWorld({ seed: 11, gateAngle: 0 });
133 const swimmers: Body[] = [];
134 for (let i = 0; i < crew; i++) {
135 swimmers.push(place(w, `c${i}`, -60, (i - (crew - 1) / 2) * 12));
136 }
137 const looping = swimmers.map(() => false);
138 for (let t = 0; t < ticks(ROUND_SECONDS); t++) {
139 for (let i = 0; i < swimmers.length; i++) {
140 const b = swimmers[i]!;
141 const offset = (i - (swimmers.length - 1) / 2) * 12;
142 if (looping[i]) {
143 // Back to the top of the lane, wide of it, so as not to lay a wake
144 // against the one being built.
145 if (b.x < w.bloom.x - 120) looping[i] = false;
146 aim(w, b, w.bloom.x - 160, w.bloom.y + offset + 150 * Math.sign(offset || 1));
147 } else {
148 if (b.x > w.bloom.x + 150) looping[i] = true;
149 aim(w, b, w.bloom.x + 400, w.bloom.y + offset);
150 }
151 }
152 step(w);
153 if (w.crossedAt !== null) return w.crossedAt;
154 }
155 return null;
156 }
157
158 test("invariant 5: with nothing moving in it the sea strictly empties, and reaches zero", () => {
159 const w = createWorld({ seed: 11 });
160 const m = place(w, "stirrer", -100, 0);
161 applyInput(w, m.id, { x: 1, y: 0.3 });
162 for (let t = 0; t < ticks(5); t++) step(w);
163 expect(fieldMagnitude(w.field)).toBeGreaterThan(0);
164
165 // Everybody leaves and the bloom is held still: nothing is writing.
166 removeMote(w, m.id);
167 expect(w.motes).toHaveLength(0);
168 let previous = fieldMagnitude(w.field);
169 let emptied = -1;
170 for (let t = 0; t < ticks(120); t++) {
171 w.bloom.vx = 0;
172 w.bloom.vy = 0;
173 step(w);
174 const now = fieldMagnitude(w.field);
175 if (previous > 0) expect(now).toBeLessThan(previous);
176 if (now === 0 && emptied < 0) emptied = t;
177 previous = now;
178 }
179 expect(emptied).toBeGreaterThan(0);
180 expect(fieldMagnitude(w.field)).toBe(0);
181 });
182
183 // ── The rules the rest of the app leans on ─────────────────────────────
184
185 test("the same world and the same inputs give the same next world, saved or not", () => {
186 const drive = (saveAt: number): World => {
187 let w = createWorld({ seed: 4242 });
188 const ids = [addMote(w, "a").id, addMote(w, "b").id];
189 for (let t = 0; t < 200; t++) {
190 // Put the world through the database and back in the middle of a round:
191 // what comes out has to keep ticking into the same world.
192 if (t === saveAt) w = JSON.parse(JSON.stringify(w)) as World;
193 applyInput(w, ids[0]!, { x: Math.cos(t / 9), y: Math.sin(t / 9) });
194 applyInput(w, ids[1]!, { x: -1, y: t < 100 ? 0.5 : -0.5 });
195 step(w);
196 }
197 return w;
198 };
199 expect(JSON.stringify(drive(-1))).toBe(JSON.stringify(drive(-1)));
200 expect(JSON.stringify(drive(100))).toBe(JSON.stringify(drive(-1)));
201 });
202
203 test("a world is plain JSON, and a tick does not make new arrays", () => {
204 const w = createWorld({ seed: 5 });
205 addMote(w, "one");
206 const fx = w.field.x;
207 const fy = w.field.y;
208 for (let t = 0; t < 50; t++) step(w);
209 expect(w.field.x).toBe(fx);
210 expect(w.field.y).toBe(fy);
211 expect(w.field.x).toHaveLength(FIELD_CELLS * FIELD_CELLS);
212 expect(w.field.y).toHaveLength(FIELD_CELLS * FIELD_CELLS);
213 expect(JSON.parse(JSON.stringify(w))).toEqual(w);
214 });
215
216 test("input is an acceleration, clipped, and nonsense is refused rather than thrown", () => {
217 const w = createWorld({ seed: 6 });
218 const m = place(w, "m", 0, 0);
219
220 expect(applyInput(w, m.id, { x: 5, y: 0 })).toBe(true);
221 expect(m.thrust.x).toBeCloseTo(THRUST_MAX, 6);
222 expect(applyInput(w, m.id, { x: 0.5, y: 0 })).toBe(true);
223 expect(m.thrust.x).toBeCloseTo(THRUST_MAX / 2, 6);
224
225 for (const bad of [{ x: NaN, y: 0 }, { x: 0, y: Infinity }] as const) {
226 expect(applyInput(w, m.id, bad)).toBe(false);
227 }
228 expect(applyInput(w, 9999, { x: 1, y: 0 })).toBe(false);
229 expect(m.thrust.x).toBeCloseTo(THRUST_MAX / 2, 6);
230
231 // One tick of full thrust is an acceleration, not a jump.
232 applyInput(w, m.id, { x: 1, y: 0 });
233 step(w);
234 expect(m.vx).toBeCloseTo(THRUST_MAX / TICK_HZ, 1);
235 expect(Math.hypot(m.x, m.y)).toBeLessThan(FIELD_CELL);
236 });
237
238 test("nothing stops instantly: letting go leaves the momentum", () => {
239 const w = createWorld({ seed: 7 });
240 const m = place(w, "m", 0, 0);
241 applyInput(w, m.id, { x: 1, y: 0 });
242 for (let t = 0; t < ticks(10); t++) step(w);
243 const carried = speed(m);
244 expect(carried).toBeGreaterThan(5);
245
246 applyInput(w, m.id, { x: 0, y: 0 });
247 step(w);
248 expect(speed(m)).toBeGreaterThan(carried * 0.9);
249 for (let t = 0; t < ticks(10); t++) step(w);
250 expect(speed(m)).toBeLessThan(carried * 0.5);
251 expect(speed(m)).toBeGreaterThan(0);
252 });
253
254 test("the rim scatters what arrives fast and beaches what arrives slow", () => {
255 const fast = createWorld({ seed: 8 });
256 const runner = place(fast, "fast", ARENA_RADIUS - 2, 0);
257 runner.vx = SCATTER_SPEED * 2;
258 const arriving = speed(runner);
259 step(fast);
260 expect(runner.vx).toBeLessThan(0);
261 expect(speed(runner)).toBeLessThan(arriving * 0.55);
262 expect(speed(runner)).toBeGreaterThan(arriving * 0.4);
263 expect(runner.stunnedUntil).toBe(fast.tick + STUN_TICKS);
264 expect(Math.hypot(runner.x, runner.y)).toBeLessThanOrEqual(ARENA_RADIUS + 1e-9);
265
266 const slow = createWorld({ seed: 8 });
267 const drifter = place(slow, "slow", ARENA_RADIUS - 0.5, 0);
268 drifter.vx = 20;
269 drifter.vy = 20;
270 step(slow);
271 expect(drifter.stunnedUntil).toBe(0);
272 // What pushed through the rim is gone; what ran along it is kept.
273 expect(Math.abs(drifter.vx)).toBeLessThan(0.5);
274 expect(drifter.vy).toBeGreaterThan(15);
275 expect(Math.hypot(drifter.x, drifter.y)).toBeCloseTo(ARENA_RADIUS, 6);
276 });
277
278 test("a stunned mote has no thruster until the stun runs out", () => {
279 const w = createWorld({ seed: 8 });
280 const m = place(w, "stunned", 0, 0);
281 m.stunnedUntil = w.tick + STUN_TICKS;
282 applyInput(w, m.id, { x: 1, y: 0 });
283 for (let t = 0; t < STUN_TICKS; t++) step(w);
284 expect(speed(m)).toBe(0);
285 step(w);
286 expect(m.vx).toBeGreaterThan(0);
287 });
288
289 test("the bloom is mass fifty with no thruster, and the gate is the only way out", () => {
290 const w = createWorld({ seed: 9, gateAngle: 0 });
291 expect(w.bloom.mass).toBe(50);
292 expect(w.bloom.thrust).toEqual({ x: 0, y: 0 });
293 // Steering cannot reach it: it is not a mote.
294 expect(applyInput(w, w.bloom.id, { x: 1, y: 0 })).toBe(false);
295 expect(moteById(w, w.bloom.id)).toBeUndefined();
296
297 // Out through the far rim, away from the gate: no score, and it is kept in.
298 const wall = createWorld({ seed: 9, gateAngle: Math.PI });
299 wall.bloom.x = ARENA_RADIUS - 1;
300 wall.bloom.vx = 40;
301 step(wall);
302 expect(wall.crossedAt).toBeNull();
303 expect(Math.hypot(wall.bloom.x, wall.bloom.y)).toBeLessThanOrEqual(ARENA_RADIUS + 1e-9);
304
305 // Out through the gate: the tick is reported, once.
306 w.bloom.x = ARENA_RADIUS - 1;
307 w.bloom.vx = 40;
308 step(w);
309 expect(w.crossedAt).toBe(w.tick);
310 const scored = w.crossedAt;
311 step(w);
312 expect(w.crossedAt).toBe(scored);
313 expect(withinGate(w.gate, 0)).toBe(true);
314 expect(withinGate(w.gate, GATE_HALF_ANGLE * 2)).toBe(false);
315 });
316
317 test("a bloom that has scored stops writing, and the sea still empties", () => {
318 const w = createWorld({ seed: 9, gateAngle: 0 });
319 // Two motes braid a lane up the inside of the rim, through the door. The
320 // current that put the bloom through is still running at the moment it
321 // scores, which is the ordinary case — a lane is what got it there.
322 const crew = [place(w, "b1", 490, -120), place(w, "b2", 494, -112)];
323 for (let t = 0; t < ticks(4); t++) {
324 for (const b of crew) aim(w, b, 490, 400);
325 step(w);
326 }
327 // The premise, pinned: there is a real current at the door to drag a
328 // scored bloom off it. Without this the test could pass on an empty sea.
329 const atTheDoor = sampleField(w.field, ARENA_RADIUS - 1, 0);
330 expect(Math.hypot(atTheDoor.x, atTheDoor.y)).toBeGreaterThan(50);
331
332 w.bloom.x = ARENA_RADIUS - 1;
333 w.bloom.y = 0;
334 w.bloom.vx = 40;
335 step(w);
336 expect(w.crossedAt).toBe(w.tick);
337 expect(speed(w.bloom)).toBe(0);
338
339 // Everyone leaves, so nothing but the bloom could still be writing. It is
340 // out of the world: it rests in the door it went through however hard the
341 // lane it rode is still running, and the sea it left behind empties.
342 for (const b of crew) removeMote(w, b.id);
343 const resting = { x: w.bloom.x, y: w.bloom.y };
344 for (let t = 0; t < ticks(60); t++) step(w);
345 expect({ x: w.bloom.x, y: w.bloom.y }).toEqual(resting);
346 expect(fieldMagnitude(w.field)).toBe(0);
347 });
348
349 test("the gate is a sixty degree arc the seed places, and a given seed places it the same way", () => {
350 const a = createWorld({ seed: 1234 });
351 const b = createWorld({ seed: 1234 });
352 expect(a.gate).toEqual(b.gate);
353 expect(a.gate.half).toBeCloseTo(Math.PI / 6, 12);
354 expect(createWorld({ seed: 77 }).gate.angle).not.toBe(a.gate.angle);
355 expect(createWorld({ seed: 1, gateAngle: 7 }).gate.angle).toBeCloseTo(7 - 2 * Math.PI, 12);
356 });
357
358 test("a mote arrives inside the arena, with a bounded name, and can leave", () => {
359 const w = createWorld({ seed: 21 });
360 for (let i = 0; i < 24; i++) {
361 const m = addMote(w, `swimmer ${i}`);
362 expect(Math.hypot(m.x, m.y)).toBeLessThan(ARENA_RADIUS);
363 }
364 expect(new Set(w.motes.map((m) => m.id)).size).toBe(24);
365 expect(addMote(w, " x".repeat(40)).name.length).toBeLessThanOrEqual(24);
366 expect(addMote(w, "line\nbreak").name).toBe("line break");
367 expect(addMote(w, " ").name).toBe("mote");
368 expect(addMote(w, 7 as unknown as string).name).toBe("mote");
369
370 const going = w.motes[0]!;
371 expect(removeMote(w, going.id)).toBe(true);
372 expect(removeMote(w, going.id)).toBe(false);
373 expect(moteById(w, going.id)).toBeUndefined();
374 });
375
376 test("the sea's own wake fit still matches the sea", () => {
377 // `ownWake` is what a body subtracts before drag, and it is a fit to the
378 // field this very code lays down. Change FIELD_SPREAD, FIELD_HALF_LIFE or
379 // the cell size and this is the test that says the fit has to be measured
380 // again: the drag of every body depends on it.
381 for (const v of [10, 40, 160]) {
382 const w = createWorld({ seed: 3 });
383 const m = place(w, "probe", -300, 7.3);
384 let read = 0;
385 for (let t = 0; t < 400; t++) {
386 m.vx = v;
387 m.vy = 0;
388 step(w);
389 m.vx = v;
390 m.vy = 0;
391 if (m.x > 300) m.x = -300;
392 if (t > 200) read = Math.max(read, sampleField(w.field, m.x, m.y).x);
393 }
394 const modelled = ownWake(1, v);
395 expect(modelled).toBeGreaterThan(read * 0.9);
396 expect(modelled).toBeLessThan(read * 1.6);
397 }
398 });
399
400 test("no cell of the sea runs faster than the cap", () => {
401 const w = createWorld({ seed: 31 });
402 const crew: Body[] = [];
403 for (let i = 0; i < 12; i++) crew.push(place(w, `c${i}`, -20 + i, 0));
404 for (let t = 0; t < ticks(30); t++) {
405 for (const b of crew) applyInput(w, b.id, { x: 1, y: 0 });
406 step(w);
407 for (let i = 0; i < w.field.x.length; i++) {
408 expect(Math.hypot(w.field.x[i]!, w.field.y[i]!)).toBeLessThanOrEqual(FIELD_MAX + 1e-9);
409 }
410 }
411 // Twelve in one lane is the fastest thing in the world, and it stays in it.
412 for (const b of w.motes) expect(Math.hypot(b.x, b.y)).toBeLessThanOrEqual(ARENA_RADIUS + 1e-9);
413 });
src/world.ts added +499
@@ -0,0 +1,499 @@
1 /**
resolved by ·
Show 1 comment

p

2 * The sea, as arithmetic. One module, no clock, no database, no network:
3 * a world goes in, the next tick comes out, and the same world always
4 * yields the same next one — every source of chance is the seed carried in
5 * the state itself.
6 *
7 * The one mechanic (#WAKE): a thruster is feeble, but every moving body
8 * writes its momentum into a coarse field of currents that decays in a few
9 * seconds, and that field drags every body inside it. A body never feels
10 * its own wake — speed is never yours, it is somebody else's last five
11 * seconds — so a lane is worth exactly the people in it.
12 *
13 * `step` advances the world in place and returns it. That is deliberate:
14 * the field is two fixed 1024-cell arrays and a tick allocates nothing, so
15 * there is one world rather than a chain of copies. Determinism is what
16 * purity buys here, and determinism is kept — clone the state if you want
17 * the one before.
18 */
19
20 // ── The numbers ────────────────────────────────────────────────────────
21 // Every constant a player can feel, in one block, so tuning never touches
22 // the logic below it. Lengths are units, times are seconds, speeds are
23 // units per second; the arena is 1000 units across.
24
25 /** Ticks per second. The round (#ROUND) drives `step` at this rate. */
26 export const TICK_HZ = 20;
27 /** Seconds in one tick. */
28 export const DT = 1 / TICK_HZ;
29 /** Half the arena's width: the rim is a circle of this radius about (0,0). */
30 export const ARENA_RADIUS = 500;
31
32 /** Cells per side of the current field. */
33 export const FIELD_CELLS = 32;
34 /** Width of one field cell, in units. */
35 export const FIELD_CELL = (2 * ARENA_RADIUS) / FIELD_CELLS;
36 /** Seconds for a still current to halve. */
37 export const FIELD_HALF_LIFE = 2;
38 /** What a cell keeps each tick — the half-life, per tick. */
39 export const FIELD_DECAY = 0.5 ** (DT / FIELD_HALF_LIFE);
40 /** Share of a cell handed to its four neighbours each tick: currents blur outward one cell. */
41 export const FIELD_SPREAD = 0.24;
42 /** No cell carries a current faster than this, however many bodies pile into it. */
43 export const FIELD_MAX = 600;
44 /** Below this a current is nothing, and is dropped so the sea reaches a true zero. */
45 export const FIELD_MIN = 1e-3;
46 /** Momentum a body writes into the sea per second, per unit of mass, per unit of speed. */
47 export const DEPOSIT = 20;
48 /**
49 * The most mass the sea takes from one body. Past this a heavier body
50 * writes a wider wake in a world we do not model, not a faster one — it
51 * keeps the bloom from saturating every cell it touches.
52 */
53 export const DEPOSIT_MASS_MAX = 2;
54
55 // How much of its own trail a body is sitting in, as a share of what it
56 // writes: `DEPOSIT * mass * WAKE_GAIN * s / (1 + s / WAKE_SPEED)`. These two
57 // describe the sea's geometry — cell size, decay, spread — and not the game;
58 // `world.test.ts` measures the sea and fails if they drift from it, so a
59 // tuning pass that moves FIELD_SPREAD or FIELD_HALF_LIFE re-measures here.
60 export const WAKE_GAIN = 0.187;
61 export const WAKE_SPEED = 159;
62 /**
63 * The sea keeps a shade more of your wake than the fit can see. Subtracting
64 * generously costs a rider a few percent of somebody else's lane and keeps a
65 * lone mote from ever being flattered by its own trail, which is the way the
66 * error has to fall.
67 */
68 export const WAKE_TRIM = 1.2;
69
70 /** How hard the sea pulls a body of mass `MOTE_MASS` toward the local current, per second. */
71 export const DRAG = 0.8;
72 /** The strongest acceleration a thruster can ask for. `THRUST_MAX / DRAG` is a lone mote's top speed. */
73 export const THRUST_MAX = 12.5;
74 /** A swimmer. */
75 export const MOTE_MASS = 1;
76 /** The bloom: fifty motes of inertia and no thruster at all. */
77 export const BLOOM_MASS = 50;
78
79 /** Arriving at the rim faster than this scatters a body instead of beaching it. */
80 export const SCATTER_SPEED = 45;
81 /** What is left of the velocity after a scatter — reversed, and this much of it. */
82 export const SCATTER_KEEP = 0.5;
83 /** Ticks a scattered body spends without a thruster (≈ 1.5 s). */
84 export const STUN_TICKS = 30;
85
86 /** Half-width of the gate arc: a 60° door on the rim. */
87 export const GATE_HALF_ANGLE = Math.PI / 6;
88 /** Motes appear inside this radius. */
89 export const SPAWN_RADIUS = 0.62 * ARENA_RADIUS;
90 /** Longest name the world keeps; the doors escape it, the world only bounds it. */
91 export const NAME_MAX = 24;
92
93 // ── The state ──────────────────────────────────────────────────────────
94
95 export interface Vec2 {
96 x: number;
97 y: number;
98 }
99
100 /** A body in the sea: a mote, or the bloom. */
101 export interface Body {
102 id: number;
103 name: string;
104 x: number;
105 y: number;
106 vx: number;
107 vy: number;
108 mass: number;
109 thrust: Vec2;
110 stunnedUntil: number;
111 }
112
113 /** The door on the rim, as the angle of its middle and its half-width. */
114 export interface Gate {
115 angle: number;
116 half: number;
117 }
118
119 /** The current field: two 32×32 grids of velocity, row-major, `y * 32 + x`. */
120 export interface Field {
121 x: number[];
122 y: number[];
123 }
124
125 export interface World {
126 tick: number;
127 /** The seeded generator. Every choice the world makes comes from here. */
128 seed: number;
129 nextId: number;
130 motes: Body[];
131 bloom: Body;
132 gate: Gate;
133 field: Field;
134 /** The tick the bloom went through the gate, or null while the round is on. */
135 crossedAt: number | null;
136 }
137
138 export interface WorldOptions {
139 seed?: number;
140 /** Where the gate sits this round; drawn from the seed when absent. */
141 gateAngle?: number;
142 }
143
144 export interface Steering {
145 x: number;
146 y: number;
147 }
148
149 const CELLS = FIELD_CELLS * FIELD_CELLS;
150
151 // One scratch grid, written whole before it is read, so a tick allocates
152 // nothing. Module-level and reused: `step` is deterministic, not re-entrant.
153 const spreadX = new Float64Array(CELLS);
154 const spreadY = new Float64Array(CELLS);
155
156 // ── Chance, from the state ─────────────────────────────────────────────
157
158 /** mulberry32: the state's seed in, the next number in [0, 1) out, seed advanced. */
159 export function nextRandom(state: World): number {
160 state.seed = (state.seed + 0x6d2b79f5) >>> 0;
161 let t = state.seed;
162 t = Math.imul(t ^ (t >>> 15), t | 1);
163 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
164 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
165 }
166
167 // ── Making one ─────────────────────────────────────────────────────────
168
169 /** A world with the bloom at the middle, a gate on the rim, a still sea and nobody in it. */
170 export function createWorld(options: WorldOptions = {}): World {
171 const seed = uint32(options.seed, 1);
172 const state: World = {
173 tick: 0,
174 seed,
175 nextId: 1,
176 motes: [],
177 bloom: body(0, "bloom", 0, 0, BLOOM_MASS),
178 gate: { angle: 0, half: GATE_HALF_ANGLE },
179 field: { x: new Array<number>(CELLS).fill(0), y: new Array<number>(CELLS).fill(0) },
180 crossedAt: null,
181 };
182 state.gate.angle = Number.isFinite(options.gateAngle)
183 ? wrapAngle(options.gateAngle as number)
184 : wrapAngle(nextRandom(state) * Math.PI * 2);
185 return state;
186 }
187
188 function body(id: number, name: string, x: number, y: number, mass: number): Body {
189 return {
190 id,
191 name,
192 x,
193 y,
194 vx: 0,
195 vy: 0,
196 mass,
197 thrust: { x: 0, y: 0 },
198 stunnedUntil: 0,
199 };
200 }
201
202 /** Put a mote in the sea, somewhere the seed chose, and hand back the mote. */
203 export function addMote(state: World, name: string): Body {
204 const angle = nextRandom(state) * Math.PI * 2;
205 const radius = SPAWN_RADIUS * Math.sqrt(nextRandom(state));
206 const mote = body(
207 state.nextId++,
208 cleanName(name),
209 Math.cos(angle) * radius,
210 Math.sin(angle) * radius,
211 MOTE_MASS,
212 );
213 state.motes.push(mote);
214 return mote;
215 }
216
217 /** Take a mote out. Its wake stays in the sea, which is the point of a wake. */
218 export function removeMote(state: World, moteId: number): boolean {
219 const at = state.motes.findIndex((m) => m.id === moteId);
220 if (at < 0) return false;
221 state.motes.splice(at, 1);
222 return true;
223 }
224
225 export function moteById(state: World, moteId: number): Body | undefined {
226 return state.motes.find((m) => m.id === moteId);
227 }
228
229 /**
230 * Steer a mote: `input` is a direction, anything longer than one unit is
231 * clipped to one, and what it sets is an *acceleration*, never a velocity.
232 * Nonsense in — a missing mote, an infinity, a NaN — is refused, not thrown.
233 *
234 * A mote id is not a permission. The world has no notion of who owns what,
235 * so every caller holds the mapping from a verified identity to a mote id
236 * on the server and never reads an id off the wire: take an id from a
237 * client and anyone steers anyone.
238 */
239 export function applyInput(state: World, moteId: number, input: Steering): boolean {
240 const mote = moteById(state, moteId);
241 if (mote === undefined) return false;
242 if (input === null || typeof input !== "object") return false;
243 const { x, y } = input;
244 if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
245 const length = Math.hypot(x, y);
246 if (length === 0) {
247 mote.thrust.x = 0;
248 mote.thrust.y = 0;
249 return true;
250 }
251 const scale = (THRUST_MAX * Math.min(length, 1)) / length;
252 mote.thrust.x = x * scale;
253 mote.thrust.y = y * scale;
254 return true;
255 }
256
257 // ── One tick ───────────────────────────────────────────────────────────
258
259 /**
260 * Drag, thrust, motion, the rim; then every body writes its wake; then the
261 * sea decays and spreads. Bodies read the field as it was at the top of the
262 * tick, so the order they are stored in cannot change the outcome.
263 */
264 export function step(state: World): World {
265 const motes = state.motes;
266 // A bloom that is through the gate is out of the world: it stopped where it
267 // scored and writes nothing more, so the sea still empties after a round is
268 // won whatever the round does about it.
269 const scored = state.crossedAt !== null;
270 for (let i = 0; i < motes.length; i++) advance(state, motes[i]!);
271 if (!scored) advance(state, state.bloom);

Changes requested — one thing, and it is small. (The forge's API records a verdict only as an anchored review comment: a bodyless POST .../reviews comes back bad_anchor. So the verdict is this comment.)

Everything from the first round was taken, and taken properly: the ownership warning now lives in applyInput's docstring where the next caller will read it, the Mote alias is gone, the misleading comment on invariant 1 is now an accurate description of which half of the field holds the mote down, and the determinism test got stronger than I asked — it saves and restores mid-round and proves the restored world ticks into the same one. At a4a04b3: 35 pass, 0 fail; 41.9 µs per tick at 24 motes; DEPOSIT = 0 fails 5 world tests and DRAG = 0 fails 5; a built lane still runs 195 u/s against a lone mote's 15.6.

The miss: the new guard on this line has no test that fails without it.

I deleted if (!scored) from both lines of step — the bloom advances and deposits after it has scored, exactly as before this commit — and all 35 tests stayed green. The new test cannot see it: its world has no motes and the bloom's velocity is zeroed by rim before deposit runs, so the sea is already empty and a bloom that keeps ticking in an empty sea sits still anyway. The test passes for the zeroing, not for the guard.

The guard is not redundant, which is why this matters. With a lane still running at the moment the bloom scores — the ordinary case, since a lane is what put it through the gate — I measured the difference:

  • with the guard: the bloom rests where it scored, moved 0.000 units over the next 60 s.
  • without it: the bloom is dragged 181 units off the gate and goes on writing into the sea for the rest of the round.

So today a tuning pass, or #8 reshaping step, can drop that line and stay green while the bloom wanders away from the door it just went through — on a canvas (#10) people will watch it happen.

Fix, concretely. In a bloom that has scored stops writing, and the sea still empties (src/world.test.ts:312), give the sea a current at the moment of the score. Two motes swimming toward the gate for a few seconds before you place the bloom at ARENA_RADIUS - 1 and score it, then removeMote both so nothing else is writing, then the same two assertions you already have: the bloom has not moved, and fieldMagnitude reaches 0. That version fails without if (!scored) — the bloom drifts — and it is the same test you meant to write.

Nothing else. The module is right: one concept, constants in one block, no dead code, no dependency, no door opened and therefore no who to check, input validated and names bounded at its own boundary, and the preview still answers / in 161 ms with the floor intact (CSP, nosniff, Referrer-Policy, frame-ancestors 'none', cross-origin POST 403). My two standing notes for later issues are unchanged: the identity→mote mapping must stay server-side (#9/#16), and a 33 KB field at 10 Hz needs a wire format before frames are designed (#16).

Approved. (The verdict is this comment: the forge's API takes a review only anchored to a line — a bodyless POST .../reviews still answers bad_anchor — so it lands on the line that was the miss last round.)

The one change I asked for is made, and it is now real. The test a bloom that has scored stops writing, and the sea still empties braids two motes up the inside of the rim first, pins the premise (sampleField at the door reads > 50, so there is a live current to drag a scored bloom off it), and only then scores. I deleted both if (!scored) guards from step at this head: the test goes red. Last round the same deletion stayed green. That is exactly the fix, written the way it needed to be written.

What I verified at 69391c5, by driving it rather than reading it

  • bun test: 65 pass, 0 fail across 8 files, 1.3 s. Nothing else in the suite moved.
  • The tests hold the mechanic, not the arithmetic. One mutation at a time: DEPOSIT = 0 reddens invariants 2/3/4/5; DRAG = 0 reddens 1/2/3/4; both together redden all five plus three more; WAKE_TRIM = 0 reddens six; FIELD_HALF_LIFE = 1e9 reddens six; removing the state.tick >= b.stunnedUntil guard, the Math.min(length, 1) thrust clip, or the body of cleanName each reddens exactly the test named for it.
  • 43 µs per tick at 24 motes after warm-up, allocating nothing — 0.09 % of a 50 ms tick. At 2000 motes it is 5.9 ms and every body is still finite and inside the rim, so nothing degenerates off the end of the roster.
  • The comment in step claims mote order cannot change the outcome. It is true: reversing motes and driving 300 ticks of varied steering gives byte-identical JSON. The advance-then-deposit split is what buys that, and #8's roster will lean on it hardest.
  • Preview: right, because it is unchanged — the module imports nothing and nothing imports it yet. / is 200 in 108 ms with the floor intact (CSP, nosniff, Referrer-Policy, HSTS), /events carries the same headers on the stream, POST /mcp answers {"tools":[]}, /nope is 404. The phone and desktop shots are #7's arena shell, unmoved.

On the three decisions the body flags. The amplifying sea and "a body never feels its own wake" are already written into docs/wake.md under Two clarifications, and the record explicitly leaves fitted-curve-versus-exact-ledger to #5 or #24 — #24 is open for it. DEPOSIT_MASS_MAX is a real deviation from #5's "scaled by mass" and it is the right one: without it the bloom's mass-50 wake saturates every cell it crosses and invariant 3 stops meaning anything. All three are stated in the block a tuner will read. No dependency. Nothing touches identity, a request body, a rate limit, SQL or a shell, and applyInput's docstring says out loud that a mote id is not a permission — the sentence #9 most needs to have read.

Four notes for later issues, none blocking this.

  1. STUN_TICKS is the one constant no test pins: set it to 0 and all 17 stay green, because the stun test's loop runs zero times and the rim test's assertion is self-referential (stunnedUntil === tick + STUN_TICKS on both sides). #42 owns what the rim costs; when it lands, assert a stunned mote is thrustless at tick 29 and moving at tick 31 against literals.
  2. cleanName strips C0 and \u007f and bounds to 24 — the right floor for the world. It does not touch U+202E or zero-width characters, and HTML escaping will not neutralise a bidi override in a ladder row or a tty.ts line. The door that first accepts a name a person typed (#12, #13) has to strip those, not only escape them.
  3. JSON.stringify(world) is ~31 KB with the field raw. That is protocol.ts's quantiser and #44's ceiling, but it is the number to keep in view: the frame cannot be the state.
  4. Own-wake superposes exactly only while no cell is clipped at FIELD_MAX; in a saturated lane the subtraction over-subtracts. #24's ground, and WAKE_TRIM already makes the error fall the way that costs a rider rather than flatters a loner.
272 for (let i = 0; i < motes.length; i++) deposit(state, motes[i]!);
273 if (!scored) deposit(state, state.bloom);
274 settle(state.field);
275 state.tick++;
276 return state;
277 }
278
279 /** Accelerate one body, move it, and answer to the rim. */
280 function advance(state: World, b: Body): void {
281 let ux = sample(state.field.x, b.x, b.y);
282 let uy = sample(state.field.y, b.x, b.y);
283 // Nobody rides their own wake: take back what this body laid under itself,
284 // and never more of the current than is running its way.
outdated

Approved. (The forge's API takes a verdict only as an anchored review comment here — POST .../reviews refuses a bodyless verdict with bad_anchor — so the verdict is this comment: approved, nothing blocking. Please ignore my stray p on line 1, a probe of that API with no delete route.)

I read every line of src/world.ts and src/world.test.ts against #5 and #2, ran the suite at 7302a14, mutation-tested the constants, and used the preview.

Verified, not just read

  • bun test at the head: 34 pass, 0 fail, 836 ms.
  • The invariants bite. With DEPOSIT = 0 five world tests fail; with DRAG = 0 five fail, invariant 1 among them. Across the two halves of the coupling, every one of the five fails when the field is taken away — which is what #5 asks for.
  • Mutation check on the tunables: DEPOSIT_MASS_MAX 2→50 fails 2 tests, SCATTER_KEEP 0.5→0.25 fails 1, FIELD_SPREAD 0.24→0.05 fails 5, FIELD_HALF_LIFE 2→6 fails 2, WAKE_TRIM 1.2→0 fails 6. These tests measure the sea; they do not restate the constants.
  • Determinism survives serialisation, not just a second run in one process: a world driven 100 ticks, put through JSON.parse(JSON.stringify(...)), then driven 100 more beside the original, stays byte-identical. That is the property #8 and #9 will lean on, and it is stronger than the test in the file.
  • 43.7 µs per tick at the roster cap of 24 motes, allocating nothing — 0.09% of the 50 ms budget.
  • Lane speed 195 u/s against a lone mote's 15.6 u/s: 12×, where #2 asked for 5×. The mechanic is there, emphatically.
  • Preview at this head answers / in 134 ms with the floor intact (CSP, nosniff, Referrer-Policy, frame-ancestors 'none') and a cross-origin POST to /api/notes is 403. The page is the untouched notes scaffold, which is right: #5 is explicitly headless and the canvas is #10. I am not holding a correctly scoped module to "the preview shows it working".

Security. This change opens no door, so there is no who to check in it. Its own boundary is sound: applyInput refuses NaN, Infinity, a non-object and a missing id rather than throwing; createWorld coerces seed and gateAngle; cleanName strips C0/DEL, bounds to 24 and falls back. No secrets, no shell, no SQL, no eval, no dependency, and nothing imported from db.ts, live.ts, env.ts or http.ts. I looked for a route to a NaN position and found none — the field is capped at FIELD_MAX, floored at FIELD_MIN, and cell indices are clamped on both the read and the write.

Notes, none blocking, each addressed to the issue that owns it

  1. #9/#16, and this is the one that matters. applyInput(state, moteId, input) keys on a mote id and the world has no notion of ownership — correct for this module, but it means the whole authorisation of steering ends up in the single line of the socket that maps a verified identity to a mote id. Hold that mapping server-side and never read an id off the wire, or anyone steers anyone.
  2. #16. JSON.stringify(world.field) is 33 KB; the whole world is 38 KB. At 10 Hz that is 380 KB/s per viewer if the field ships raw. Worth knowing before frames are designed.
  3. #8. After the bloom scores, rim sets crossedAt and clamps the bloom to the rim without touching its velocity, so it sits in the gate pushing outward and keeps depositing: 30 s of ticks past a score left field magnitude parked at 1173, never decaying. Harmless as long as the round stops on the score — so the round has to stop on it.
  4. Comment only. In world.test.ts, "without the field there is nothing to hold it back and it would be long gone" is not quite right: with deposits off, drag toward a zero field is exactly what still holds it back, and that test passes. Keep the floor assertion; the reason given for it is wrong.
  5. The non-conserving sea and the fitted ownWake are the two judgement calls, and both are disclosed in the constants block and the pull body rather than buried — the right place for them. WAKE_TRIM erring against a lone mote is the right direction for the error to fall. export type Mote = Body is a second name for one concept; drop it before it spreads.

One module named for one concept, every constant in one block, no dead code, no TODOs, no dependency, and nothing the direction did not ask for. Land it.

285 const speed = Math.hypot(b.vx, b.vy);
286 if (speed > 0) {
287 const hx = b.vx / speed;
288 const hy = b.vy / speed;
289 const ahead = ux * hx + uy * hy;
290 if (ahead > 0) {
291 const own = Math.min(ahead, ownWake(b.mass, speed));
292 ux -= own * hx;
293 uy -= own * hy;
294 }
295 }
296 // Heavier is slower to answer the sea: the bloom takes fifty times as long.
297 const mobility = MOTE_MASS / b.mass;
298 let ax = DRAG * mobility * (ux - b.vx);
299 let ay = DRAG * mobility * (uy - b.vy);
300 if (state.tick >= b.stunnedUntil) {
301 ax += b.thrust.x;
302 ay += b.thrust.y;
303 }
304 b.vx += ax * DT;
305 b.vy += ay * DT;
306 b.x += b.vx * DT;
307 b.y += b.vy * DT;
308 rim(state, b);
309 }
310
311 /** The edge of the world: a door for the bloom, a wall or a scattering for everyone. */
312 function rim(state: World, b: Body): void {
313 const r = Math.hypot(b.x, b.y);
314 if (r <= ARENA_RADIUS) return;
315 const nx = b.x / r;
316 const ny = b.y / r;
317 if (b === state.bloom && withinGate(state.gate, Math.atan2(b.y, b.x))) {
318 if (state.crossedAt === null) {
319 state.crossedAt = state.tick + 1;
320 // Through the door and done: this is the end of the round, not a
321 // collision, and it is the one place a body is allowed to stop dead.
322 b.vx = 0;
323 b.vy = 0;
324 }
325 } else if (Math.hypot(b.vx, b.vy) > SCATTER_SPEED) {
326 b.vx = -b.vx * SCATTER_KEEP;
327 b.vy = -b.vy * SCATTER_KEEP;
328 b.stunnedUntil = state.tick + 1 + STUN_TICKS;
329 } else {
330 // Slow arrival: keep what runs along the rim, lose what pushes through it.
331 const outward = b.vx * nx + b.vy * ny;
332 if (outward > 0) {
333 b.vx -= outward * nx;
334 b.vy -= outward * ny;
335 }
336 }
337 b.x = nx * ARENA_RADIUS;
338 b.y = ny * ARENA_RADIUS;
339 }
340
341 /** Write this body's momentum into the four cells under it. */
342 function deposit(state: World, b: Body): void {
343 const gain = DEPOSIT * depositMass(b.mass) * DT;
344 scatter(state.field, b.x, b.y, b.vx * gain, b.vy * gain);
345 }
346
347 const depositMass = (mass: number): number => Math.min(mass, DEPOSIT_MASS_MAX);
348
349 /**
350 * How much of its own trail a body reads under itself at this speed. Standing
351 * still it has written nothing; running, it is always one step ahead of what
352 * it wrote, and what it left has spread and faded behind it.
353 */
354 export function ownWake(mass: number, speed: number): number {
355 return (WAKE_TRIM * DEPOSIT * depositMass(mass) * WAKE_GAIN * speed) / (1 + speed / WAKE_SPEED);
356 }
357
358 /** Decay, spread one cell, cap, and drop what is left of nothing. */
359 function settle(field: Field): void {
360 const fx = field.x;
361 const fy = field.y;
362 const keep = FIELD_DECAY * (1 - FIELD_SPREAD);
363 const give = (FIELD_DECAY * FIELD_SPREAD) / 4;
364 for (let y = 0; y < FIELD_CELLS; y++) {
365 for (let x = 0; x < FIELD_CELLS; x++) {
366 const i = y * FIELD_CELLS + x;
367 let sx = 0;
368 let sy = 0;
369 if (x > 0) {
370 sx += fx[i - 1]!;
371 sy += fy[i - 1]!;
372 }
373 if (x < FIELD_CELLS - 1) {
374 sx += fx[i + 1]!;
375 sy += fy[i + 1]!;
376 }
377 if (y > 0) {
378 sx += fx[i - FIELD_CELLS]!;
379 sy += fy[i - FIELD_CELLS]!;
380 }
381 if (y < FIELD_CELLS - 1) {
382 sx += fx[i + FIELD_CELLS]!;
383 sy += fy[i + FIELD_CELLS]!;
384 }
385 spreadX[i] = keep * fx[i]! + give * sx;
386 spreadY[i] = keep * fy[i]! + give * sy;
387 }
388 }
389 for (let i = 0; i < CELLS; i++) {
390 let x = spreadX[i]!;
391 let y = spreadY[i]!;
392 const speed = Math.hypot(x, y);
393 if (speed < FIELD_MIN) {
394 x = 0;
395 y = 0;
396 } else if (speed > FIELD_MAX) {
397 x = (x * FIELD_MAX) / speed;
398 y = (y * FIELD_MAX) / speed;
399 }
400 fx[i] = x;
401 fy[i] = y;
402 }
403 }
404
405 // ── The field, read and written ────────────────────────────────────────
406
407 /** Where a world point falls on the grid, in cells, with the cell centre at the integer. */
408 function gridCoord(v: number): number {
409 return (v + ARENA_RADIUS) / FIELD_CELL - 0.5;
410 }
411
412 const clampCell = (i: number): number => (i < 0 ? 0 : i > FIELD_CELLS - 1 ? FIELD_CELLS - 1 : i);
413
414 /** Bilinear read of one grid at a world point. */
415 function sample(grid: number[], x: number, y: number): number {
416 const gx = gridCoord(x);
417 const gy = gridCoord(y);
418 const x0 = Math.floor(gx);
419 const y0 = Math.floor(gy);
420 const tx = gx - x0;
421 const ty = gy - y0;
422 const cx0 = clampCell(x0);
423 const cx1 = clampCell(x0 + 1);
424 const cy0 = clampCell(y0) * FIELD_CELLS;
425 const cy1 = clampCell(y0 + 1) * FIELD_CELLS;
426 return (
427 grid[cy0 + cx0]! * (1 - tx) * (1 - ty) +
428 grid[cy0 + cx1]! * tx * (1 - ty) +
429 grid[cy1 + cx0]! * (1 - tx) * ty +
430 grid[cy1 + cx1]! * tx * ty
431 );
432 }
433
434 /** The current at a world point, for a renderer or a test. */
435 export function sampleField(field: Field, x: number, y: number): Vec2 {
436 return { x: sample(field.x, x, y), y: sample(field.y, x, y) };
437 }
438
439 /** Bilinear write: the same four cells and the same weights as the read. */
440 function scatter(field: Field, x: number, y: number, dx: number, dy: number): void {
441 const gx = gridCoord(x);
442 const gy = gridCoord(y);
443 const x0 = Math.floor(gx);
444 const y0 = Math.floor(gy);
445 const tx = gx - x0;
446 const ty = gy - y0;
447 const cx0 = clampCell(x0);
448 const cx1 = clampCell(x0 + 1);
449 const cy0 = clampCell(y0) * FIELD_CELLS;
450 const cy1 = clampCell(y0 + 1) * FIELD_CELLS;
451 const w00 = (1 - tx) * (1 - ty);
452 const w10 = tx * (1 - ty);
453 const w01 = (1 - tx) * ty;
454 const w11 = tx * ty;
455 field.x[cy0 + cx0]! += dx * w00;
456 field.y[cy0 + cx0]! += dy * w00;
457 field.x[cy0 + cx1]! += dx * w10;
458 field.y[cy0 + cx1]! += dy * w10;
459 field.x[cy1 + cx0]! += dx * w01;
460 field.y[cy1 + cx0]! += dy * w01;
461 field.x[cy1 + cx1]! += dx * w11;
462 field.y[cy1 + cx1]! += dy * w11;
463 }
464
465 /** Everything the sea is carrying, as one number: the test for energy from nowhere. */
466 export function fieldMagnitude(field: Field): number {
467 let total = 0;
468 for (let i = 0; i < CELLS; i++) total += Math.hypot(field.x[i]!, field.y[i]!);
469 return total;
470 }
471
472 // ── Small arithmetic ───────────────────────────────────────────────────
473
474 /** An angle in (-π, π]. */
475 export function wrapAngle(a: number): number {
476 const turn = Math.PI * 2;
477 const r = a - Math.floor((a + Math.PI) / turn) * turn;
478 return r;
479 }
480
481 /** Whether an angle on the rim is inside the door. */
482 export function withinGate(gate: Gate, angle: number): boolean {
483 return Math.abs(wrapAngle(angle - gate.angle)) <= gate.half;
484 }
485
486 function uint32(value: unknown, fallback: number): number {
487 return typeof value === "number" && Number.isFinite(value)
488 ? Math.abs(Math.trunc(value)) % 4294967296
489 : fallback;
490 }
491
492 function cleanName(name: unknown): string {
493 if (typeof name !== "string") return "mote";
494 const clean = name
495 .replace(/[\u0000-\u001f\u007f]/g, " ")
496 .trim()
497 .slice(0, NAME_MAX);
498 return clean.length > 0 ? clean : "mote";
499 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs