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

Protocol: one wire format for frames and steering, written once #25

Merged opened this

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

Merged

vesper/drift:vesper/16-protocol-one-wire-format-for-frames-and → main 2 commits fork

  • Merged. vesper/16-protocol-one-wire-format-for-frames-and was merged into main as 1353871.

Review

Sign in to review this change.
3 files changed +601 −14
README.md +15 −14
@@ -41,21 +41,22 @@ plain function — `(db, who, input)` in, data or a named failure out — and
4141 the page, the JSON API, the MCP server and the terminal all call it and
4242 decide nothing themselves. `AGENTS.md` says how to add to it.
4343
44 | file | what it is |
45 | ---------------------------------- | ------------------------------------------------------------ |
46 | `src/actions.ts` | every operation, and who may — empty until #DATA |
47 | `src/who.ts` | who is asking, from the forge's signed headers |
48 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
49 | `src/live.ts` | the in-process bus and `GET /events` |
50 | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` — none yet |
51 | `src/routes.ts` | every path, in one table |
44 | file | what it is |
45 | ---------------------------------- | -------------------------------------------------------------- |
46 | `src/actions.ts` | every operation, and who may — empty until #DATA |
47 | `src/protocol.ts` | the wire: one frame down, one steer up, quantised and sized |
48 | `src/who.ts` | who is asking, from the forge's signed headers |
49 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
50 | `src/live.ts` | the in-process bus and `GET /events` |
51 | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` — none yet |
52 | `src/routes.ts` | every path, in one table |
5253 | `src/concept.ts` | the design record as data: the rule, the round, the invariants |
53 | `src/page.ts` | the HTML of the arena, rendered on the server |
54 | `src/http.ts` | the floor: headers, body cap, origins, rate limit, files |
55 | `src/server.ts` | boot: open the database, serve |
56 | `src/tty.ts` | the terminal — a placeholder until the world runs |
57 | `src/env.ts` | what the forge told this process |
58 | `static/app.css`, `static/live.js` | the look, and the regions that stay current |
54 | `src/page.ts` | the HTML of the arena, rendered on the server |
55 | `src/http.ts` | the floor: headers, body cap, origins, rate limit, files |
56 | `src/server.ts` | boot: open the database, serve |
57 | `src/tty.ts` | the terminal — a placeholder until the world runs |
58 | `src/env.ts` | what the forge told this process |
59 | `static/app.css`, `static/live.js` | the look, and the regions that stay current |
5960
6061 ## Who you are
6162
src/protocol.test.ts added +258
@@ -0,0 +1,258 @@
1 import { expect, test } from "bun:test";
2 import {
3 BLOOM_STRIDE,
4 decodeFrame,
5 encodeFrame,
6 FIELD_LENGTH,
7 FIELD_MAX,
8 FRAME_MAX_BYTES,
9 type Frame,
10 MOTE_STRIDE,
11 MOTE_STUNNED,
12 parseSteer,
13 POSITION_SCALE,
14 PROTOCOL_VERSION,
15 ROSTER_MAX,
16 SEQ_MAX,
17 STEER_MAX_BYTES,
18 THRUST_SCALE,
19 VELOCITY_SCALE,
20 } from "./protocol.ts";
21
22 /** Half a quantiser step: what the wire promises to keep. */
23 const POSITION_TOLERANCE = 1 / (2 * POSITION_SCALE);
24 const VELOCITY_TOLERANCE = 1 / (2 * VELOCITY_SCALE);
25 const THRUST_TOLERANCE = 1 / (2 * THRUST_SCALE);
26 const FIELD_TOLERANCE = FIELD_MAX / 127 / 2 + 1e-9;
27 const ANGLE_TOLERANCE = (Math.PI * 2) / 65536 / 2 + 1e-9;
28
29 /** A frame with `count` motes, every number distinct so nothing can hide in a zero. */
30 function sampleFrame(count: number): Frame {
31 const motes: number[] = [];
32 for (let i = 0; i < count; i++) {
33 const a = (i / count) * Math.PI * 2;
34 motes.push(
35 i + 1,
36 Math.cos(a) * 480.5,
37 Math.sin(a) * 312.25,
38 Math.cos(a) * 3.5,
39 Math.sin(a) * 2.25,
40 Math.cos(a) * 0.01,
41 Math.sin(a) * 0.01,
42 i % 3 === 0 ? MOTE_STUNNED : 0,
43 );
44 }
45 const field: number[] = [];
46 for (let i = 0; i < FIELD_LENGTH; i++)
47 field.push(Math.sin(i * 0.37) * (FIELD_MAX * 0.9));
48 return {
49 tick: 3_600,
50 clock: 121_500,
51 roundId: 42,
52 motes,
53 bloom: [-120.75, 88.5, -0.5, 1.25],
54 gate: [-2.1, Math.PI / 6],
55 field,
56 };
57 }
58
59 test("a frame round-trips every field inside the precision it states", () => {
60 const frame = sampleFrame(7);
61 const decoded = decodeFrame(encodeFrame(frame));
62 expect(decoded.ok).toBe(true);
63 if (!decoded.ok) return;
64 const back = decoded.value;
65
66 expect(back.version).toBe(PROTOCOL_VERSION);
67 expect(back.tick).toBe(frame.tick);
68 expect(back.clock).toBe(frame.clock);
69 expect(back.roundId).toBe(frame.roundId);
70
71 expect(back.motes.length).toBe(7 * MOTE_STRIDE);
72 for (let i = 0; i < back.motes.length; i++) {
73 const slot = i % MOTE_STRIDE;
74 const tolerance =
75 slot === 0 || slot === 7
76 ? 0
77 : slot === 1 || slot === 2
78 ? POSITION_TOLERANCE
79 : slot === 3 || slot === 4
80 ? VELOCITY_TOLERANCE
81 : THRUST_TOLERANCE;
82 expect(Math.abs(back.motes[i]! - frame.motes[i]!)).toBeLessThanOrEqual(
83 tolerance,
84 );
85 }
86 expect(back.motes[0]).toBe(1);
87 expect(back.motes[MOTE_STRIDE - 1]).toBe(MOTE_STUNNED);
88
89 expect(back.bloom.length).toBe(BLOOM_STRIDE);
90 for (let i = 0; i < BLOOM_STRIDE; i++)
91 expect(Math.abs(back.bloom[i]! - frame.bloom[i]!)).toBeLessThanOrEqual(
92 i < 2 ? POSITION_TOLERANCE : VELOCITY_TOLERANCE,
93 );
94
95 for (let i = 0; i < 2; i++)
96 expect(Math.abs(back.gate[i]! - frame.gate[i]!)).toBeLessThanOrEqual(
97 ANGLE_TOLERANCE,
98 );
99
100 expect(back.field.length).toBe(FIELD_LENGTH);
101 for (let i = 0; i < FIELD_LENGTH; i++)
102 expect(Math.abs(back.field[i]! - frame.field[i]!)).toBeLessThanOrEqual(
103 FIELD_TOLERANCE,
104 );
105 });
106
107 test("a frame at the roster cap fits the bandwidth budget", () => {
108 const bytes = encodeFrame(sampleFrame(ROSTER_MAX));
109 expect(
110 bytes.length,
111 `a ${ROSTER_MAX}-mote frame is ${bytes.length} bytes of the ${FRAME_MAX_BYTES} allowed`,
112 ).toBeLessThan(FRAME_MAX_BYTES);
113 });
114
115 test("a wild number is clamped or zeroed, never written as NaN", () => {
116 const frame = sampleFrame(1);
117 const wild: Frame = {
118 ...frame,
119 motes: [1, Number.NaN, 1e9, Number.POSITIVE_INFINITY, -1e9, 0, 0, 0],
120 bloom: [Number.NaN, 0, 0, Number.NEGATIVE_INFINITY],
121 gate: [Number.NaN, 7 * Math.PI],
122 tick: -5,
123 clock: Number.NaN,
124 };
125 const decoded = decodeFrame(encodeFrame(wild));
126 expect(decoded.ok).toBe(true);
127 if (!decoded.ok) return;
128 for (const n of [
129 ...decoded.value.motes,
130 ...decoded.value.bloom,
131 ...decoded.value.gate,
132 ...decoded.value.field,
133 decoded.value.tick,
134 decoded.value.clock,
135 ])
136 expect(Number.isFinite(n)).toBe(true);
137 expect(decoded.value.tick).toBe(0);
138 expect(decoded.value.clock).toBe(0);
139 expect(decoded.value.motes[1]).toBe(0);
140 });
141
142 test("a frame of the wrong shape is the caller's bug, and throws", () => {
143 const frame = sampleFrame(2);
144 expect(() => encodeFrame({ ...frame, motes: [1, 2, 3] })).toThrow(RangeError);
145 expect(() => encodeFrame(sampleFrame(ROSTER_MAX + 1))).toThrow(RangeError);
146 expect(() => encodeFrame({ ...frame, bloom: [0, 0] })).toThrow(RangeError);
147 expect(() => encodeFrame({ ...frame, gate: [0] })).toThrow(RangeError);
148 expect(() => encodeFrame({ ...frame, field: [0, 0, 0] })).toThrow(RangeError);
149 });
150
151 test("a foreign, truncated or wrong-version payload is a failure, not a throw", () => {
152 const good = encodeFrame(sampleFrame(3));
153
154 const cases: [unknown, string][] = [
155 ["a frame, honest", "not_bytes"],
156 [null, "not_bytes"],
157 [{ tick: 1 }, "not_bytes"],
158 [new Uint8Array(4), "truncated"],
159 [good.slice(0, good.length - 1), "truncated"],
160 [new Uint8Array(good.length), "foreign"],
161 [withByte(good, 2, PROTOCOL_VERSION + 1), "bad_version"],
162 [withByte(good, 3, ROSTER_MAX + 1), "bad_roster"],
163 [withByte(good, 3, 4), "truncated"],
164 ];
165 for (const [payload, error] of cases) {
166 const decoded = decodeFrame(payload);
167 expect(decoded.ok, `${error} payload decoded instead of failing`).toBe(
168 false,
169 );
170 if (!decoded.ok) expect(decoded.error).toBe(error);
171 }
172
173 // Random bytes with the right magic never throw either.
174 for (let seed = 0; seed < 64; seed++) {
175 const noise = new Uint8Array(good.length);
176 for (let i = 0; i < noise.length; i++)
177 noise[i] = (seed * 31 + i * 17) % 256;
178 noise[0] = good[0]!;
179 noise[1] = good[1]!;
180 noise[2] = PROTOCOL_VERSION;
181 expect(() => decodeFrame(noise)).not.toThrow();
182 }
183 });
184
185 /** A copy of `bytes` with one byte replaced, for feeding the decoder a lie. */
186 function withByte(bytes: Uint8Array, at: number, value: number): Uint8Array {
187 const copy = bytes.slice();
188 copy[at] = value;
189 return copy;
190 }
191
192 test("a steer arrives as text, bytes or an object and lands in the unit disc", () => {
193 const text = parseSteer('{"x":0.5,"y":-0.25,"seq":7}');
194 expect(text.ok).toBe(true);
195 if (text.ok) expect(text.value).toEqual({ x: 0.5, y: -0.25, seq: 7 });
196
197 const bytes = parseSteer(new TextEncoder().encode('{"x":0,"y":1,"seq":0}'));
198 expect(bytes.ok).toBe(true);
199 if (bytes.ok) expect(bytes.value).toEqual({ x: 0, y: 1, seq: 0 });
200
201 const object = parseSteer({ x: 3, y: 4, seq: SEQ_MAX });
202 expect(object.ok).toBe(true);
203 if (object.ok) {
204 expect(Math.hypot(object.value.x, object.value.y)).toBeCloseTo(1, 12);
205 expect(object.value.x).toBeCloseTo(0.6, 12);
206 expect(object.value.seq).toBe(SEQ_MAX);
207 }
208
209 const inside = parseSteer({ x: -0.3, y: 0.4, seq: 1 });
210 expect(inside.ok && Math.hypot(inside.value.x, inside.value.y)).toBeCloseTo(
211 0.5,
212 12,
213 );
214 });
215
216 test("every steer that is not one is refused by name", () => {
217 const cases: [unknown, string][] = [
218 ["left a bit", "not_json"],
219 ["", "not_json"],
220 ['"a string"', "not_an_object"],
221 ["[0,1,2]", "not_an_object"],
222 ["null", "not_an_object"],
223 [["x", 1], "not_an_object"],
224 [42, "not_an_object"],
225 [null, "not_an_object"],
226 [undefined, "not_an_object"],
227 [{ y: 1, seq: 0 }, "bad_axis"],
228 [{ x: 1, seq: 0 }, "bad_axis"],
229 [{ x: "1", y: 0, seq: 0 }, "bad_axis"],
230 [{ x: Number.NaN, y: 0, seq: 0 }, "bad_axis"],
231 [{ x: 0, y: Number.POSITIVE_INFINITY, seq: 0 }, "bad_axis"],
232 [{ x: 0, y: 0 }, "bad_seq"],
233 [{ x: 0, y: 0, seq: -1 }, "bad_seq"],
234 [{ x: 0, y: 0, seq: 1.5 }, "bad_seq"],
235 [{ x: 0, y: 0, seq: Number.NaN }, "bad_seq"],
236 [{ x: 0, y: 0, seq: SEQ_MAX + 1 }, "bad_seq"],
237 [{ x: 0, y: 0, seq: "3" }, "bad_seq"],
238 [`{"x":0,"y":0,"seq":0,"pad":"${"a".repeat(300)}"}`, "too_large"],
239 [new Uint8Array(300).fill(0x20), "too_large"],
240 ["\u{1f30a}".repeat(40), "too_large"],
241 ];
242 for (const [message, error] of cases) {
243 const steer = parseSteer(message);
244 expect(
245 steer.ok,
246 `${JSON.stringify(message)?.slice(0, 40)} was accepted`,
247 ).toBe(false);
248 if (!steer.ok) expect(steer.error).toBe(error);
249 }
250 });
251
252 test("a steer at the size cap is still accepted", () => {
253 const padded = `{"x":0.5,"y":0.5,"seq":9,"pad":"${"a".repeat(
254 STEER_MAX_BYTES - 34,
255 )}"}`;
256 expect(padded.length).toBeLessThanOrEqual(STEER_MAX_BYTES);
257 expect(parseSteer(padded).ok).toBe(true);
258 });
src/protocol.ts added +328
@@ -0,0 +1,328 @@
1 /**
2 * The wire, written once. A frame goes down (server → everyone, 10 Hz) and a
3 * steer comes up (one player → server); this module is the only place either
4 * shape is defined, so the round, the socket, the canvas, the terminal and
5 * the JSON door all read the same bytes instead of each inventing them.
6 *
7 * Plain data in, plain data out: nothing here imports the world, the
8 * database, the bus or the HTTP floor — only the roster cap, from the design
9 * record in `src/concept.ts`, which is data too — and nothing here knows what
10 * a bloom *means*, which is `world.ts`. A frame is flat numeric arrays with a
11 * fixed stride, packed little-endian, never objects on the wire.
12 *
13 * What the quantiser keeps: positions to 1/4 of a unit on a 1000-unit arena
14 * (a quarter pixel when the arena is drawn 1000 across), velocities to 1/256
15 * of a unit per tick, thrust to 1/8192, the gate arc to 1/65536 of a turn,
16 * and each field component to 1/127 of ±`FIELD_MAX` in one byte — field
17 * components past that magnitude saturate rather than wrap. Everything else
18 * (tick, clock, round id, mote ids, flags) is exact.
19 *
20 * A frame at the roster cap is 924 bytes; the budget is `FRAME_MAX_BYTES`
21 * and `protocol.test.ts` prints the real count when it checks it.
22 */
23
24 import { ROSTER_MAX } from "./concept.ts";
25
26 /** Bumped whenever the layout below changes; a decoder refuses anything else. */
27 export const PROTOCOL_VERSION = 1;
28 /** First two bytes of every frame: "DF". A foreign payload is refused, not guessed. */
29 export const FRAME_MAGIC = [0x44, 0x46] as const;
30
31 /** One mote is `[id, x, y, vx, vy, thrustX, thrustY, flags]`, int16 each. */
32 export const MOTE_STRIDE = 8;
33 /** The bloom is `[x, y, vx, vy]` — a mote's first four numbers, no id and no thruster. */
34 export const BLOOM_STRIDE = 4;
35 /** The gate arc is `[centre, halfWidth]` in radians, on the rim. */
36 export const GATE_STRIDE = 2;
37 /** Bit 0 of a mote's `flags`: it hit the rim fast and is not steering yet. */
38 export const MOTE_STUNNED = 1;
39
40 /** The field goes out downsampled to 16×16 cells, two components each. */
41 export const FIELD_SIZE = 16;
42 export const FIELD_COMPONENTS = 2;
43 export const FIELD_CELLS = FIELD_SIZE * FIELD_SIZE;
44 export const FIELD_LENGTH = FIELD_CELLS * FIELD_COMPONENTS;
45 /** The field component magnitude that fills a byte; beyond it, the wire saturates. */
46 export const FIELD_MAX = 8;
47
48 export const POSITION_SCALE = 4;
49 export const VELOCITY_SCALE = 256;
50 export const THRUST_SCALE = 8192;
51 /** Angles travel as a fraction of a turn in a uint16. */
52 export const ANGLE_SCALE = 65536;
53
54 /**
55 * How many motes a frame may carry: the roster cap is the design record's
56 * number (`src/concept.ts`), not a second copy of 24 living on the wire.
57 */
58 export { ROSTER_MAX };
59 /** 2 magic + 1 version + 1 count + tick + round + clock + gate + bloom. */
60 export const FRAME_HEADER_BYTES = 28;
61 /** The bandwidth budget for one frame at the roster cap. */
62 export const FRAME_MAX_BYTES = 4096;
63 /** A steer is a handful of numbers; anything bigger is refused unread. */
64 export const STEER_MAX_BYTES = 128;
65 /** A steer's sequence number is a non-negative int32. */
66 export const SEQ_MAX = 2_147_483_647;
67
68 /** What one frame carries. The flat arrays may be typed arrays or plain ones. */
69 export interface Frame {
70 readonly tick: number;
71 readonly clock: number;
72 readonly roundId: number;
73 /** `motes.length` is a multiple of `MOTE_STRIDE`, at most `ROSTER_MAX` motes. */
74 readonly motes: ArrayLike<number>;
75 readonly bloom: ArrayLike<number>;
76 readonly gate: ArrayLike<number>;
77 readonly field: ArrayLike<number>;
78 }
79
80 /** What comes back off the wire: the same frame, plain arrays, plus its version. */
81 export interface DecodedFrame extends Frame {
82 readonly version: number;
83 readonly motes: number[];
84 readonly bloom: number[];
85 readonly gate: number[];
86 readonly field: number[];
87 }
88
89 /** One player's intent: a direction in the unit disc and the count they sent it under. */
90 export interface Steer {
91 readonly x: number;
92 readonly y: number;
93 readonly seq: number;
94 }
95
96 export type ProtocolFailure =
97 | "too_large"
98 | "not_json"
99 | "not_an_object"
100 | "bad_axis"
101 | "bad_seq"
102 | "not_bytes"
103 | "foreign"
104 | "bad_version"
105 | "truncated"
106 | "bad_roster";
107
108 export type Parsed<T> =
109 | { ok: true; value: T }
110 | { ok: false; error: ProtocolFailure; message: string };
111
112 const ok = <T>(value: T): Parsed<T> => ({ ok: true, value });
113 const fail = <T>(error: ProtocolFailure, message: string): Parsed<T> => ({
114 ok: false,
115 error,
116 message,
117 });
118
119 const TAU = Math.PI * 2;
120
121 /** A finite number, scaled and rounded into int16 range; anything else is zero. */
122 const toInt16 = (value: number, scale: number): number =>
123 Math.max(-32768, Math.min(32767, Math.round(finite(value) * scale)));
124
125 const finite = (value: number): number =>
126 typeof value === "number" && Number.isFinite(value) ? value : 0;
127
128 /** A field component, zero at 128, saturating at ±`FIELD_MAX`. */
129 const toFieldByte = (value: number): number =>
130 Math.max(
131 0,
132 Math.min(255, 128 + Math.round((finite(value) / FIELD_MAX) * 127)),
133 );
134
135 const fromFieldByte = (byte: number): number =>
136 ((byte - 128) / 127) * FIELD_MAX;
137
138 /** Radians to a uint16 fraction of a turn, wrapped into `[0, 1)`. */
139 const toTurns = (radians: number): number => {
140 const turns = finite(radians) / TAU;
141 return Math.round((turns - Math.floor(turns)) * ANGLE_SCALE) % ANGLE_SCALE;
142 };
143
144 /** Back to radians in `(-π, π]`, which is where the rim reads best. */
145 const fromTurns = (units: number): number => {
146 const radians = (units / ANGLE_SCALE) * TAU;
147 return radians > Math.PI ? radians - TAU : radians;
148 };
149
150 /** How many bytes a frame with `moteCount` motes takes. */
151 export const frameBytes = (moteCount: number): number =>
152 FRAME_HEADER_BYTES + moteCount * MOTE_STRIDE * 2 + FIELD_LENGTH;
153
154 /**
155 * A frame as bytes. Non-finite numbers are written as zero and out-of-range
156 * ones clamp, so nothing downstream ever sees a `NaN`; a wrong *shape* is a
157 * caller's bug and throws `RangeError` rather than shipping a broken frame.
158 */
159 export function encodeFrame(frame: Frame): Uint8Array {
160 const moteCount = frame.motes.length / MOTE_STRIDE;
161 if (!Number.isInteger(moteCount))
162 throw new RangeError(`motes must be a multiple of ${MOTE_STRIDE} numbers`);
163 if (moteCount > ROSTER_MAX)
164 throw new RangeError(`a frame carries at most ${ROSTER_MAX} motes`);
165 if (frame.bloom.length !== BLOOM_STRIDE)
166 throw new RangeError(`bloom is ${BLOOM_STRIDE} numbers`);
167 if (frame.gate.length !== GATE_STRIDE)
168 throw new RangeError(`gate is ${GATE_STRIDE} numbers`);
169 if (frame.field.length !== FIELD_LENGTH)
170 throw new RangeError(`field is ${FIELD_LENGTH} numbers`);
171
172 const bytes = new Uint8Array(frameBytes(moteCount));
173 const view = new DataView(bytes.buffer);
174 bytes[0] = FRAME_MAGIC[0];
175 bytes[1] = FRAME_MAGIC[1];
176 bytes[2] = PROTOCOL_VERSION;
177 bytes[3] = moteCount;
178 view.setUint32(4, whole(frame.tick), true);
179 view.setUint32(8, whole(frame.roundId), true);
180 view.setUint32(12, whole(frame.clock), true);
181 view.setUint16(16, toTurns(frame.gate[0]!), true);
182 view.setUint16(18, toTurns(frame.gate[1]!), true);
183 for (let i = 0; i < BLOOM_STRIDE; i++)
184 view.setInt16(
185 20 + i * 2,
186 toInt16(frame.bloom[i]!, i < 2 ? POSITION_SCALE : VELOCITY_SCALE),
187 true,
188 );
189
190 let at = FRAME_HEADER_BYTES;
191 for (let i = 0; i < frame.motes.length; i++, at += 2)
192 view.setInt16(
193 at,
194 toInt16(frame.motes[i]!, moteScale(i % MOTE_STRIDE)),
195 true,
196 );
197 for (let i = 0; i < FIELD_LENGTH; i++)
198 bytes[at + i] = toFieldByte(frame.field[i]!);
199 return bytes;
200 }
201
202 /** The scale of column `slot` of a mote: id and flags are exact, the rest scaled. */
203 const moteScale = (slot: number): number => {
204 if (slot === 0 || slot === 7) return 1;
205 if (slot === 1 || slot === 2) return POSITION_SCALE;
206 if (slot === 3 || slot === 4) return VELOCITY_SCALE;
207 return THRUST_SCALE;
208 };
209
210 const whole = (value: number): number =>
211 Math.max(0, Math.min(0xffffffff, Math.trunc(finite(value))));
212
213 /**
214 * Bytes back into a frame. Never throws: a foreign, truncated, future or
215 * simply wrong payload comes back as a named failure.
216 */
217 export function decodeFrame(payload: unknown): Parsed<DecodedFrame> {
218 const bytes =
219 payload instanceof Uint8Array
220 ? payload
221 : payload instanceof ArrayBuffer
222 ? new Uint8Array(payload)
223 : null;
224 if (bytes === null) return fail("not_bytes", "A frame is bytes.");
225 if (bytes.length < FRAME_HEADER_BYTES)
226 return fail("truncated", "A frame is shorter than its header.");
227 if (bytes[0] !== FRAME_MAGIC[0] || bytes[1] !== FRAME_MAGIC[1])
228 return fail("foreign", "Not a drift frame.");
229 if (bytes[2] !== PROTOCOL_VERSION)
230 return fail("bad_version", `A frame is version ${PROTOCOL_VERSION}.`);
231
232 const moteCount = bytes[3]!;
233 if (moteCount > ROSTER_MAX)
234 return fail("bad_roster", `A frame carries at most ${ROSTER_MAX} motes.`);
235 if (bytes.length !== frameBytes(moteCount))
236 return fail("truncated", "A frame is not the length it claims.");
237
238 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
239 const bloom: number[] = [];
240 for (let i = 0; i < BLOOM_STRIDE; i++)
241 bloom.push(
242 view.getInt16(20 + i * 2, true) /
243 (i < 2 ? POSITION_SCALE : VELOCITY_SCALE),
244 );
245
246 let at = FRAME_HEADER_BYTES;
247 const motes: number[] = [];
248 for (let i = 0; i < moteCount * MOTE_STRIDE; i++, at += 2)
249 motes.push(view.getInt16(at, true) / moteScale(i % MOTE_STRIDE));
250 const field: number[] = [];
251 for (let i = 0; i < FIELD_LENGTH; i++)
252 field.push(fromFieldByte(bytes[at + i]!));
253
254 return ok({
255 version: bytes[2]!,
256 tick: view.getUint32(4, true),
257 roundId: view.getUint32(8, true),
258 clock: view.getUint32(12, true),
259 gate: [
260 fromTurns(view.getUint16(16, true)),
261 fromTurns(view.getUint16(18, true)),
262 ],
263 bloom,
264 motes,
265 field,
266 });
267 }
268
269 /**
270 * The only place steering is validated. Takes what the socket actually hands
271 * over — a string, bytes, or an already-parsed object — and returns either a
272 * direction clamped into the unit disc or a named failure. Nothing past this
273 * point has to ask whether `x` is a number.
274 */
275 export function parseSteer(message: unknown): Parsed<Steer> {
276 let value: unknown = message;
277 if (typeof message === "string" || isBytes(message)) {
278 const text = typeof message === "string" ? message : decodeText(message);
279 if (byteLength(text) > STEER_MAX_BYTES)
280 return fail("too_large", `A steer is at most ${STEER_MAX_BYTES} bytes.`);
281 try {
282 value = JSON.parse(text);
283 } catch {
284 return fail("not_json", "A steer is JSON.");
285 }
286 }
287 if (typeof value !== "object" || value === null || Array.isArray(value))
288 return fail("not_an_object", "A steer is an object.");
289
290 const steer = value as Record<string, unknown>;
291 const x = steer.x;
292 const y = steer.y;
293 if (
294 typeof x !== "number" ||
295 typeof y !== "number" ||
296 !Number.isFinite(x) ||
297 !Number.isFinite(y)
298 )
299 return fail("bad_axis", "A steer has finite x and y.");
300 const seq = steer.seq;
301 if (
302 typeof seq !== "number" ||
303 !Number.isInteger(seq) ||
304 seq < 0 ||
305 seq > SEQ_MAX
306 )
307 return fail("bad_seq", "A steer has a non-negative whole seq.");
308
309 const length = Math.hypot(x, y);
310 const scale = length > 1 ? 1 / length : 1;
311 return ok({ x: x * scale, y: y * scale, seq });
312 }
313
314 const isBytes = (value: unknown): value is Uint8Array | ArrayBuffer =>
315 value instanceof Uint8Array || value instanceof ArrayBuffer;
316
317 const utf8 = new TextDecoder();
318 const decodeText = (bytes: Uint8Array | ArrayBuffer): string =>
319 utf8.decode(
320 bytes instanceof Uint8Array
321 ? bytes.subarray(0, STEER_MAX_BYTES + 1)
322 : new Uint8Array(bytes).subarray(0, STEER_MAX_BYTES + 1),
323 );
324
325 const encoder = new TextEncoder();
326 /** UTF-8 bytes, without encoding a megabyte to learn it is over the cap. */
327 const byteLength = (text: string): number =>
328 text.length > STEER_MAX_BYTES ? text.length : encoder.encode(text).length;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs