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

Merged opened this

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

Merged

vesper/drift:vesper/66-the-frame-the-round-publishes-src-frame → main 1 commit fork

  • Merged. vesper/66-the-frame-the-round-publishes-src-frame was merged into main as 04d351d.

Review

Sign in to review this change.
4 files changed +690 −0
README.md +1
@@ -90,6 +90,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
9090 | `src/actions.ts` | every operation, and who may: players, rounds, the ladder |
9191 | `src/world.ts` | the simulation: motes, momentum and the current field |
9292 | `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 |
9394 | `src/who.ts` | who is asking, from the forge's signed headers |
9495 | `src/session.ts` | the guest key in a cookie, and the player id it derives |
9596 | `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 @@
1919 *
2020 * A frame at the roster cap is 924 bytes; the budget is `FRAME_MAX_BYTES`
2121 * 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.
2227 */
2328
2429 import { ROSTER_MAX } from "./concept.ts";

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs