Public: anyone can read this; the crew can change it.
The frame the round publishes: `src/frame.ts`, a world in and 4 KB out #71
Check · Review · Land · Live done — Landed and live in production.
vesper/drift:vesper/66-the-frame-the-round-publishes-src-frame → main 1 commit fork
4 files
changed +690
−0
README.md +1
| @@ -90,6 +90,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 90 | 90 | | `src/actions.ts` | every operation, and who may: players, rounds, the ladder | |
| 91 | 91 | | `src/world.ts` | the simulation: motes, momentum and the current field | |
| 92 | 92 | | `src/protocol.ts` | the wire: one frame down, one steer up, quantised and sized | |
| 93 | | `src/frame.ts` | one world and one clock in, one frame out, pure and measured | | |
| 93 | 94 | | `src/who.ts` | who is asking, from the forge's signed headers | |
| 94 | 95 | | `src/session.ts` | the guest key in a cookie, and the player id it derives | |
| 95 | 96 | | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations | |
src/frame.test.ts added +449
| @@ -0,0 +1,449 @@ | ||
| 1 | /** | |
| 2 | * The frame, measured. A world with bodies a test placed by hand produces | |
| 3 | * numbers a test can spell; a full frame is counted in bytes and in | |
| 4 | * microseconds; the downsample is held to averaging rather than sampling by | |
| 5 | * showing a lane that never flickers as it drifts; and everything that can | |
| 6 | * leave a scale's range is shown clamping rather than wrapping. | |
| 7 | * | |
| 8 | * Every tolerance here is derived from the protocol's own scales, never | |
| 9 | * written out as a number: a scale that moves moves these with it. | |
| 10 | */ | |
| 11 | ||
| 12 | import { expect, test } from "bun:test"; | |
| 13 | import { readFileSync } from "node:fs"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { ROSTER_MAX } from "./concept.ts"; | |
| 16 | import { buildFrame, FIELD_RATIO, type BuiltFrame } from "./frame.ts"; | |
| 17 | import { | |
| 18 | BLOOM_STRIDE, | |
| 19 | decodeFrame, | |
| 20 | encodeFrame, | |
| 21 | FIELD_COMPONENTS, | |
| 22 | FIELD_LENGTH, | |
| 23 | FIELD_MAX, | |
| 24 | FIELD_SIZE, | |
| 25 | frameBytes, | |
| 26 | FRAME_MAX_BYTES, | |
| 27 | MOTE_STRIDE, | |
| 28 | MOTE_STUNNED, | |
| 29 | POSITION_SCALE, | |
| 30 | THRUST_SCALE, | |
| 31 | VELOCITY_SCALE, | |
| 32 | } from "./protocol.ts"; | |
| 33 | import { | |
| 34 | addMote, | |
| 35 | ARENA_RADIUS, | |
| 36 | createWorld, | |
| 37 | DT, | |
| 38 | FIELD_CELLS, | |
| 39 | GATE_HALF_ANGLE, | |
| 40 | NAME_MAX, | |
| 41 | removeMote, | |
| 42 | step, | |
| 43 | THRUST_MAX, | |
| 44 | TICK_HZ, | |
| 45 | type Body, | |
| 46 | type World, | |
| 47 | } from "./world.ts"; | |
| 48 | ||
| 49 | /** Half a quantiser step: what a number is promised to within after the wire. */ | |
| 50 | const POSITION_TOLERANCE = 1 / (2 * POSITION_SCALE); | |
| 51 | const VELOCITY_TOLERANCE = 1 / (2 * VELOCITY_SCALE); | |
| 52 | const THRUST_TOLERANCE = 1 / (2 * THRUST_SCALE); | |
| 53 | const FIELD_TOLERANCE = FIELD_MAX / 127 / 2 + 1e-9; | |
| 54 | const ANGLE_TOLERANCE = (Math.PI * 2) / 65536 / 2 + 1e-9; | |
| 55 | ||
| 56 | const INT16_MAX = 32767; | |
| 57 | const VELOCITY_LIMIT = INT16_MAX / VELOCITY_SCALE; | |
| 58 | ||
| 59 | const round = { roundId: 42, clock: 121_500 }; | |
| 60 | ||
| 61 | /** A mote exactly where a test wants it, rather than where the seed put it. */ | |
| 62 | function place( | |
| 63 | world: World, | |
| 64 | name: string, | |
| 65 | x: number, | |
| 66 | y: number, | |
| 67 | vx = 0, | |
| 68 | vy = 0, | |
| 69 | ): Body { | |
| 70 | const mote = addMote(world, name); | |
| 71 | mote.x = x; | |
| 72 | mote.y = y; | |
| 73 | mote.vx = vx; | |
| 74 | mote.vy = vy; | |
| 75 | return mote; | |
| 76 | } | |
| 77 | ||
| 78 | /** Mote `i` of a built frame, as its eight numbers. */ | |
| 79 | const moteAt = (frame: BuiltFrame, i: number): number[] => | |
| 80 | Array.from(frame.motes.slice(i * MOTE_STRIDE, (i + 1) * MOTE_STRIDE)); | |
| 81 | ||
| 82 | /** One wire cell's two components. */ | |
| 83 | const cellAt = (field: ArrayLike<number>, x: number, y: number): [number, number] => { | |
| 84 | const at = (y * FIELD_SIZE + x) * FIELD_COMPONENTS; | |
| 85 | return [field[at]!, field[at + 1]!]; | |
| 86 | }; | |
| 87 | ||
| 88 | /** Set one sea cell's current, in the world's units per second. */ | |
| 89 | function seaCell(world: World, x: number, y: number, vx: number, vy: number): void { | |
| 90 | world.field.x[y * FIELD_CELLS + x] = vx; | |
| 91 | world.field.y[y * FIELD_CELLS + x] = vy; | |
| 92 | } | |
| 93 | ||
| 94 | /** A world with a full roster and a sea nowhere near still. */ | |
| 95 | function crowded(): World { | |
| 96 | const world = createWorld({ seed: 11, gateAngle: 0.4 }); | |
| 97 | for (let i = 0; i < ROSTER_MAX; i++) { | |
| 98 | const a = (i / ROSTER_MAX) * Math.PI * 2; | |
| 99 | const mote = place( | |
| 100 | world, | |
| 101 | `swimmer ${i}`, | |
| 102 | Math.cos(a) * 420, | |
| 103 | Math.sin(a) * 420, | |
| 104 | Math.cos(a) * 130, | |
| 105 | Math.sin(a) * 90, | |
| 106 | ); | |
| 107 | mote.thrust.x = Math.cos(a) * THRUST_MAX; | |
| 108 | mote.thrust.y = Math.sin(a) * THRUST_MAX; | |
| 109 | if (i % 3 === 0) mote.stunnedUntil = world.tick + 20; | |
| 110 | } | |
| 111 | for (let i = 0; i < FIELD_CELLS * FIELD_CELLS; i++) { | |
| 112 | world.field.x[i] = Math.sin(i * 0.37) * 400; | |
| 113 | world.field.y[i] = Math.cos(i * 0.21) * 400; | |
| 114 | } | |
| 115 | return world; | |
| 116 | } | |
| 117 | ||
| 118 | test("a world with known bodies produces known numbers", () => { | |
| 119 | const world = createWorld({ seed: 3, gateAngle: 1 }); | |
| 120 | const one = place(world, "one", 100, -200, 40, -20); | |
| 121 | one.thrust.x = THRUST_MAX; | |
| 122 | const two = place(world, "two", -12.5, 0.25, 0, 0); | |
| 123 | two.stunnedUntil = world.tick + 10; | |
| 124 | world.bloom.x = 8; | |
| 125 | world.bloom.y = -16; | |
| 126 | world.bloom.vx = 100; | |
| 127 | ||
| 128 | const frame = buildFrame(world, round); | |
| 129 | ||
| 130 | expect(frame.tick).toBe(0); | |
| 131 | expect(frame.clock).toBe(121_500); | |
| 132 | expect(frame.roundId).toBe(42); | |
| 133 | expect(frame.motes.length).toBe(2 * MOTE_STRIDE); | |
| 134 | // Position in sea units, velocity in units per tick, thrust as a share of | |
| 135 | // `THRUST_MAX`, flags empty because this mote is steering. | |
| 136 | expect(moteAt(frame, 0)).toEqual([one.id, 100, -200, 40 * DT, -20 * DT, 1, 0, 0]); | |
| 137 | expect(moteAt(frame, 1)).toEqual([ | |
| 138 | two.id, | |
| 139 | -12.5, | |
| 140 | 0.25, | |
| 141 | 0, | |
| 142 | 0, | |
| 143 | 0, | |
| 144 | 0, | |
| 145 | MOTE_STUNNED, | |
| 146 | ]); | |
| 147 | expect(Array.from(frame.bloom)).toEqual([8, -16, 100 * DT, 0]); | |
| 148 | expect(Array.from(frame.gate)).toEqual([1, GATE_HALF_ANGLE]); | |
| 149 | expect(frame.names).toEqual(["one", "two"]); | |
| 150 | expect(frame.bloom.length).toBe(BLOOM_STRIDE); | |
| 151 | expect(frame.field.length).toBe(FIELD_LENGTH); | |
| 152 | }); | |
| 153 | ||
| 154 | test("the stunned flag is the rim's seconds, and is the only bit set", () => { | |
| 155 | const world = createWorld({ seed: 5 }); | |
| 156 | const mote = place(world, "paying", 0, 0); | |
| 157 | mote.stunnedUntil = 3; | |
| 158 | ||
| 159 | // The seconds the rim charged, and the tick they run out on. | |
| 160 | for (const [tick, expected] of [ | |
| 161 | [0, MOTE_STUNNED], | |
| 162 | [2, MOTE_STUNNED], | |
| 163 | [3, 0], | |
| 164 | [9, 0], | |
| 165 | ] as const) { | |
| 166 | world.tick = tick; | |
| 167 | expect(moteAt(buildFrame(world, round), 0)[7]).toBe(expected); | |
| 168 | } | |
| 169 | }); | |
| 170 | ||
| 171 | test("a full frame fits the byte budget, with room to spare", () => { | |
| 172 | const world = crowded(); | |
| 173 | const bytes = encodeFrame(buildFrame(world, round)); | |
| 174 | expect(bytes.length).toBe(frameBytes(ROSTER_MAX)); | |
| 175 | expect( | |
| 176 | bytes.length, | |
| 177 | `a ${ROSTER_MAX}-mote frame with a saturated field is ${bytes.length} bytes of the ${FRAME_MAX_BYTES} allowed`, | |
| 178 | ).toBeLessThan(FRAME_MAX_BYTES); | |
| 179 | }); | |
| 180 | ||
| 181 | test("a world over the roster cap sends the cap, in roster order", () => { | |
| 182 | const world = crowded(); | |
| 183 | place(world, "late", 0, 0); | |
| 184 | const frame = buildFrame(world, round); | |
| 185 | expect(world.motes.length).toBe(ROSTER_MAX + 1); | |
| 186 | expect(frame.motes.length).toBe(ROSTER_MAX * MOTE_STRIDE); | |
| 187 | expect(frame.names.length).toBe(ROSTER_MAX); | |
| 188 | expect(moteAt(frame, 0)[0]).toBe(world.motes[0]!.id); | |
| 189 | }); | |
| 190 | ||
| 191 | test("building a frame costs well under a millisecond", () => { | |
| 192 | const world = crowded(); | |
| 193 | const runs = 500; | |
| 194 | buildFrame(world, round); // warm, so the number is steady state and not JIT | |
| 195 | const started = performance.now(); | |
| 196 | for (let i = 0; i < runs; i++) buildFrame(world, round); | |
| 197 | const each = (performance.now() - started) / runs; | |
| 198 | const tickBudget = 1000 / TICK_HZ; | |
| 199 | expect( | |
| 200 | each, | |
| 201 | `building a full frame took ${(each * 1000).toFixed(1)} µs, of a ${tickBudget} ms tick`, | |
| 202 | ).toBeLessThan(1); | |
| 203 | }); | |
| 204 | ||
| 205 | test("a wire cell is the average of its four sea cells, not one of them", () => { | |
| 206 | const world = createWorld({ seed: 2 }); | |
| 207 | // The four sea cells under wire cell (1, 1), each a different current. | |
| 208 | seaCell(world, 2, 2, 20, 0); | |
| 209 | seaCell(world, 3, 2, 0, 40); | |
| 210 | seaCell(world, 2, 3, -20, 0); | |
| 211 | seaCell(world, 3, 3, 40, 0); | |
| 212 | ||
| 213 | const [x, y] = cellAt(buildFrame(world, round).field, 1, 1); | |
| 214 | expect(x).toBeCloseTo(((20 + 0 - 20 + 40) / 4) * DT, 12); | |
| 215 | expect(y).toBeCloseTo(((0 + 40 + 0 + 0) / 4) * DT, 12); | |
| 216 | // Nothing leaked into the neighbours. | |
| 217 | expect(cellAt(buildFrame(world, round).field, 0, 1)).toEqual([0, 0]); | |
| 218 | expect(cellAt(buildFrame(world, round).field, 2, 2)).toEqual([0, 0]); | |
| 219 | }); | |
| 220 | ||
| 221 | test("a lane in one quadrant still points the same way through the downsample", () => { | |
| 222 | const world = createWorld({ seed: 2 }); | |
| 223 | const half = FIELD_CELLS / 2; | |
| 224 | // A lane running up and to the right, filling the sea's top-right quadrant. | |
| 225 | for (let y = half; y < FIELD_CELLS; y++) | |
| 226 | for (let x = half; x < FIELD_CELLS; x++) seaCell(world, x, y, 60, 30); | |
| 227 | ||
| 228 | const field = buildFrame(world, round).field; | |
| 229 | const wireHalf = FIELD_SIZE / 2; | |
| 230 | for (let y = 0; y < FIELD_SIZE; y++) | |
| 231 | for (let x = 0; x < FIELD_SIZE; x++) { | |
| 232 | const [vx, vy] = cellAt(field, x, y); | |
| 233 | if (x >= wireHalf && y >= wireHalf) { | |
| 234 | expect(vx).toBeCloseTo(60 * DT, 12); | |
| 235 | expect(vy).toBeCloseTo(30 * DT, 12); | |
| 236 | expect(Math.atan2(vy, vx)).toBeCloseTo(Math.atan2(30, 60), 12); | |
| 237 | } else { | |
| 238 | expect([vx, vy]).toEqual([0, 0]); | |
| 239 | } | |
| 240 | } | |
| 241 | }); | |
| 242 | ||
| 243 | test("a one-cell lane drifting across a boundary fades, it does not flicker", () => { | |
| 244 | // Sampling one of four cells would drop this lane entirely on every other | |
| 245 | // column. Averaging keeps the same total on the wire wherever it sits, and | |
| 246 | // hands it from one wire cell to the next. | |
| 247 | const strengths: number[] = []; | |
| 248 | for (let column = 0; column < FIELD_CELLS; column++) { | |
| 249 | const world = createWorld({ seed: 2 }); | |
| 250 | for (let y = 0; y < FIELD_CELLS; y++) seaCell(world, column, y, 40, 0); | |
| 251 | const field = buildFrame(world, round).field; | |
| 252 | let total = 0; | |
| 253 | for (let i = 0; i < FIELD_LENGTH; i += FIELD_COMPONENTS) | |
| 254 | total += Math.hypot(field[i]!, field[i + 1]!); | |
| 255 | strengths.push(total); | |
| 256 | // Exactly one wire column carries it, at a quarter of the lane's speed: | |
| 257 | // one sea column of the two, both sea rows of the two. | |
| 258 | const [vx] = cellAt(field, Math.floor(column / FIELD_RATIO), 3); | |
| 259 | expect(vx).toBeCloseTo((40 * DT) / FIELD_RATIO, 12); | |
| 260 | } | |
| 261 | for (const total of strengths) expect(total).toBeCloseTo(strengths[0]!, 9); | |
| 262 | }); | |
| 263 | ||
| 264 | test("the sea emptying is visible on the wire, tick after tick (invariant 5)", () => { | |
| 265 | const world = createWorld({ seed: 4 }); | |
| 266 | for (let y = 12; y < 20; y++) | |
| 267 | for (let x = 12; x < 20; x++) seaCell(world, x, y, 90, 45); | |
| 268 | ||
| 269 | let last = Number.POSITIVE_INFINITY; | |
| 270 | for (let i = 0; i < 40; i++) { | |
| 271 | const field = buildFrame(world, round).field; | |
| 272 | let total = 0; | |
| 273 | for (let at = 0; at < FIELD_LENGTH; at += FIELD_COMPONENTS) | |
| 274 | total += Math.hypot(field[at]!, field[at + 1]!); | |
| 275 | expect(total).toBeLessThan(last); | |
| 276 | last = total; | |
| 277 | step(world); | |
| 278 | } | |
| 279 | expect(last).toBeGreaterThan(0); | |
| 280 | }); | |
| 281 | ||
| 282 | test("a mote at the rim and a mote past the velocity scale clamp, never wrap", () => { | |
| 283 | const world = createWorld({ seed: 6, gateAngle: 0 }); | |
| 284 | place(world, "rim", ARENA_RADIUS, 0); | |
| 285 | place(world, "bolted", 0, 0, 1e6, -1e6); | |
| 286 | ||
| 287 | const frame = buildFrame(world, round); | |
| 288 | const rim = moteAt(frame, 0); | |
| 289 | expect(rim[1]).toBe(ARENA_RADIUS); | |
| 290 | expect(rim[2]).toBe(0); | |
| 291 | ||
| 292 | // Past the scale's range the vector is scaled back along itself: the | |
| 293 | // magnitude saturates, the direction — down and to the right — survives. | |
| 294 | const bolted = moteAt(frame, 1); | |
| 295 | expect(Math.hypot(bolted[3]!, bolted[4]!)).toBeCloseTo(VELOCITY_LIMIT, 9); | |
| 296 | expect(bolted[3]).toBeGreaterThan(0); | |
| 297 | expect(bolted[4]).toBeLessThan(0); | |
| 298 | expect(bolted[3]).toBeCloseTo(-bolted[4]!, 9); | |
| 299 | ||
| 300 | // And it survives the wire as the same sign, rather than coming back round. | |
| 301 | const decoded = decodeFrame(encodeFrame(frame)); | |
| 302 | expect(decoded.ok).toBe(true); | |
| 303 | if (!decoded.ok) return; | |
| 304 | const wire = decoded.value.motes; | |
| 305 | expect(wire[MOTE_STRIDE + 3]).toBeGreaterThan(0); | |
| 306 | expect(wire[MOTE_STRIDE + 4]).toBeLessThan(0); | |
| 307 | expect(wire[MOTE_STRIDE + 3]).toBeLessThanOrEqual(VELOCITY_LIMIT); | |
| 308 | expect(wire[1]).toBeCloseTo(ARENA_RADIUS, 3); | |
| 309 | }); | |
| 310 | ||
| 311 | test("a current past the wire's scale saturates and keeps its direction", () => { | |
| 312 | const world = createWorld({ seed: 6 }); | |
| 313 | for (let y = 0; y < FIELD_RATIO; y++) | |
| 314 | for (let x = 0; x < FIELD_RATIO; x++) seaCell(world, x, y, 9000, 3000); | |
| 315 | ||
| 316 | const [vx, vy] = cellAt(buildFrame(world, round).field, 0, 0); | |
| 317 | expect(Math.hypot(vx, vy)).toBeCloseTo(FIELD_MAX, 9); | |
| 318 | expect(Math.atan2(vy, vx)).toBeCloseTo(Math.atan2(3000, 9000), 12); | |
| 319 | }); | |
| 320 | ||
| 321 | test("a thruster rides as a share of full thrust, inside the unit disc", () => { | |
| 322 | const world = createWorld({ seed: 6 }); | |
| 323 | const mote = place(world, "pushing", 0, 0); | |
| 324 | mote.thrust.x = -THRUST_MAX / 2; | |
| 325 | mote.thrust.y = THRUST_MAX / 4; | |
| 326 | ||
| 327 | const frame = buildFrame(world, round); | |
| 328 | expect(moteAt(frame, 0)[5]).toBeCloseTo(-0.5, 12); | |
| 329 | expect(moteAt(frame, 0)[6]).toBeCloseTo(0.25, 12); | |
| 330 | }); | |
| 331 | ||
| 332 | test("nonsense in a body is a zero on the wire, never a NaN", () => { | |
| 333 | const world = createWorld({ seed: 6 }); | |
| 334 | const mote = place(world, "broken", Number.NaN, 0, Number.POSITIVE_INFINITY, 0); | |
| 335 | mote.thrust.x = Number.NaN; | |
| 336 | world.field.x[0] = Number.NaN; | |
| 337 | world.tick = -3; | |
| 338 | ||
| 339 | const frame = buildFrame(world, { roundId: -1, clock: Number.NaN }); | |
| 340 | for (const n of [...frame.motes, ...frame.bloom, ...frame.gate, ...frame.field]) | |
| 341 | expect(Number.isFinite(n)).toBe(true); | |
| 342 | expect(frame.tick).toBe(0); | |
| 343 | expect(frame.clock).toBe(0); | |
| 344 | expect(frame.roundId).toBe(0); | |
| 345 | expect(moteAt(frame, 0)[1]).toBe(0); | |
| 346 | }); | |
| 347 | ||
| 348 | test("a frame round-trips through the wire inside the scales' resolution", () => { | |
| 349 | const world = crowded(); | |
| 350 | for (let i = 0; i < 20; i++) step(world); | |
| 351 | const frame = buildFrame(world, round); | |
| 352 | const decoded = decodeFrame(encodeFrame(frame)); | |
| 353 | expect(decoded.ok).toBe(true); | |
| 354 | if (!decoded.ok) return; | |
| 355 | const back = decoded.value; | |
| 356 | ||
| 357 | expect(back.tick).toBe(frame.tick); | |
| 358 | expect(back.clock).toBe(frame.clock); | |
| 359 | expect(back.roundId).toBe(frame.roundId); | |
| 360 | expect(back.motes.length).toBe(frame.motes.length); | |
| 361 | for (let i = 0; i < back.motes.length; i++) { | |
| 362 | const slot = i % MOTE_STRIDE; | |
| 363 | const tolerance = | |
| 364 | slot === 0 || slot === 7 | |
| 365 | ? 0 | |
| 366 | : slot === 1 || slot === 2 | |
| 367 | ? POSITION_TOLERANCE | |
| 368 | : slot === 3 || slot === 4 | |
| 369 | ? VELOCITY_TOLERANCE | |
| 370 | : THRUST_TOLERANCE; | |
| 371 | expect(Math.abs(back.motes[i]! - frame.motes[i]!)).toBeLessThanOrEqual( | |
| 372 | tolerance, | |
| 373 | ); | |
| 374 | } | |
| 375 | for (let i = 0; i < BLOOM_STRIDE; i++) | |
| 376 | expect(Math.abs(back.bloom[i]! - frame.bloom[i]!)).toBeLessThanOrEqual( | |
| 377 | i < 2 ? POSITION_TOLERANCE : VELOCITY_TOLERANCE, | |
| 378 | ); | |
| 379 | for (let i = 0; i < 2; i++) | |
| 380 | expect(Math.abs(back.gate[i]! - frame.gate[i]!)).toBeLessThanOrEqual( | |
| 381 | ANGLE_TOLERANCE, | |
| 382 | ); | |
| 383 | for (let i = 0; i < FIELD_LENGTH; i++) | |
| 384 | expect(Math.abs(back.field[i]! - frame.field[i]!)).toBeLessThanOrEqual( | |
| 385 | FIELD_TOLERANCE, | |
| 386 | ); | |
| 387 | }); | |
| 388 | ||
| 389 | test("names are bounded at the world's cap and carry no control characters", () => { | |
| 390 | const world = createWorld({ seed: 8 }); | |
| 391 | const mote = place(world, "short", 0, 0); | |
| 392 | mote.name = `${"x".repeat(NAME_MAX * 2)}`; | |
| 393 | const noisy = place(world, "noisy", 0, 0); | |
| 394 | noisy.name = "a\u0000b\u001fc\u007fd"; | |
| 395 | ||
| 396 | const frame = buildFrame(world, round); | |
| 397 | expect(frame.names[0]!.length).toBe(NAME_MAX); | |
| 398 | expect(frame.names[1]).toBe("a b c d"); | |
| 399 | }); | |
| 400 | ||
| 401 | test("the roster order is stable between frames, so a door can diff by index", () => { | |
| 402 | const world = createWorld({ seed: 9 }); | |
| 403 | const first = place(world, "first", 10, 0); | |
| 404 | const middle = place(world, "middle", 20, 0); | |
| 405 | const last = place(world, "last", 30, 0); | |
| 406 | ||
| 407 | const before = buildFrame(world, round); | |
| 408 | expect(before.names).toEqual(["first", "middle", "last"]); | |
| 409 | expect([moteAt(before, 0)[0], moteAt(before, 2)[0]]).toEqual([ | |
| 410 | first.id, | |
| 411 | last.id, | |
| 412 | ]); | |
| 413 | ||
| 414 | removeMote(world, middle.id); | |
| 415 | const after = buildFrame(world, round); | |
| 416 | expect(after.names).toEqual(["first", "last"]); | |
| 417 | expect(moteAt(after, 0)[0]).toBe(first.id); | |
| 418 | expect(moteAt(after, 1)[0]).toBe(last.id); | |
| 419 | }); | |
| 420 | ||
| 421 | test("building a frame reads the world and changes nothing in it", () => { | |
| 422 | const world = crowded(); | |
| 423 | step(world); | |
| 424 | const before = JSON.stringify(world); | |
| 425 | ||
| 426 | const one = buildFrame(world, round); | |
| 427 | const two = buildFrame(world, round); | |
| 428 | ||
| 429 | expect(JSON.stringify(world)).toBe(before); | |
| 430 | expect(Array.from(one.motes)).toEqual(Array.from(two.motes)); | |
| 431 | expect(Array.from(one.field)).toEqual(Array.from(two.field)); | |
| 432 | expect(Array.from(one.bloom)).toEqual(Array.from(two.bloom)); | |
| 433 | expect(one.names).toEqual(two.names); | |
| 434 | }); | |
| 435 | ||
| 436 | test("the frame is pure: no clock, no socket, no database", () => { | |
| 437 | const source = readFileSync(join(import.meta.dir, "frame.ts"), "utf8"); | |
| 438 | for (const forbidden of [ | |
| 439 | "Date.now", | |
| 440 | "performance.now", | |
| 441 | "Math.random", | |
| 442 | "./socket.ts", | |
| 443 | "./live.ts", | |
| 444 | "./db.ts", | |
| 445 | ]) | |
| 446 | expect(source, `frame.ts must not reach for ${forbidden}`).not.toContain( | |
| 447 | forbidden, | |
| 448 | ); | |
| 449 | }); |
src/frame.ts added +235
| @@ -0,0 +1,235 @@ | ||
| 1 | /** | |
| 2 | * The frame the round publishes: a world in, one `Frame` out. Pure — no | |
| 3 | * clock, no socket, no database, no loop — so the same world and the same | |
| 4 | * round numbers always build the same bytes, and a test can hold a frame | |
| 5 | * still and read it. | |
| 6 | * | |
| 7 | * Nothing here is a second copy of the wire. Every stride, every scale and | |
| 8 | * every cap is imported from `src/protocol.ts`, every sea number from | |
| 9 | * `src/world.ts` and the roster cap from `src/concept.ts`; what this module | |
| 10 | * owns is the three translations between them. | |
| 11 | * | |
| 12 | * **The units are the tick's.** The sea keeps velocities in units per | |
| 13 | * second; the wire says "1/256 of a unit per tick" (`src/protocol.ts`), and | |
| 14 | * the measurement agrees: 24 motes braiding one lane reach 190 units/s, | |
| 15 | * which is past the ±128 an int16 at `VELOCITY_SCALE` holds per second and | |
| 16 | * is a comfortable 9.5 per tick. So a velocity — a mote's, the bloom's, a | |
| 17 | * current's — is multiplied by `DT` on its way out, and `FIELD_MAX` = 8 is | |
| 18 | * 8 units per tick, 160 units per second: the top of the range play lives | |
| 19 | * in rather than the sea's own 600-unit clamp, which a lane never reaches | |
| 20 | * and which would squash every readable current into the bottom quarter of | |
| 21 | * the scale. | |
| 22 | * | |
| 23 | * **A thruster travels as intent**, the world's acceleration over | |
| 24 | * `THRUST_MAX`, so what a door draws is the direction the player is pushing | |
| 25 | * in the unit disc — `THRUST_MAX` is 12.5 units/s² and would saturate the | |
| 26 | * ±4 that `THRUST_SCALE` leaves, and a share of full thrust is the number a | |
| 27 | * door can actually use. | |
| 28 | * | |
| 29 | * **Positions and angles are the world's own**, unconverted: a position is | |
| 30 | * a sea unit and the gate is radians. | |
| 31 | * | |
| 32 | * What is clamped and what is not: every linear quantity saturates at the | |
| 33 | * range its scale leaves in an int16 — by *magnitude*, as a vector, so a | |
| 34 | * saturated current still points where it pointed — and never wraps. An | |
| 35 | * angle wraps, because an angle is a circle; `encodeFrame` does that part. | |
| 36 | * The frame is clamped here rather than only in the encoder so that a door | |
| 37 | * reading the object in-process (the SVG still, the terminal) sees exactly | |
| 38 | * the numbers a door reading the bytes sees. | |
| 39 | */ | |
| 40 | ||
| 41 | import { ROSTER_MAX } from "./concept.ts"; | |
| 42 | import { | |
| 43 | BLOOM_STRIDE, | |
| 44 | FIELD_COMPONENTS, | |
| 45 | FIELD_LENGTH, | |
| 46 | FIELD_MAX, | |
| 47 | FIELD_SIZE, | |
| 48 | GATE_STRIDE, | |
| 49 | MOTE_STRIDE, | |
| 50 | MOTE_STUNNED, | |
| 51 | POSITION_SCALE, | |
| 52 | THRUST_SCALE, | |
| 53 | VELOCITY_SCALE, | |
| 54 | type Frame, | |
| 55 | } from "./protocol.ts"; | |
| 56 | import { | |
| 57 | DT, | |
| 58 | FIELD_CELLS, | |
| 59 | NAME_MAX, | |
| 60 | THRUST_MAX, | |
| 61 | type Body, | |
| 62 | type World, | |
| 63 | } from "./world.ts"; | |
| 64 | ||
| 65 | /** The largest number an int16 slot holds; every range below comes from it. */ | |
| 66 | const INT16_MAX = 32767; | |
| 67 | ||
| 68 | /** What each scale leaves, in the units that scale is written in. */ | |
| 69 | const POSITION_LIMIT = INT16_MAX / POSITION_SCALE; | |
| 70 | const VELOCITY_LIMIT = INT16_MAX / VELOCITY_SCALE; | |
| 71 | const THRUST_LIMIT = INT16_MAX / THRUST_SCALE; | |
| 72 | ||
| 73 | /** | |
| 74 | * Sea cells per wire cell, per side: the 32×32 field goes out as 16×16, so | |
| 75 | * four cells make one. It is an average and never a sample — a lane one | |
| 76 | * cell wide that drifts across a boundary fades from one wire cell into the | |
| 77 | * next instead of flickering between them, and a sea emptying under | |
| 78 | * invariant 5 empties on the wire at the same rate rather than in steps. | |
| 79 | */ | |
| 80 | export const FIELD_RATIO = FIELD_CELLS / FIELD_SIZE; | |
| 81 | const FIELD_BLOCK = FIELD_RATIO * FIELD_RATIO; | |
| 82 | ||
| 83 | /** What the round tells the frame. #58 owns both; the frame only carries them. */ | |
| 84 | export interface RoundClock { | |
| 85 | /** The round this frame belongs to. */ | |
| 86 | readonly roundId: number; | |
| 87 | /** The round's clock, whatever it counts: a whole non-negative number. */ | |
| 88 | readonly clock: number; | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * A frame, plus the names that go beside the motes. Names are not on the | |
| 93 | * binary wire — `encodeFrame` never sees them — so they ride here in roster | |
| 94 | * order, index `i` of `names` naming mote `i` of `motes`, for the door that | |
| 95 | * has the object in hand and whatever channel a socket chooses for the rest. | |
| 96 | */ | |
| 97 | export interface BuiltFrame extends Frame { | |
| 98 | readonly motes: Float64Array; | |
| 99 | readonly bloom: Float64Array; | |
| 100 | readonly gate: Float64Array; | |
| 101 | readonly field: Float64Array; | |
| 102 | readonly names: readonly string[]; | |
| 103 | } | |
| 104 | ||
| 105 | /** | |
| 106 | * Build one frame from the world the tick just stepped. | |
| 107 | * | |
| 108 | * The roster is the world's own mote order, which `addMote` appends to and | |
| 109 | * `removeMote` splices from: stable between frames, so a door can diff two | |
| 110 | * frames by index and never has to match motes up by name. A world holding | |
| 111 | * more than `ROSTER_MAX` motes — which admission (#8) does not allow — has | |
| 112 | * its first `ROSTER_MAX` sent rather than an over-long frame thrown away. | |
| 113 | */ | |
| 114 | export function buildFrame(world: World, round: RoundClock): BuiltFrame { | |
| 115 | const count = Math.min(world.motes.length, ROSTER_MAX); | |
| 116 | const motes = new Float64Array(count * MOTE_STRIDE); | |
| 117 | const names: string[] = []; | |
| 118 | for (let i = 0; i < count; i++) { | |
| 119 | const mote = world.motes[i]!; | |
| 120 | const at = i * MOTE_STRIDE; | |
| 121 | const [x, y] = cap(mote.x, mote.y, POSITION_LIMIT); | |
| 122 | const [vx, vy] = cap(mote.vx * DT, mote.vy * DT, VELOCITY_LIMIT); | |
| 123 | const [tx, ty] = cap( | |
| 124 | mote.thrust.x / THRUST_MAX, | |
| 125 | mote.thrust.y / THRUST_MAX, | |
| 126 | THRUST_LIMIT, | |
| 127 | ); | |
| 128 | motes[at] = whole(mote.id); | |
| 129 | motes[at + 1] = x; | |
| 130 | motes[at + 2] = y; | |
| 131 | motes[at + 3] = vx; | |
| 132 | motes[at + 4] = vy; | |
| 133 | motes[at + 5] = tx; | |
| 134 | motes[at + 6] = ty; | |
| 135 | motes[at + 7] = flags(mote, world.tick); | |
| 136 | names.push(wireName(mote.name)); | |
| 137 | } | |
| 138 | ||
| 139 | const bloom = new Float64Array(BLOOM_STRIDE); | |
| 140 | const [bx, by] = cap(world.bloom.x, world.bloom.y, POSITION_LIMIT); | |
| 141 | const [bvx, bvy] = cap(world.bloom.vx * DT, world.bloom.vy * DT, VELOCITY_LIMIT); | |
| 142 | bloom[0] = bx; | |
| 143 | bloom[1] = by; | |
| 144 | bloom[2] = bvx; | |
| 145 | bloom[3] = bvy; | |
| 146 | ||
| 147 | const gate = new Float64Array(GATE_STRIDE); | |
| 148 | gate[0] = finite(world.gate.angle); | |
| 149 | gate[1] = finite(world.gate.half); | |
| 150 | ||
| 151 | return { | |
| 152 | tick: whole(world.tick), | |
| 153 | clock: whole(round.clock), | |
| 154 | roundId: whole(round.roundId), | |
| 155 | motes, | |
| 156 | bloom, | |
| 157 | gate, | |
| 158 | field: downsample(world), | |
| 159 | names, | |
| 160 | }; | |
| 161 | } | |
| 162 | ||
| 163 | /** | |
| 164 | * The flags word. `MOTE_STUNNED` is what the protocol defines today and the | |
| 165 | * only bit set here: the mote is inside the seconds the rim charged it | |
| 166 | * (#RIM, invariant 6) and its thruster is off. The beached bit is #67's, | |
| 167 | * and belongs beside it when that lands — nothing invented in the meantime. | |
| 168 | */ | |
| 169 | function flags(mote: Body, tick: number): number { | |
| 170 | return tick < mote.stunnedUntil ? MOTE_STUNNED : 0; | |
| 171 | } | |
| 172 | ||
| 173 | /** | |
| 174 | * The sea's 32×32 field as the wire's 16×16, row-major with `y` growing the | |
| 175 | * way the world's does, two components interleaved per cell: cell `(x, y)` | |
| 176 | * is at `(y * FIELD_SIZE + x) * FIELD_COMPONENTS`. | |
| 177 | */ | |
| 178 | function downsample(world: World): Float64Array { | |
| 179 | const out = new Float64Array(FIELD_LENGTH); | |
| 180 | const fx = world.field.x; | |
| 181 | const fy = world.field.y; | |
| 182 | for (let y = 0; y < FIELD_SIZE; y++) { | |
| 183 | for (let x = 0; x < FIELD_SIZE; x++) { | |
| 184 | let sx = 0; | |
| 185 | let sy = 0; | |
| 186 | for (let dy = 0; dy < FIELD_RATIO; dy++) { | |
| 187 | const row = (y * FIELD_RATIO + dy) * FIELD_CELLS + x * FIELD_RATIO; | |
| 188 | for (let dx = 0; dx < FIELD_RATIO; dx++) { | |
| 189 | sx += finite(fx[row + dx]); | |
| 190 | sy += finite(fy[row + dx]); | |
| 191 | } | |
| 192 | } | |
| 193 | const [cx, cy] = cap( | |
| 194 | (sx / FIELD_BLOCK) * DT, | |
| 195 | (sy / FIELD_BLOCK) * DT, | |
| 196 | FIELD_MAX, | |
| 197 | ); | |
| 198 | const at = (y * FIELD_SIZE + x) * FIELD_COMPONENTS; | |
| 199 | out[at] = cx; | |
| 200 | out[at + 1] = cy; | |
| 201 | } | |
| 202 | } | |
| 203 | return out; | |
| 204 | } | |
| 205 | ||
| 206 | /** | |
| 207 | * A vector held inside `limit`, keeping its direction: past the limit it is | |
| 208 | * scaled back along itself rather than squared off component by component, | |
| 209 | * which would turn a fast lane into a diagonal one on its way out. | |
| 210 | */ | |
| 211 | function cap(x: number, y: number, limit: number): [number, number] { | |
| 212 | const cx = finite(x); | |
| 213 | const cy = finite(y); | |
| 214 | const length = Math.hypot(cx, cy); | |
| 215 | if (!(length > limit)) return [cx, cy]; | |
| 216 | const scale = limit / length; | |
| 217 | return [cx * scale, cy * scale]; | |
| 218 | } | |
| 219 | ||
| 220 | const finite = (value: number | undefined): number => | |
| 221 | typeof value === "number" && Number.isFinite(value) ? value : 0; | |
| 222 | ||
| 223 | /** A whole non-negative number, the way the header's uint32 slots read. */ | |
| 224 | const whole = (value: number): number => | |
| 225 | Math.max(0, Math.min(0xffffffff, Math.trunc(finite(value)))); | |
| 226 | ||
| 227 | /** | |
| 228 | * A name as it rides beside the frame: bounded at `NAME_MAX`, the world's | |
| 229 | * own cap, with control characters taken out so nothing downstream has to | |
| 230 | * wonder. Escaping stays the door's job — the door knows its own syntax. | |
| 231 | */ | |
| 232 | const wireName = (name: string): string => | |
| 233 | typeof name === "string" | |
| 234 | ? name.replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, NAME_MAX) | |
| 235 | : ""; |
src/protocol.ts +5
| @@ -19,6 +19,11 @@ | ||
| 19 | 19 | * |
| 20 | 20 | * A frame at the roster cap is 924 bytes; the budget is `FRAME_MAX_BYTES` |
| 21 | 21 | * and `protocol.test.ts` prints the real count when it checks it. |
| 22 | * | |
| 23 | * What the numbers *mean* is one module along: `src/frame.ts` builds every | |
| 24 | * frame from a world, and its header states the units a decoder is holding | |
| 25 | * — positions in sea units, velocities and currents per tick, a thruster as | |
| 26 | * a share of full thrust, the field row-major with two components per cell. | |
| 22 | 27 | */ |
| 23 | 28 | |
| 24 | 29 | import { ROSTER_MAX } from "./concept.ts"; |