Public: anyone can read this; the crew can change it.
The round, as state and one `tick`: no timer, no database, no socket in it #64
Check · Review · Land · Live done — Landed and live in production.
vesper/drift:vesper/58-the-round-as-state-and-one-tick-no-timer → main 3 commits fork 1 thread · 1 unresolved
3 files
changed +888
−3
README.md +13 −3
| @@ -60,9 +60,18 @@ yet. `src/world.ts` advances one tick — motes, momentum, the current field, | ||
| 60 | 60 | the rim and the gate — with no clock, no database and no network, and the |
| 61 | 61 | same world always yields the same next one because every source of chance is |
| 62 | 62 | the seed in the state. `bun test` drives it by hand and proves every |
| 63 | invariant the record states about the sea, each named by its number. The loop that | |
| 64 | calls `step` twenty times a second and the socket that carries the frames are | |
| 65 | the issues after this one. | |
| 63 | invariant the record states about the sea, each named by its number. | |
| 64 | ||
| 65 | The round is the same kind of thing one level up. `src/round.ts` is the | |
| 66 | clock, the end condition, the quiet between rounds, the next world and the | |
| 67 | seat bookkeeping — plain data and one `tick(round)` that returns the next | |
| 68 | round and a list of **effects**: `started`, `ended` with its outcome, and a | |
| 69 | `frame` every second tick. It holds no timer, no database and no socket, so | |
| 70 | its whole behaviour is reached by calling a function: 3600 calls is exactly | |
| 71 | one round, a bloom put through the gate ends it sooner, and one seed replays | |
| 72 | to the same state, gate after gate. What an effect *means* — a row, an | |
| 73 | emit, bytes on a socket — belongs to the loop that spends them, which is the | |
| 74 | issue after this one. | |
| 66 | 75 | |
| 67 | 76 | ## The game |
| 68 | 77 | |
| @@ -89,6 +98,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 89 | 98 | | ---------------------------------- | ----------------------------------------------------------------- | |
| 90 | 99 | | `src/actions.ts` | every operation, and who may: players, rounds, the ladder | |
| 91 | 100 | | `src/world.ts` | the simulation: motes, momentum and the current field | |
| 101 | | `src/round.ts` | the round: its clock, its end, its seats, and effects as data | | |
| 92 | 102 | | `src/protocol.ts` | the wire: one frame down, one steer up, quantised and sized | |
| 93 | 103 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| 94 | 104 | | `src/session.ts` | the guest key in a cookie, and the player id it derives | |
src/round.test.ts added +414
| @@ -0,0 +1,414 @@ | ||
| 1 | /** | |
| 2 | * The round's arithmetic, driven by hand. Nothing here sleeps, nothing sets | |
| 3 | * a timer and nothing opens a database: every rule in `src/round.ts` is | |
| 4 | * reached by calling `tick` a known number of times, which is the property | |
| 5 | * the module is built for — a round that can only be tested by waiting is a | |
| 6 | * round nobody tests. | |
| 7 | * | |
| 8 | * The numbers come from the record (`src/concept.ts`, `docs/wake.md`) rather | |
| 9 | * than being spelled again here, so a tuning pass moves them in one place. | |
| 10 | */ | |
| 11 | ||
| 12 | import { expect, test } from "bun:test"; | |
| 13 | import { | |
| 14 | IDLE_SECONDS, | |
| 15 | INTERMISSION_SECONDS, | |
| 16 | ROSTER_MAX, | |
| 17 | ROUND_SECONDS, | |
| 18 | UNSTEERED_SECONDS, | |
| 19 | } from "./concept.ts"; | |
| 20 | import { | |
| 21 | decodeFrame, | |
| 22 | encodeFrame, | |
| 23 | MOTE_STRIDE, | |
| 24 | POSITION_SCALE, | |
| 25 | VELOCITY_SCALE, | |
| 26 | type Frame, | |
| 27 | } from "./protocol.ts"; | |
| 28 | import { | |
| 29 | FRAME_EVERY, | |
| 30 | IDLE_TICKS, | |
| 31 | INTERMISSION_TICKS, | |
| 32 | ROUND_TICKS, | |
| 33 | UNSTEERED_TICKS, | |
| 34 | elapsedSeconds, | |
| 35 | frameOf, | |
| 36 | open, | |
| 37 | releaseSeat, | |
| 38 | remainingSeconds, | |
| 39 | steer, | |
| 40 | takeSeat, | |
| 41 | tick, | |
| 42 | type Effect, | |
| 43 | type Round, | |
| 44 | } from "./round.ts"; | |
| 45 | import { | |
| 46 | ARENA_RADIUS, | |
| 47 | DT, | |
| 48 | TICK_HZ, | |
| 49 | fieldMagnitude, | |
| 50 | moteById, | |
| 51 | withinGate, | |
| 52 | type World, | |
| 53 | } from "./world.ts"; | |
| 54 | ||
| 55 | const STARTED_AT = "2026-09-20T00:00:00.000Z"; | |
| 56 | ||
| 57 | /** Drive `n` ticks and hand back the round it left and everything it said. */ | |
| 58 | function run(round: Round, n: number): { round: Round; effects: Effect[] } { | |
| 59 | const effects: Effect[] = []; | |
| 60 | let at = round; | |
| 61 | for (let i = 0; i < n; i++) { | |
| 62 | const out = tick(at); | |
| 63 | at = out.round; | |
| 64 | effects.push(...out.effects); | |
| 65 | } | |
| 66 | return { round: at, effects }; | |
| 67 | } | |
| 68 | ||
| 69 | const kinds = (effects: readonly Effect[], kind: Effect["kind"]): Effect[] => | |
| 70 | effects.filter((e) => e.kind === kind); | |
| 71 | ||
| 72 | /** Put the bloom on the rim inside the gate, running outward through it. */ | |
| 73 | function aimBloomAtGate(world: World): void { | |
| 74 | const a = world.gate.angle; | |
| 75 | world.bloom.x = Math.cos(a) * (ARENA_RADIUS - 1); | |
| 76 | world.bloom.y = Math.sin(a) * (ARENA_RADIUS - 1); | |
| 77 | world.bloom.vx = Math.cos(a) * 100; | |
| 78 | world.bloom.vy = Math.sin(a) * 100; | |
| 79 | } | |
| 80 | ||
| 81 | // ── The clock ────────────────────────────────────────────────────────── | |
| 82 | ||
| 83 | test("a round is the tick count and nothing else: 3600 ticks is ROUND_SECONDS", () => { | |
| 84 | const round = open({ seed: 7, startedAt: STARTED_AT }); | |
| 85 | expect(ROUND_TICKS).toBe(ROUND_SECONDS * TICK_HZ); | |
| 86 | expect(remainingSeconds(round)).toBe(ROUND_SECONDS); | |
| 87 | ||
| 88 | const before = run(round, ROUND_TICKS - 1); | |
| 89 | expect(before.round.phase).toBe("running"); | |
| 90 | expect(kinds(before.effects, "ended")).toHaveLength(0); | |
| 91 | expect(elapsedSeconds(before.round)).toBeCloseTo(ROUND_SECONDS - 1 / TICK_HZ, 9); | |
| 92 | expect(remainingSeconds(before.round)).toBeCloseTo(1 / TICK_HZ, 9); | |
| 93 | ||
| 94 | const last = tick(before.round); | |
| 95 | const ended = kinds(last.effects, "ended"); | |
| 96 | expect(ended).toHaveLength(1); | |
| 97 | const end = ended[0]!; | |
| 98 | if (end.kind !== "ended") throw new Error("not an ended effect"); | |
| 99 | expect(end.outcome).toBe("clock"); | |
| 100 | expect(end.roundId).toBe(1); | |
| 101 | expect(end.startedAt).toBe(STARTED_AT); | |
| 102 | // The record's instants are the started-at plus a whole number of ticks, | |
| 103 | // never a second reading of the wall clock. | |
| 104 | expect(end.endedAt).toBe("2026-09-20T00:03:00.000Z"); | |
| 105 | expect(end.scores).toEqual([]); | |
| 106 | expect(last.round.phase).toBe("intermission"); | |
| 107 | expect(last.round.tick).toBe(0); | |
| 108 | }); | |
| 109 | ||
| 110 | test("a round announces itself once, on its first tick", () => { | |
| 111 | const round = open({ seed: 3, startedAt: STARTED_AT }); | |
| 112 | const first = tick(round); | |
| 113 | const started = kinds(first.effects, "started"); | |
| 114 | expect(started).toHaveLength(1); | |
| 115 | expect(started[0]).toEqual({ | |
| 116 | kind: "started", | |
| 117 | roundId: 1, | |
| 118 | startedAt: STARTED_AT, | |
| 119 | }); | |
| 120 | expect(kinds(run(first.round, 40).effects, "started")).toHaveLength(0); | |
| 121 | }); | |
| 122 | ||
| 123 | test("the bloom through the gate ends the round sooner, with outcome gate", () => { | |
| 124 | const round = open({ seed: 11, startedAt: STARTED_AT }); | |
| 125 | const early = run(round, 100); | |
| 126 | expect(early.round.phase).toBe("running"); | |
| 127 | aimBloomAtGate(early.round.world); | |
| 128 | ||
| 129 | const crossed = tick(early.round); | |
| 130 | expect(withinGate(crossed.round.world.gate, Math.atan2(crossed.round.world.bloom.y, crossed.round.world.bloom.x))).toBe(true); | |
| 131 | const ended = kinds(crossed.effects, "ended"); | |
| 132 | expect(ended).toHaveLength(1); | |
| 133 | const end = ended[0]!; | |
| 134 | if (end.kind !== "ended") throw new Error("not an ended effect"); | |
| 135 | expect(end.outcome).toBe("gate"); | |
| 136 | // 101 ticks in, not 3600: the clock was nowhere near out. | |
| 137 | expect(end.endedAt).toBe("2026-09-20T00:00:05.050Z"); | |
| 138 | expect(crossed.round.phase).toBe("intermission"); | |
| 139 | }); | |
| 140 | ||
| 141 | // ── The quiet, and the next world ────────────────────────────────────── | |
| 142 | ||
| 143 | test("the intermission is quiet, the sea keeps decaying, and the next round is new", () => { | |
| 144 | let round = open({ seed: 21, startedAt: STARTED_AT }); | |
| 145 | round = takeSeat(round, "key-a", "a").round; | |
| 146 | round = steer(round, "key-a", { x: 1, y: 0.3 }); | |
| 147 | round = run(round, 200).round; | |
| 148 | aimBloomAtGate(round.world); | |
| 149 | const gone = tick(round); | |
| 150 | expect(gone.round.phase).toBe("intermission"); | |
| 151 | const stirred = fieldMagnitude(gone.round.world.field); | |
| 152 | expect(stirred).toBeGreaterThan(0); | |
| 153 | // The round's end frees every seat, but the bodies stay in the sea with | |
| 154 | // their thrusters off: the quiet is what the round left, draining. | |
| 155 | expect(gone.round.roster).toEqual([]); | |
| 156 | const drifting = gone.round.world.motes[0]!; | |
| 157 | expect(drifting.thrust).toEqual({ x: 0, y: 0 }); | |
| 158 | ||
| 159 | const quiet = run(gone.round, INTERMISSION_TICKS - 1); | |
| 160 | expect(quiet.round.phase).toBe("intermission"); | |
| 161 | expect(kinds(quiet.effects, "started")).toHaveLength(0); | |
| 162 | expect(kinds(quiet.effects, "ended")).toHaveLength(0); | |
| 163 | expect(quiet.round.world.tick).toBe(201 + INTERMISSION_TICKS - 1); | |
| 164 | expect(fieldMagnitude(quiet.round.world.field)).toBeLessThan(stirred / 4); | |
| 165 | expect(remainingSeconds(quiet.round)).toBeCloseTo(1 / TICK_HZ, 9); | |
| 166 | ||
| 167 | const rolled = tick(quiet.round); | |
| 168 | expect(rolled.round.phase).toBe("running"); | |
| 169 | expect(rolled.round.id).toBe(2); | |
| 170 | expect(rolled.round.tick).toBe(0); | |
| 171 | expect(rolled.round.world.tick).toBe(0); | |
| 172 | expect(rolled.round.world.motes).toEqual([]); | |
| 173 | expect(fieldMagnitude(rolled.round.world.field)).toBe(0); | |
| 174 | // A new gate, drawn from the world's own seeded RNG. | |
| 175 | expect(rolled.round.world.gate.angle).not.toBe(gone.round.world.gate.angle); | |
| 176 | // 201 ticks of round, then the whole quiet: 10.05 s + 20 s, by arithmetic. | |
| 177 | expect(rolled.round.startedAt).toBe("2026-09-20T00:00:30.050Z"); | |
| 178 | expect(INTERMISSION_TICKS).toBe(INTERMISSION_SECONDS * TICK_HZ); | |
| 179 | // And it announces itself on its own first tick, like the first one did. | |
| 180 | expect(kinds(tick(rolled.round).effects, "started")).toHaveLength(1); | |
| 181 | }); | |
| 182 | ||
| 183 | test("a seat taken in the quiet swims the next round, in a new body", () => { | |
| 184 | const ended = run(open({ seed: 5, startedAt: STARTED_AT }), ROUND_TICKS); | |
| 185 | const waiting = takeSeat(ended.round, "key-b", "b"); | |
| 186 | expect(waiting.seat).not.toBeNull(); | |
| 187 | const rolled = run(waiting.round, INTERMISSION_TICKS); | |
| 188 | expect(rolled.round.phase).toBe("running"); | |
| 189 | expect(rolled.round.roster).toHaveLength(1); | |
| 190 | const seat = rolled.round.roster[0]!; | |
| 191 | expect(seat.playerKey).toBe("key-b"); | |
| 192 | expect(moteById(rolled.round.world, seat.moteId)?.name).toBe("b"); | |
| 193 | // The idle clock starts again with the new world, so nothing is released | |
| 194 | // for having been quiet during a round that had not begun. | |
| 195 | expect(seat.lastSteeredTick).toBe(0); | |
| 196 | }); | |
| 197 | ||
| 198 | test("one seed replays to the same state, gate chain and all", () => { | |
| 199 | const options = { seed: 99, startedAt: STARTED_AT } as const; | |
| 200 | const a = run(open(options), ROUND_TICKS + INTERMISSION_TICKS + 5); | |
| 201 | const b = run(open(options), ROUND_TICKS + INTERMISSION_TICKS + 5); | |
| 202 | expect(a.round).toEqual(b.round); | |
| 203 | expect(a.effects).toEqual(b.effects); | |
| 204 | expect(a.round.id).toBe(2); | |
| 205 | // And a different seed is a different world, or the replay proves nothing. | |
| 206 | const other = run(open({ seed: 100, startedAt: STARTED_AT }), 5); | |
| 207 | expect(other.round.world.gate.angle).not.toBe(a.round.world.gate.angle); | |
| 208 | }); | |
| 209 | ||
| 210 | // ── Seats ────────────────────────────────────────────────────────────── | |
| 211 | ||
| 212 | test("an idle seat goes at IDLE_SECONDS, an unsteered one 40 s earlier", () => { | |
| 213 | expect(IDLE_SECONDS - UNSTEERED_SECONDS).toBe(40); | |
| 214 | expect(IDLE_TICKS - UNSTEERED_TICKS).toBe(40 * TICK_HZ); | |
| 215 | ||
| 216 | let round = open({ seed: 2, startedAt: STARTED_AT }); | |
| 217 | round = takeSeat(round, "idle", "idle").round; | |
| 218 | round = takeSeat(round, "silent", "silent").round; | |
| 219 | round = steer(round, "idle", { x: 0, y: 1 }); | |
| 220 | ||
| 221 | const early = run(round, UNSTEERED_TICKS - 1); | |
| 222 | expect(early.round.roster.map((s) => s.playerKey)).toEqual(["idle", "silent"]); | |
| 223 | const swept = tick(early.round); | |
| 224 | expect(swept.round.roster.map((s) => s.playerKey)).toEqual(["idle"]); | |
| 225 | // The mote goes with the seat. | |
| 226 | expect(swept.round.world.motes.map((m) => m.name)).toEqual(["idle"]); | |
| 227 | ||
| 228 | const held = run(swept.round, IDLE_TICKS - UNSTEERED_TICKS - 1); | |
| 229 | expect(held.round.roster).toHaveLength(1); | |
| 230 | const released = tick(held.round); | |
| 231 | expect(released.round.roster).toEqual([]); | |
| 232 | expect(released.round.world.motes).toEqual([]); | |
| 233 | expect(released.round.world.tick).toBe(IDLE_TICKS); | |
| 234 | }); | |
| 235 | ||
| 236 | test("steering keeps a seat, so a swimmer is never swept mid-round", () => { | |
| 237 | let round = open({ seed: 4, startedAt: STARTED_AT }); | |
| 238 | round = takeSeat(round, "swimmer", "swimmer").round; | |
| 239 | for (let i = 0; i < ROUND_TICKS - 1; i++) { | |
| 240 | if (i % 100 === 0) round = steer(round, "swimmer", { x: 1, y: 0 }); | |
| 241 | round = tick(round).round; | |
| 242 | } | |
| 243 | expect(round.roster).toHaveLength(1); | |
| 244 | expect(round.roster[0]!.steered).toBe(true); | |
| 245 | }); | |
| 246 | ||
| 247 | test("one key is one seat, a full roster is a spectator, and a stranger steers nothing", () => { | |
| 248 | let round = open({ seed: 6, startedAt: STARTED_AT }); | |
| 249 | const first = takeSeat(round, "key", "name"); | |
| 250 | round = first.round; | |
| 251 | const again = takeSeat(round, "key", "another name"); | |
| 252 | expect(again.seat).toEqual(first.seat!); | |
| 253 | expect(again.round.roster).toHaveLength(1); | |
| 254 | expect(round.world.motes).toHaveLength(1); | |
| 255 | ||
| 256 | for (let i = 1; i < ROSTER_MAX; i++) round = takeSeat(round, `key-${i}`, `m${i}`).round; | |
| 257 | expect(round.roster).toHaveLength(ROSTER_MAX); | |
| 258 | const over = takeSeat(round, "late", "late"); | |
| 259 | expect(over.seat).toBeNull(); | |
| 260 | expect(over.round).toBe(round); | |
| 261 | expect(round.world.motes).toHaveLength(ROSTER_MAX); | |
| 262 | ||
| 263 | // A key with no seat is ignored, not an error, and moves nothing. | |
| 264 | const ignored = steer(round, "late", { x: 1, y: 0 }); | |
| 265 | expect(ignored).toBe(round); | |
| 266 | expect(round.world.motes.every((m) => m.thrust.x === 0)).toBe(true); | |
| 267 | // A seat given up takes its mote with it. | |
| 268 | const left = releaseSeat(round, "key"); | |
| 269 | expect(left.roster).toHaveLength(ROSTER_MAX - 1); | |
| 270 | expect(left.world.motes).toHaveLength(ROSTER_MAX - 1); | |
| 271 | expect(releaseSeat(left, "key")).toBe(left); | |
| 272 | }); | |
| 273 | ||
| 274 | test("a seat steers its own mote and nobody else's", () => { | |
| 275 | let round = open({ seed: 8, startedAt: STARTED_AT }); | |
| 276 | round = takeSeat(round, "a", "a").round; | |
| 277 | round = takeSeat(round, "b", "b").round; | |
| 278 | const [a, b] = round.roster; | |
| 279 | round = steer(round, "a", { x: 1, y: 0 }); | |
| 280 | expect(moteById(round.world, a!.moteId)!.thrust.x).toBeGreaterThan(0); | |
| 281 | expect(moteById(round.world, b!.moteId)!.thrust).toEqual({ x: 0, y: 0 }); | |
| 282 | expect(round.roster[0]!.steered).toBe(true); | |
| 283 | expect(round.roster[1]!.steered).toBe(false); | |
| 284 | }); | |
| 285 | ||
| 286 | // ── Frames ───────────────────────────────────────────────────────────── | |
| 287 | ||
| 288 | test("a frame every second tick, and never in between", () => { | |
| 289 | let round = open({ seed: 13, startedAt: STARTED_AT }); | |
| 290 | round = takeSeat(round, "a", "a").round; | |
| 291 | round = steer(round, "a", { x: 0.5, y: -0.5 }); | |
| 292 | const on: number[] = []; | |
| 293 | for (let i = 1; i <= 21; i++) { | |
| 294 | const out = tick(round); | |
| 295 | round = out.round; | |
| 296 | const frames = kinds(out.effects, "frame"); | |
| 297 | expect(frames.length).toBeLessThanOrEqual(1); | |
| 298 | if (frames.length === 1) on.push(i); | |
| 299 | } | |
| 300 | expect(on).toEqual([2, 4, 6, 8, 10, 12, 14, 16, 18, 20]); | |
| 301 | expect(FRAME_EVERY).toBe(2); | |
| 302 | expect(on.length / (21 / TICK_HZ)).toBeCloseTo(TICK_HZ / FRAME_EVERY, 0); | |
| 303 | }); | |
| 304 | ||
| 305 | test("a frame is the protocol's shape: it encodes, decodes and says the clock", () => { | |
| 306 | let round = open({ seed: 17, startedAt: STARTED_AT }); | |
| 307 | round = takeSeat(round, "a", "a").round; | |
| 308 | round = steer(round, "a", { x: 1, y: 0 }); | |
| 309 | round = run(round, 39).round; | |
| 310 | ||
| 311 | const out = tick(round); | |
| 312 | const frames = kinds(out.effects, "frame"); | |
| 313 | expect(frames).toHaveLength(1); | |
| 314 | const effect = frames[0]!; | |
| 315 | if (effect.kind !== "frame") throw new Error("not a frame effect"); | |
| 316 | const frame: Frame = effect.frame; | |
| 317 | expect(frame.roundId).toBe(1); | |
| 318 | expect(frame.tick).toBe(out.round.world.tick); | |
| 319 | expect(frame.clock).toBe(Math.round(remainingSeconds(out.round) * 1000)); | |
| 320 | expect(frame.motes.length).toBe(MOTE_STRIDE); | |
| 321 | ||
| 322 | const decoded = decodeFrame(encodeFrame(frame)); | |
| 323 | expect(decoded.ok).toBe(true); | |
| 324 | if (!decoded.ok) throw new Error(decoded.message); | |
| 325 | const mote = out.round.world.motes[0]!; | |
| 326 | expect(decoded.value.motes[0]).toBe(mote.id); | |
| 327 | // Half a quantiser step is what the wire promises to keep (#PROTOCOL). | |
| 328 | const tolerance = 1 / (2 * POSITION_SCALE); | |
| 329 | expect(Math.abs(decoded.value.motes[1]! - mote.x)).toBeLessThanOrEqual(tolerance); | |
| 330 | expect(Math.abs(decoded.value.motes[2]! - mote.y)).toBeLessThanOrEqual(tolerance); | |
| 331 | expect(Math.abs(decoded.value.motes[3]! - mote.vx * DT)).toBeLessThanOrEqual( | |
| 332 | 1 / (2 * VELOCITY_SCALE), | |
| 333 | ); | |
| 334 | expect(decoded.value.gate[0]).toBeCloseTo(out.round.world.gate.angle, 3); | |
| 335 | expect(decoded.value.clock).toBe(frame.clock); | |
| 336 | // The wire's field is the sea's, coarser: stirred here, so not all zero. | |
| 337 | expect(decoded.value.field.some((v) => v !== 0)).toBe(true); | |
| 338 | }); | |
| 339 | ||
| 340 | test("a frame carries at most the roster cap, whatever the sea holds", () => { | |
| 341 | let round = open({ seed: 19, startedAt: STARTED_AT }); | |
| 342 | for (let i = 0; i < ROSTER_MAX; i++) round = takeSeat(round, `k${i}`, `m${i}`).round; | |
| 343 | expect(round.world.motes).toHaveLength(ROSTER_MAX); | |
| 344 | const frame = frameOf(round); | |
| 345 | expect(frame.motes.length).toBe(ROSTER_MAX * MOTE_STRIDE); | |
| 346 | expect(() => encodeFrame(frame)).not.toThrow(); | |
| 347 | }); | |
| 348 | ||
| 349 | test("a seat taken in the quiet is on the wire, and the drifters take what room is left", () => { | |
| 350 | // A full round ends with its bodies still in the sea, so the quiet holds | |
| 351 | // more motes than a frame can carry. Whoever is seated comes first. | |
| 352 | let round = open({ seed: 23, startedAt: STARTED_AT }); | |
| 353 | for (let i = 0; i < ROSTER_MAX; i++) { | |
| 354 | round = takeSeat(round, `key-${i}`, `m${i}`).round; | |
| 355 | round = steer(round, `key-${i}`, { x: Math.cos(i), y: Math.sin(i) }); | |
| 356 | } | |
| 357 | round = run(round, 100).round; | |
| 358 | aimBloomAtGate(round.world); | |
| 359 | round = tick(round).round; | |
| 360 | expect(round.phase).toBe("intermission"); | |
| 361 | expect(round.roster).toEqual([]); | |
| 362 | const drifting = round.world.motes.length; | |
| 363 | expect(drifting).toBe(ROSTER_MAX); | |
| 364 | ||
| 365 | const arrival = takeSeat(round, "newcomer", "newcomer"); | |
| 366 | round = arrival.round; | |
| 367 | expect(round.world.motes).toHaveLength(ROSTER_MAX + 1); | |
| 368 | const ids = (frame: Frame): number[] => { | |
| 369 | const out: number[] = []; | |
| 370 | for (let i = 0; i < frame.motes.length; i += MOTE_STRIDE) out.push(frame.motes[i]!); | |
| 371 | return out; | |
| 372 | }; | |
| 373 | const one = frameOf(round); | |
| 374 | expect(one.motes.length).toBe(ROSTER_MAX * MOTE_STRIDE); | |
| 375 | // The newcomer is on the wire, and the dead round's leftovers fill the rest. | |
| 376 | expect(ids(one)).toContain(arrival.seat!.moteId); | |
| 377 | expect(ids(one)).toHaveLength(ROSTER_MAX); | |
| 378 | ||
| 379 | for (let i = 0; i < ROSTER_MAX - 1; i++) | |
| 380 | round = takeSeat(round, `next-${i}`, `n${i}`).round; | |
| 381 | expect(round.roster).toHaveLength(ROSTER_MAX); | |
| 382 | const full = frameOf(round); | |
| 383 | expect(ids(full).sort()).toEqual(round.roster.map((s) => s.moteId).sort()); | |
| 384 | expect(() => encodeFrame(full)).not.toThrow(); | |
| 385 | }); | |
| 386 | ||
| 387 | test("a frame in the quiet says the round's clock has run out", () => { | |
| 388 | const ended = run(open({ seed: 27, startedAt: STARTED_AT }), ROUND_TICKS); | |
| 389 | expect(ended.round.phase).toBe("intermission"); | |
| 390 | // The countdown to the next round is on the bus (#53), not in a number a | |
| 391 | // mid-quiet joiner would read as a round with 20 s left. | |
| 392 | expect(frameOf(ended.round).clock).toBe(0); | |
| 393 | expect(remainingSeconds(ended.round)).toBe(INTERMISSION_SECONDS); | |
| 394 | ||
| 395 | const running = run(ended.round, INTERMISSION_TICKS + 2).round; | |
| 396 | expect(running.phase).toBe("running"); | |
| 397 | expect(frameOf(running).clock).toBe(Math.round((ROUND_SECONDS - 2 / TICK_HZ) * 1000)); | |
| 398 | }); | |
| 399 | ||
| 400 | test("a seat carried into the next round has to steer it to keep it", () => { | |
| 401 | // Steering in the quiet is a nudge at a dying sea, not a round swum: the | |
| 402 | // carried seat starts unsteered, so the record's shorter patience applies. | |
| 403 | const ended = run(open({ seed: 29, startedAt: STARTED_AT }), ROUND_TICKS); | |
| 404 | let round = takeSeat(ended.round, "waiting", "waiting").round; | |
| 405 | round = steer(round, "waiting", { x: 1, y: 0 }); | |
| 406 | expect(round.roster[0]!.steered).toBe(true); | |
| 407 | ||
| 408 | round = run(round, INTERMISSION_TICKS).round; | |
| 409 | expect(round.phase).toBe("running"); | |
| 410 | expect(round.roster[0]!.steered).toBe(false); | |
| 411 | const held = run(round, UNSTEERED_TICKS - 1); | |
| 412 | expect(held.round.roster).toHaveLength(1); | |
| 413 | expect(tick(held.round).round.roster).toEqual([]); | |
| 414 | }); |
src/round.ts added +461
| @@ -0,0 +1,461 @@ | ||
| 1 | /** | |
| 2 | * A round, as state and one `tick`. The clock, the end condition, the quiet | |
| 3 | * between rounds, the next world and the seat bookkeeping the record decides | |
| 4 | * (#WAKE, "A seat") — all of it arithmetic, none of it waiting. | |
| 5 | * | |
| 6 | * There is no `setInterval` here, no `Database`, no socket and no bus. A | |
| 7 | * round is plain data; `tick(round)` returns the next round and a list of | |
| 8 | * **effects** as data — `started`, `ended`, `frame`. What an effect *means* | |
| 9 | * is the caller's: #53 owns the interval, writes the round row through | |
| 10 | * `recordRound`, emits on the bus and publishes the frame to the sockets. | |
| 11 | * That split is the whole point: every rule in this file is testable by | |
| 12 | * calling a function 3600 times, and nothing in it needs a clock to be true. | |
| 13 | * | |
| 14 | * The clock is the tick count times `DT` and nothing else. A wall-clock | |
| 15 | * delta would make a round longer on a busy box and shorter on an idle one, | |
| 16 | * and would make a replay from a seed impossible; the one instant this | |
| 17 | * module reads from the wall is the `startedAt` handed to `open`, and every | |
| 18 | * instant after it is that one plus a whole number of ticks. | |
| 19 | * | |
| 20 | * The world is stepped in place — that is `world.ts`'s deliberate choice, so | |
| 21 | * a tick allocates no grids — while the `Round` wrapper around it is a fresh | |
| 22 | * small object each tick, so a caller can hold `round = tick(round).round` | |
| 23 | * and never wonder which fields moved under it. | |
| 24 | */ | |
| 25 | ||
| 26 | import { | |
| 27 | IDLE_SECONDS, | |
| 28 | INTERMISSION_SECONDS, | |
| 29 | ROSTER_MAX, | |
| 30 | ROUND_SECONDS, | |
| 31 | UNSTEERED_SECONDS, | |
| 32 | } from "./concept.ts"; | |
| 33 | import { | |
| 34 | FIELD_LENGTH, | |
| 35 | FIELD_SIZE, | |
| 36 | MOTE_STUNNED, | |
| 37 | type Frame, | |
| 38 | } from "./protocol.ts"; | |
| 39 | import { | |
| 40 | DT, | |
| 41 | FIELD_CELLS as SEA_CELLS, | |
| 42 | THRUST_MAX, | |
| 43 | TICK_HZ, | |
| 44 | addMote, | |
| 45 | applyInput, | |
| 46 | createWorld, | |
| 47 | moteById, | |
| 48 | nextRandom, | |
| 49 | removeMote, | |
| 50 | step, | |
| 51 | wrapAngle, | |
| 52 | type Steering, | |
| 53 | type World, | |
| 54 | } from "./world.ts"; | |
| 55 | ||
| 56 | // ── The numbers, as ticks ────────────────────────────────────────────── | |
| 57 | // The record owns the seconds (`src/concept.ts`); this is the same numbers | |
| 58 | // in the only unit the loop counts in, so nothing below ever divides. | |
| 59 | ||
| 60 | /** How long a round runs, in ticks: 180 s at 20 Hz. */ | |
| 61 | export const ROUND_TICKS = Math.round(ROUND_SECONDS * TICK_HZ); | |
| 62 | /** The quiet after it, in ticks. */ | |
| 63 | export const INTERMISSION_TICKS = Math.round(INTERMISSION_SECONDS * TICK_HZ); | |
| 64 | /** A seat whose key has stopped steering is released after this many ticks. */ | |
| 65 | export const IDLE_TICKS = Math.round(IDLE_SECONDS * TICK_HZ); | |
| 66 | /** A seat that has never been steered goes sooner. */ | |
| 67 | export const UNSTEERED_TICKS = Math.round(UNSTEERED_SECONDS * TICK_HZ); | |
| 68 | /** A frame every second tick: 20 Hz of simulation, 10 Hz on the wire. */ | |
| 69 | export const FRAME_EVERY = 2; | |
| 70 | ||
| 71 | // ── The state ────────────────────────────────────────────────────────── | |
| 72 | ||
| 73 | /** Running a round, or in the quiet between two. */ | |
| 74 | export type Phase = "running" | "intermission"; | |
| 75 | ||
| 76 | /** Why a round ended: the clock ran out, or the bloom went through the gate. */ | |
| 77 | export type Outcome = "clock" | "gate"; | |
| 78 | ||
| 79 | /** | |
| 80 | * One seat: a player key, the mote it steers, and the two facts the record | |
| 81 | * releases a seat on. `lastSteeredTick` is a tick of *this round's* world — | |
| 82 | * a seat carried into a new world starts its idle clock again — and it holds | |
| 83 | * the tick the seat was taken until the key first steers. | |
| 84 | */ | |
| 85 | export interface Seat { | |
| 86 | readonly playerKey: string; | |
| 87 | readonly moteId: number; | |
| 88 | readonly lastSteeredTick: number; | |
| 89 | readonly steered: boolean; | |
| 90 | } | |
| 91 | ||
| 92 | /** What a finished round pays, in `recordRound`'s shape. Empty until #12. */ | |
| 93 | export interface Score { | |
| 94 | readonly player_id: string; | |
| 95 | readonly points: number; | |
| 96 | } | |
| 97 | ||
| 98 | export interface Round { | |
| 99 | /** The round's number in this process, and the `roundId` on the wire. */ | |
| 100 | readonly id: number; | |
| 101 | readonly world: World; | |
| 102 | readonly phase: Phase; | |
| 103 | /** Ticks spent in this phase. The clock is this times `DT`. */ | |
| 104 | readonly tick: number; | |
| 105 | /** | |
| 106 | * When this phase began, as an ISO instant. For a running round it is the | |
| 107 | * round's started-at, the one the record keeps; during the intermission it | |
| 108 | * is the instant the round ended. | |
| 109 | */ | |
| 110 | readonly startedAt: string; | |
| 111 | readonly roster: readonly Seat[]; | |
| 112 | } | |
| 113 | ||
| 114 | /** | |
| 115 | * Something that happened on a tick, as data. The caller decides what it | |
| 116 | * means — an emit, a row, bytes on a socket — and this module decides when | |
| 117 | * one happens. | |
| 118 | */ | |
| 119 | export type Effect = | |
| 120 | | { readonly kind: "started"; readonly roundId: number; readonly startedAt: string } | |
| 121 | | { | |
| 122 | readonly kind: "ended"; | |
| 123 | readonly roundId: number; | |
| 124 | readonly outcome: Outcome; | |
| 125 | readonly startedAt: string; | |
| 126 | readonly endedAt: string; | |
| 127 | readonly scores: readonly Score[]; | |
| 128 | } | |
| 129 | | { readonly kind: "frame"; readonly frame: Frame }; | |
| 130 | ||
| 131 | export interface Ticked { | |
| 132 | readonly round: Round; | |
| 133 | readonly effects: readonly Effect[]; | |
| 134 | } | |
| 135 | ||
| 136 | export interface RoundOptions { | |
| 137 | /** The world's seed; every choice a round makes descends from it. */ | |
| 138 | seed?: number; | |
| 139 | /** Where the gate sits; drawn from the seed when absent. */ | |
| 140 | gateAngle?: number; | |
| 141 | /** The round's number. The next round is this plus one. */ | |
| 142 | id?: number; | |
| 143 | /** When it started, as an ISO instant. Now, when absent. */ | |
| 144 | startedAt?: string; | |
| 145 | } | |
| 146 | ||
| 147 | // ── Opening one ──────────────────────────────────────────────────────── | |
| 148 | ||
| 149 | /** | |
| 150 | * A round about to run: a fresh world, nobody seated, the clock at zero. | |
| 151 | * Nothing has happened yet — the `started` effect belongs to the first | |
| 152 | * `tick`, so the first round and every round after it announce themselves | |
| 153 | * the same way. | |
| 154 | */ | |
| 155 | export function open(options: RoundOptions = {}): Round { | |
| 156 | const world = createWorld({ | |
| 157 | ...(Number.isFinite(options.seed) ? { seed: options.seed as number } : {}), | |
| 158 | ...(Number.isFinite(options.gateAngle) | |
| 159 | ? { gateAngle: options.gateAngle as number } | |
| 160 | : {}), | |
| 161 | }); | |
| 162 | return { | |
| 163 | id: wholeId(options.id), | |
| 164 | world, | |
| 165 | phase: "running", | |
| 166 | tick: 0, | |
| 167 | startedAt: instant(options.startedAt), | |
| 168 | roster: [], | |
| 169 | }; | |
| 170 | } | |
| 171 | ||
| 172 | // ── One tick ─────────────────────────────────────────────────────────── | |
| 173 | ||
| 174 | /** | |
| 175 | * Advance a round by one tick: step the sea, release the seats the record | |
| 176 | * says are gone, end the round if the clock or the gate says so, roll into | |
| 177 | * the next one when the quiet is up, and hand out a frame every second tick. | |
| 178 | * | |
| 179 | * Never throws and never reads a clock, so a round is a pure function of its | |
| 180 | * seed and what was steered: two rounds opened the same way and driven the | |
| 181 | * same way stay identical to the last cell. It is not a rewind, though — the | |
| 182 | * world under a `Round` is stepped in place (the module header says why), so | |
| 183 | * a `Round` handed to `tick` twice is a world stepped twice. | |
| 184 | */ | |
| 185 | export function tick(round: Round): Ticked { | |
| 186 | const effects: Effect[] = []; | |
| 187 | const count = round.tick + 1; | |
| 188 | if (round.phase === "running" && count === 1) | |
| 189 | effects.push({ | |
| 190 | kind: "started", | |
| 191 | roundId: round.id, | |
| 192 | startedAt: round.startedAt, | |
| 193 | }); | |
| 194 | ||
| 195 | step(round.world); | |
| 196 | let next: Round = { | |
| 197 | ...round, | |
| 198 | tick: count, | |
| 199 | // Seats are swept while a round is running and not during the quiet: the | |
| 200 | // round's end frees everyone anyway, so the only seats the intermission | |
| 201 | // holds are the ones taken for the round that has not begun, and the | |
| 202 | // record's patience is measured in the round a seat was taken from. | |
| 203 | roster: | |
| 204 | round.phase === "running" ? prune(round.world, round.roster) : round.roster, | |
| 205 | }; | |
| 206 | ||
| 207 | if (round.phase === "running") { | |
| 208 | // The gate is the world's word: `step` records the tick the bloom passed | |
| 209 | // `withinGate`, so the round reads a crossing rather than re-deciding it. | |
| 210 | const outcome: Outcome | null = | |
| 211 | next.world.crossedAt !== null | |
| 212 | ? "gate" | |
| 213 | : count >= ROUND_TICKS | |
| 214 | ? "clock" | |
| 215 | : null; | |
| 216 | if (outcome !== null) { | |
| 217 | const endedAt = after(round.startedAt, count); | |
| 218 | effects.push({ | |
| 219 | kind: "ended", | |
| 220 | roundId: round.id, | |
| 221 | outcome, | |
| 222 | startedAt: round.startedAt, | |
| 223 | endedAt, | |
| 224 | scores: [], | |
| 225 | }); | |
| 226 | // A seat opens when the round ends (#WAKE, "A seat"): every key is | |
| 227 | // released here and #8 seats the next round. The bodies stay in the | |
| 228 | // sea with their thrusters off — nobody steers them, and the quiet is | |
| 229 | // the sea emptying with what the round left drifting in it. | |
| 230 | for (const seat of next.roster) { | |
| 231 | const mote = moteById(next.world, seat.moteId); | |
| 232 | if (mote !== undefined) { | |
| 233 | mote.thrust.x = 0; | |
| 234 | mote.thrust.y = 0; | |
| 235 | } | |
| 236 | } | |
| 237 | next = { ...next, phase: "intermission", tick: 0, startedAt: endedAt, roster: [] }; | |
| 238 | } | |
| 239 | } else if (count >= INTERMISSION_TICKS) { | |
| 240 | next = roll(next); | |
| 241 | } | |
| 242 | ||
| 243 | // The frame is built from where the tick left the round, so the one sent on | |
| 244 | // a rolling tick is the new world and the new clock rather than the old. | |
| 245 | if (count % FRAME_EVERY === 0) | |
| 246 | effects.push({ kind: "frame", frame: frameOf(next) }); | |
| 247 | return { round: next, effects }; | |
| 248 | } | |
| 249 | ||
| 250 | /** The next round: a new gate from the world's own seeded RNG, seats carried. */ | |
| 251 | function roll(round: Round): Round { | |
| 252 | const old = round.world; | |
| 253 | const opened = open({ | |
| 254 | id: round.id + 1, | |
| 255 | seed: old.seed, | |
| 256 | gateAngle: wrapAngle(nextRandom(old) * Math.PI * 2), | |
| 257 | startedAt: after(round.startedAt, INTERMISSION_TICKS), | |
| 258 | }); | |
| 259 | // Seats taken during the quiet swim the round they were taken for: a new | |
| 260 | // mote in the new world, at a place the new seed chose, and an idle clock | |
| 261 | // that starts again with it. A nudge at the dying sea is not a round | |
| 262 | // steered, so a carried seat is unsteered again and has the record's | |
| 263 | // shorter patience to prove otherwise. | |
| 264 | const roster = round.roster.slice(0, ROSTER_MAX).map((seat) => ({ | |
| 265 | ...seat, | |
| 266 | moteId: addMote(opened.world, moteById(old, seat.moteId)?.name ?? "mote").id, | |
| 267 | lastSteeredTick: opened.world.tick, | |
| 268 | steered: false, | |
| 269 | })); | |
| 270 | return { ...opened, roster }; | |
| 271 | } | |
| 272 | ||
| 273 | /** Release the seats the record has run out of patience with, motes and all. */ | |
| 274 | function prune(world: World, roster: readonly Seat[]): readonly Seat[] { | |
| 275 | const kept = roster.filter((seat) => { | |
| 276 | const limit = seat.steered ? IDLE_TICKS : UNSTEERED_TICKS; | |
| 277 | if (world.tick - seat.lastSteeredTick < limit) return true; | |
| 278 | // An idle seat's mote leaves the sea; its wake stays, which is the point. | |
| 279 | removeMote(world, seat.moteId); | |
| 280 | return false; | |
| 281 | }); | |
| 282 | return kept.length === roster.length ? roster : kept; | |
| 283 | } | |
| 284 | ||
| 285 | // ── Seats ────────────────────────────────────────────────────────────── | |
| 286 | ||
| 287 | /** | |
| 288 | * Put a key in a seat. This is the bookkeeping, not the admission: who may | |
| 289 | * have a seat, and what a spectator is told, is `join`'s (#8). A key that | |
| 290 | * already holds a seat gets that same seat back — one key, one seat | |
| 291 | * (invariant 7) — and a full roster answers `null`, which is the caller's | |
| 292 | * cue to hand back the spectator's sentence. | |
| 293 | */ | |
| 294 | export function takeSeat( | |
| 295 | round: Round, | |
| 296 | playerKey: string, | |
| 297 | name: string, | |
| 298 | ): { round: Round; seat: Seat | null } { | |
| 299 | if (typeof playerKey !== "string" || playerKey === "") | |
| 300 | return { round, seat: null }; | |
| 301 | const held = round.roster.find((s) => s.playerKey === playerKey); | |
| 302 | if (held !== undefined) return { round, seat: held }; | |
| 303 | if (round.roster.length >= ROSTER_MAX) return { round, seat: null }; | |
| 304 | const seat: Seat = { | |
| 305 | playerKey, | |
| 306 | moteId: addMote(round.world, name).id, | |
| 307 | lastSteeredTick: round.world.tick, | |
| 308 | steered: false, | |
| 309 | }; | |
| 310 | return { round: { ...round, roster: [...round.roster, seat] }, seat }; | |
| 311 | } | |
| 312 | ||
| 313 | /** Give a seat up: the mote leaves the sea, the wake it left does not. */ | |
| 314 | export function releaseSeat(round: Round, playerKey: string): Round { | |
| 315 | const seat = round.roster.find((s) => s.playerKey === playerKey); | |
| 316 | if (seat === undefined) return round; | |
| 317 | removeMote(round.world, seat.moteId); | |
| 318 | return { | |
| 319 | ...round, | |
| 320 | roster: round.roster.filter((s) => s.playerKey !== playerKey), | |
| 321 | }; | |
| 322 | } | |
| 323 | ||
| 324 | /** | |
| 325 | * Steer a key's own mote and note that the key is still here. A key with no | |
| 326 | * seat is ignored rather than refused: input arrives from a socket, and a | |
| 327 | * socket that outlives its seat is ordinary. The mote id is never read off | |
| 328 | * the wire — the seat is the only mapping from a person to a body. | |
| 329 | */ | |
| 330 | export function steer( | |
| 331 | round: Round, | |
| 332 | playerKey: string, | |
| 333 | input: Steering, | |
| 334 | ): Round { | |
| 335 | const seat = round.roster.find((s) => s.playerKey === playerKey); | |
| 336 | if (seat === undefined) return round; | |
| 337 | if (!applyInput(round.world, seat.moteId, input)) return round; | |
| 338 | const steered: Seat = { | |
| 339 | ...seat, | |
| 340 | lastSteeredTick: round.world.tick, | |
| 341 | steered: true, | |
| 342 | }; | |
| 343 | return { | |
| 344 | ...round, | |
| 345 | roster: round.roster.map((s) => (s === seat ? steered : s)), | |
| 346 | }; | |
| 347 | } | |
| 348 | ||
| 349 | // ── What the clock reads ─────────────────────────────────────────────── | |
| 350 | ||
| 351 | /** Ticks left in this phase, never below zero. */ | |
| 352 | export const remainingTicks = (round: Round): number => | |
| 353 | Math.max(0, (round.phase === "running" ? ROUND_TICKS : INTERMISSION_TICKS) - round.tick); | |
| 354 | ||
| 355 | /** Seconds left in this phase: the tick count and `DT`, never a wall clock. */ | |
| 356 | export const remainingSeconds = (round: Round): number => | |
| 357 | remainingTicks(round) * DT; | |
| 358 | ||
| 359 | /** Seconds spent in this phase. */ | |
| 360 | export const elapsedSeconds = (round: Round): number => round.tick * DT; | |
| 361 | ||
| 362 | // ── The frame ────────────────────────────────────────────────────────── | |
| 363 | ||
| 364 | /** | |
| 365 | * What one frame carries, in the shape `src/protocol.ts` defines. Velocities | |
| 366 | * and currents travel in units per *tick* — that is the unit the wire's | |
| 367 | * scales are cut for, and it keeps a fast lane inside the byte it is given — | |
| 368 | * and a thruster travels as the direction it was steered in, a fraction of | |
| 369 | * `THRUST_MAX` in the unit disc. The field is the sea's 32×32 grid averaged | |
| 370 | * down to 16×16, two components a cell, interleaved `x, y`, row-major. | |
| 371 | * | |
| 372 | * A frame carries at most `ROSTER_MAX` motes, and during the quiet the sea | |
| 373 | * can hold more than that: the ended round's bodies are still drifting while | |
| 374 | * seats are taken for the next one. So the motes are *chosen*, not | |
| 375 | * truncated — everyone seated first, and what the round left drifting takes | |
| 376 | * the room that is spare. Truncating would have shown the leftovers and hidden | |
| 377 | * every newcomer for the whole intermission, which is exactly the moment the | |
| 378 | * record promises a seat opens. | |
| 379 | */ | |
| 380 | export function frameOf(round: Round): Frame { | |
| 381 | const world = round.world; | |
| 382 | const seated = new Set(round.roster.map((s) => s.moteId)); | |
| 383 | const shown = [ | |
| 384 | ...world.motes.filter((m) => seated.has(m.id)), | |
| 385 | ...world.motes.filter((m) => !seated.has(m.id)), | |
| 386 | ].slice(0, ROSTER_MAX); | |
| 387 | const motes: number[] = []; | |
| 388 | for (const m of shown) | |
| 389 | motes.push( | |
| 390 | m.id, | |
| 391 | m.x, | |
| 392 | m.y, | |
| 393 | m.vx * DT, | |
| 394 | m.vy * DT, | |
| 395 | m.thrust.x / THRUST_MAX, | |
| 396 | m.thrust.y / THRUST_MAX, | |
| 397 | world.tick < m.stunnedUntil ? MOTE_STUNNED : 0, | |
| 398 | ); | |
| 399 | return { | |
| 400 | tick: world.tick, | |
| 401 | // The clock is the round's, and in the quiet the round's clock has run | |
| 402 | // out: zero rather than the intermission's countdown, so a socket that | |
| 403 | // connects mid-quiet cannot read it as a round with 20 s left. Version 1 | |
| 404 | // of the wire has no phase bit; how long the quiet has left is what | |
| 405 | // `round.ended` on the bus is for (#53), not a number that lies. | |
| 406 | clock: round.phase === "running" ? Math.round(remainingSeconds(round) * 1000) : 0, | |
| 407 | roundId: round.id, | |
| 408 | motes, | |
| 409 | bloom: [world.bloom.x, world.bloom.y, world.bloom.vx * DT, world.bloom.vy * DT], | |
| 410 | gate: [world.gate.angle, world.gate.half], | |
| 411 | field: downsample(round), | |
| 412 | }; | |
| 413 | } | |
| 414 | ||
| 415 | // The sea's grid is 32 cells a side and the wire's is 16: `FIELD_CELLS` | |
| 416 | // means both, one in each module, so the sea's is named for the sea here. | |
| 417 | const BLOCK = SEA_CELLS / FIELD_SIZE; | |
| 418 | ||
| 419 | /** The sea's grid, averaged into the wire's coarser one, in units per tick. */ | |
| 420 | function downsample(round: Round): number[] { | |
| 421 | const { x, y } = round.world.field; | |
| 422 | const out = new Array<number>(FIELD_LENGTH).fill(0); | |
| 423 | const share = DT / (BLOCK * BLOCK); | |
| 424 | for (let cy = 0; cy < FIELD_SIZE; cy++) | |
| 425 | for (let cx = 0; cx < FIELD_SIZE; cx++) { | |
| 426 | let sx = 0; | |
| 427 | let sy = 0; | |
| 428 | for (let dy = 0; dy < BLOCK; dy++) | |
| 429 | for (let dx = 0; dx < BLOCK; dx++) { | |
| 430 | const i = (cy * BLOCK + dy) * SEA_CELLS + (cx * BLOCK + dx); | |
| 431 | sx += x[i]!; | |
| 432 | sy += y[i]!; | |
| 433 | } | |
| 434 | const at = (cy * FIELD_SIZE + cx) * 2; | |
| 435 | out[at] = sx * share; | |
| 436 | out[at + 1] = sy * share; | |
| 437 | } | |
| 438 | return out; | |
| 439 | } | |
| 440 | ||
| 441 | // ── Small arithmetic ─────────────────────────────────────────────────── | |
| 442 | ||
| 443 | const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/; | |
| 444 | ||
| 445 | /** An ISO instant the caller can trust: theirs when it is one, now when not. */ | |
| 446 | function instant(value: string | undefined): string { | |
| 447 | return typeof value === "string" && ISO.test(value) | |
| 448 | ? value | |
| 449 | : new Date().toISOString(); | |
| 450 | } | |
| 451 | ||
| 452 | /** That instant, a whole number of ticks later. */ | |
| 453 | function after(base: string, ticks: number): string { | |
| 454 | const at = Date.parse(base); | |
| 455 | return new Date( | |
| 456 | (Number.isFinite(at) ? at : 0) + Math.round(ticks * DT * 1000), | |
| 457 | ).toISOString(); | |
| 458 | } | |
| 459 | ||
| 460 | const wholeId = (value: number | undefined): number => | |
| 461 | typeof value === "number" && Number.isInteger(value) && value > 0 ? value : 1; |
bun run testis green on this head for me too (127 pass,tsc --noEmitclean), the module imports no timer, noDatabase, no socket and no bus, and every rule really is reachable by calling a function — that is what #58 asked for and it is done well. Security is clean: there is no identity in here to get wrong, the seat is the only map from a person to a body (steertakes a player key, never a mote id off the wire, which is invariant 7 held at the one place that could break it), names go throughcleanName, steering throughapplyInput's finite-and-clamped check, and there is no shell, no SQL, no secret, noeval, no new dependency.One blocker, and it is on the line this is anchored to.
High — during the quiet the frame shows the dead round's bodies and hides everybody waiting
The round's end empties the roster but leaves the motes drifting (deliberate, and I like it), while
takeSeatduring the intermission adds a live mote to that same sea. After a busy round the sea therefore holds up to 48 bodies, and thisworld.motes.slice(0, ROSTER_MAX)keeps the first 24 — which are exactly the leftovers, becauseaddMotepushes. Measured on 07b3255:So for the whole 20 s intermission — one sixth of the cycle, and precisely the moment the record promises "a seat opens when the round ends" — the page draws 24 ghosts and none of the people who are seated for the next round. Nothing throws, which is why the tests miss it.
Fix this first: choose the motes rather than truncating them. Seated bodies first, then let what the round left drifting have the spare room:
with a test that ends a round with a full, steering roster, takes a seat in the quiet and asserts that seat's mote id is in
frameOf's motes — it fails today.Medium — a frame in the quiet is indistinguishable from a round with 20 s left
Same function. During the intermission
frameOfreports the ended round'sroundIdand the intermission's countdown inclock, so a socket that connects mid-quiet reads "round 1, 12.4 s left" and has no way to know better;started/endedonly help a client that was already listening. Protocol v1 has no phase bit, so the honest thing this module can do without touching the wire is reportclock: 0whilephase === "intermission"— the round's clock has genuinely run out — and leave the rest to #53. If you would rather the phase travelled on the wire, say so and I will open that againstsrc/protocol.tsinstead of holding it here.Low, take or leave
rollcarriessteeredinto the new world while resettinglastSteeredTick. A client that steered for one tick of the quiet buys 60 s of patience in a round it has never steered;steered: falseon a carried seat is what the record is actually counting.tickdoc says "the same round in gives the same round and the same effects out", but the world is stepped in place, so callingticktwice on oneRounddoes not. The module header already explains the in-place world — worth softening that sentence so nobody builds a rewind on it.round.test.tsimports from./protocol.tstwice (the big import, thenencodeFramealone); fold them.FIELD_CELLSis 32 inworld.tsand 256 inprotocol.ts, anddownsamplewants the world's. It is correct as written; a rename-on-import would save the next reader the check.Not held against it: the preview is unchanged because #58 explicitly kept the interval, the sockets and the bus in #53 — that is the split working, not a gap. I also checked the units
frameOfchose land inside the wire's byte: four motes braiding one lane peak at 3.33 u/tick against aFIELD_MAXof 8, so per-tick was the right call and there is headroom for a crowd.