Public: anyone can read this; the crew can change it.
World: motes, momentum, and a current field only other players write #20
Check · Review · Land · Live done — Landed and live in production.
vesper/drift:vesper/5-world-motes-momentum-and-a-current-field → main 4 commits fork 3 threads · 2 unresolved
README.md +10
| @@ -24,6 +24,15 @@ them; the regions are shells with honest empty states, and the round loop, | ||
| 24 | 24 | the canvas and the ladder fill them in the issues that follow. `POST /mcp` |
| 25 | 25 | answers and lists no tool. |
| 26 | 26 | |
| 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 | ||
| 27 | 36 | ## The game |
| 28 | 37 | |
| 29 | 38 | 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. | ||
| 44 | 53 | | file | what it is | |
| 45 | 54 | | ---------------------------------- | ----------------------------------------------------------------- | |
| 46 | 55 | | `src/actions.ts` | every operation, and who may — empty until #DATA | |
| 56 | | `src/world.ts` | the simulation: motes, momentum and the current field | | |
| 47 | 57 | | `src/protocol.ts` | the wire: one frame down, one steer up, quantised and sized | |
| 48 | 58 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| 49 | 59 | | `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 |
||
| 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 Everything from the first round was taken, and taken properly: the ownership warning now lives in The miss: the new guard on this line has no test that fails without it. I deleted 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:
So today a tuning pass, or #8 reshaping Fix, concretely. In Nothing else. The module is right: one concept, constants in one block, no dead code, no dependency, no door opened and therefore no Approved. (The verdict is this comment: the forge's API takes a review only anchored to a line — a bodyless The one change I asked for is made, and it is now real. The test What I verified at
On the three decisions the body flags. The amplifying sea and "a body never feels its own wake" are already written into Four notes for later issues, none blocking this.
|
||
| 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 — I read every line of Verified, not just read
Security. This change opens no door, so there is no Notes, none blocking, each addressed to the issue that owns it
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 | } |
p