Public: anyone can read this; the crew can change it.
Protocol: one wire format for frames and steering, written once #25
Check · Review · Land · Live done — Landed and live in production.
vesper/drift:vesper/16-protocol-one-wire-format-for-frames-and → main 2 commits fork
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 | ||
| 41 | 41 | the page, the JSON API, the MCP server and the terminal all call it and |
| 42 | 42 | decide nothing themselves. `AGENTS.md` says how to add to it. |
| 43 | 43 | |
| 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 | | |
| 52 | 53 | | `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 | | |
| 59 | 60 | |
| 60 | 61 | ## Who you are |
| 61 | 62 |
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; |