Public: anyone can read this; the crew can change it.
forked from trevato/cs
A member of a room is the engine's: the socket, the clock and the four sentences stop being the shooter's #370
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/334-a-member-of-a-room-is-the-engine-s-the-s → main 1 commit fork
6 files
changed +589
−71
src/client/session.ts +56 −70
| @@ -19,24 +19,24 @@ | ||
| 19 | 19 | * still in between. Interpolating them is its own issue and is not pretended to |
| 20 | 20 | * here or anywhere else. |
| 21 | 21 | * |
| 22 | * The socket, its lifecycle, the clock since the last snapshot, the count of | |
| 23 | * ticks sent and the rule for which sentence is said are not the shooter's and | |
| 24 | * are `createMember` behind the engine's door. What is left here is this game's | |
| 25 | * half: the codec out of `src/rooms/wire.ts`, the world a `hello` builds with | |
| 26 | * `me` on our seat, the roster onto bodies, the feed and messages with their | |
| 27 | * caps, our own body off a snapshot, and the four sentences in its words. | |
| 28 | * | |
| 22 | 29 | * Nothing in here is a browser: `connect` is handed in, so `session.test.ts` |
| 23 | 30 | * plays a whole room through a socket of plain objects, on a clock it moves. |
| 24 | 31 | */ |
| 25 | 32 | |
| 26 | import type { Hello, Pressed } from "../rooms/wire.ts"; | |
| 33 | import { createMember, type MemberSocket } from "../engine/index.ts"; | |
| 34 | import type { Down, Hello, Pressed, Snapshot } from "../rooms/wire.ts"; | |
| 27 | 35 | import { applySnapshot, forget, readDown, writeInput, SNAPSHOT_FRAME } from "../rooms/wire.ts"; |
| 28 | 36 | import type { Player, World } from "./world.ts"; |
| 29 | 37 | |
| 30 | /** All this file asks a WebSocket to be. A browser's is one of these. */ | |
| 31 | export type Socket = { | |
| 32 | binaryType: string; | |
| 33 | send(data: Uint8Array): void; | |
| 34 | close(): void; | |
| 35 | onopen: (() => void) | null; | |
| 36 | onmessage: ((event: { data: unknown }) => void) | null; | |
| 37 | onclose: (() => void) | null; | |
| 38 | onerror: (() => void) | null; | |
| 39 | }; | |
| 38 | /** All this file asks a WebSocket to be — the engine's, under the name this game has always used. */ | |
| 39 | export type Socket = MemberSocket; | |
| 40 | 40 | |
| 41 | 41 | /** What the page shows about a room while it is up. */ |
| 42 | 42 | export type Standing = { |
| @@ -72,6 +72,12 @@ export const DROPPED = "The room closed the connection. Leave the match and join | ||
| 72 | 72 | /** How far behind the last snapshot has to be before the page says so, in ms. */ |
| 73 | 73 | export const BEHIND_MS = 500; |
| 74 | 74 | |
| 75 | /** | |
| 76 | * The reused input buffer: two bytes of headroom over `INPUT_BYTES`, as it has | |
| 77 | * always been, since `writeInput` hands back only what it wrote. | |
| 78 | */ | |
| 79 | const INPUT_BUFFER = 16; | |
| 80 | ||
| 75 | 81 | export function createSession(options: { |
| 76 | 82 | connect(url: string): Socket; |
| 77 | 83 | /** Where to connect: the socket's path with the room and the name on it. */ |
| @@ -86,29 +92,23 @@ export function createSession(options: { | ||
| 86 | 92 | build(hello: Hello): World; |
| 87 | 93 | now(): number; |
| 88 | 94 | }): Session { |
| 89 | const socket = options.connect(options.url); | |
| 90 | socket.binaryType = "arraybuffer"; | |
| 91 | let world: World | null = null; | |
| 92 | 95 | let seat = -1; |
| 93 | let live = false; | |
| 94 | let closed = false; | |
| 95 | let landed = -1; | |
| 96 | 96 | let people = 0; |
| 97 | /** What the next input frame is stamped with: our own count of ticks sent. */ | |
| 98 | let sent = 0; | |
| 99 | const frame = new Uint8Array(16); | |
| 100 | 97 | |
| 101 | socket.onopen = (): void => void (live = true); | |
| 102 | socket.onclose = (): void => { | |
| 103 | live = false; | |
| 104 | closed = true; | |
| 105 | }; | |
| 106 | socket.onerror = (): void => void (live = false); | |
| 107 | socket.onmessage = (event): void => { | |
| 108 | const data = event.data; | |
| 109 | if (typeof data === "string") { | |
| 110 | const down = readDown(data); | |
| 111 | if (down === null) return; | |
| 98 | const member = createMember<World, Down, Omit<Pressed, "tick">, Snapshot>({ | |
| 99 | connect: options.connect, | |
| 100 | url: options.url, | |
| 101 | now: options.now, | |
| 102 | codec: { | |
| 103 | snapshot: SNAPSHOT_FRAME, | |
| 104 | inputBytes: INPUT_BUFFER, | |
| 105 | readText: readDown, | |
| 106 | // A world only ever exists with a seat, so `seat` is ours by the time a | |
| 107 | // snapshot is read. | |
| 108 | readSnapshot: (world, bytes) => applySnapshot(world, bytes, seat), | |
| 109 | writeInput: (pressed, tick, into) => writeInput({ ...pressed, tick }, into), | |
| 110 | }, | |
| 111 | hear(down, world) { | |
| 112 | 112 | if (down.k === "hello") { |
| 113 | 113 | seat = down.seat; |
| 114 | 114 | const built = options.build(down); |
| @@ -117,36 +117,35 @@ export function createSession(options: { | ||
| 117 | 117 | built.me = (built.players[seat] ?? built.me) as Player; |
| 118 | 118 | built.me.bot = false; |
| 119 | 119 | forget(built); |
| 120 | world = built; | |
| 121 | apply(down.roster); | |
| 122 | } else if (down.k === "roster") { | |
| 123 | apply(down.roster); | |
| 124 | } else if (world !== null) { | |
| 120 | apply(built, down.roster); | |
| 121 | return built; | |
| 122 | } | |
| 123 | if (world === null) return null; | |
| 124 | if (down.k === "roster") { | |
| 125 | apply(world, down.roster); | |
| 126 | } else { | |
| 125 | 127 | // The words of the round, which are written inside the server's own tick. |
| 126 | 128 | for (const entry of down.feed) world.feed.push({ ...entry, weapon: entry.weapon as never }); |
| 127 | 129 | for (const line of down.say) world.messages.push(line); |
| 128 | 130 | if (world.feed.length > 8) world.feed.splice(0, world.feed.length - 8); |
| 129 | 131 | if (world.messages.length > 8) world.messages.splice(0, world.messages.length - 8); |
| 130 | 132 | } |
| 131 | return; | |
| 132 | } | |
| 133 | if (world === null || seat < 0) return; | |
| 134 | const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data instanceof Uint8Array ? data : null; | |
| 135 | if (bytes === null || bytes[0] !== SNAPSHOT_FRAME) return; | |
| 136 | const got = applySnapshot(world, bytes, seat); | |
| 137 | if (got === null) return; | |
| 138 | landed = options.now(); | |
| 133 | return world; | |
| 134 | }, | |
| 139 | 135 | // Our own body: the server's position, handed over rather than written in |
| 140 | 136 | // (`wire.ts`), so this is the one place a page decides what to do with it. |
| 141 | if (got.own !== null) { | |
| 142 | world.me.pos = got.own.pos; | |
| 143 | world.me.vel = got.own.vel; | |
| 144 | } | |
| 145 | }; | |
| 137 | landed(got, world) { | |
| 138 | if (got.own !== null) { | |
| 139 | world.me.pos = got.own.pos; | |
| 140 | world.me.vel = got.own.vel; | |
| 141 | } | |
| 142 | }, | |
| 143 | sentences: { joining: CONNECTING, playing: PLAYING, behind: BEHIND, dropped: DROPPED }, | |
| 144 | behindMs: BEHIND_MS, | |
| 145 | }); | |
| 146 | 146 | |
| 147 | 147 | /** Names and bots off the roster, onto the bodies they belong to. */ |
| 148 | function apply(roster: Hello["roster"]): void { | |
| 149 | if (world === null) return; | |
| 148 | function apply(world: World, roster: Hello["roster"]): void { | |
| 150 | 149 | people = 0; |
| 151 | 150 | for (const seated of roster) { |
| 152 | 151 | const body = world.players[seated.seat]; |
| @@ -160,25 +159,12 @@ export function createSession(options: { | ||
| 160 | 159 | } |
| 161 | 160 | |
| 162 | 161 | return { |
| 163 | world: () => world, | |
| 162 | world: member.world, | |
| 164 | 163 | seat: () => seat, |
| 165 | standing(now) { | |
| 166 | const behind = landed < 0 ? -1 : now - landed; | |
| 167 | const line = | |
| 168 | closed ? DROPPED : | |
| 169 | !live || landed < 0 ? CONNECTING : | |
| 170 | behind > BEHIND_MS ? BEHIND : PLAYING; | |
| 171 | return { code: options.code, live, behind, line, people }; | |
| 172 | }, | |
| 173 | press(pressed) { | |
| 174 | if (!live) return; | |
| 175 | sent += 1; | |
| 176 | socket.send(writeInput({ ...pressed, tick: sent }, frame)); | |
| 177 | }, | |
| 178 | close() { | |
| 179 | closed = true; | |
| 180 | live = false; | |
| 181 | socket.close(); | |
| 182 | }, | |
| 164 | standing: (now) => ({ | |
| 165 | code: options.code, live: member.live(), behind: member.behind(now), line: member.line(now), people, | |
| 166 | }), | |
| 167 | press: member.press, | |
| 168 | close: member.close, | |
| 183 | 169 | }; |
| 184 | 170 | } |
src/engine/GUIDE.md +41
| @@ -793,6 +793,47 @@ a room — the query on a socket upgrade and the field in a menu — go through | ||
| 793 | 793 | neither trusting the other. A code is never a path and never reaches markup |
| 794 | 794 | unescaped. |
| 795 | 795 | |
| 796 | ### member | |
| 797 | ||
| 798 | **Weight**: 0.9 kB raw, 0.6 kB gzipped — this module alone. | |
| 799 | ||
| 800 | The other end of a room: a page's member of it. Every game played with | |
| 801 | somebody else writes the same half-page under its protocol — a socket asked for | |
| 802 | bytes, open, errored and closed, a count of the ticks it sent, a buffer it | |
| 803 | reuses for them, the time since the last frame landed, and a sentence for the | |
| 804 | player about all of that — and none of it knows what the game is, so it is | |
| 805 | here. `createMember` is handed a `MemberOptions` and hands back a `Member`. | |
| 806 | ||
| 807 | **The seam is the codec, and it is plain functions.** A `MemberCodec` is the | |
| 808 | game's wire: the byte a snapshot starts with, how big the input buffer is, a | |
| 809 | line of text into a control message, a snapshot's bytes into the world, and one | |
| 810 | tick of input written into the buffer. Beside it the game hands in what a | |
| 811 | control message does — `MemberOptions.hear` is given the message and the world | |
| 812 | so far and gives back the world to fill from then on, so a greeting builds one | |
| 813 | and the rest keep the one they had — and, optionally, what to do with whatever | |
| 814 | a snapshot handed back. The shooter's snapshot hands back its own body's | |
| 815 | position, which the page reconciles against rather than takes; the member | |
| 816 | passes it on untouched and decides nothing about it. `MemberOptions.connect` and the clock are | |
| 817 | handed in too, so the module touches no global and a test plays a room through | |
| 818 | a `MemberSocket` of plain objects. | |
| 819 | ||
| 820 | **The four sentences are the game's text under the engine's rule.** A | |
| 821 | `Sentences` is four strings — joining, playing, behind and dropped — and | |
| 822 | `MemberOptions.behindMs` is the threshold; neither has a default here, for the | |
| 823 | same reason the hub's caps do not. The rule is the engine's: dropped once the | |
| 824 | socket has closed, from either end, over everything else; joining until the | |
| 825 | socket is open and a snapshot has landed; behind when more than the threshold | |
| 826 | has passed since the last one — exactly the threshold is not; and playing | |
| 827 | otherwise. `Member.line` says it, and `Member.live`, `Member.closed`, | |
| 828 | `Member.behind` and `Member.sent` are the readings under it. | |
| 829 | ||
| 830 | **Every refusal is a drop, never a throw.** A line the codec does not read, a | |
| 831 | binary frame whose first byte is not the snapshot's, a snapshot while there is | |
| 832 | no world, a snapshot the codec refuses — which leaves the clock where it was — | |
| 833 | data that is neither a string, an array buffer nor a `Uint8Array`, anything the | |
| 834 | socket delivers after it closed, and a `Member.press` while it is not open, | |
| 835 | which is not counted as a tick either. | |
| 836 | ||
| 796 | 837 | ### renderer |
| 797 | 838 | |
| 798 | 839 | **Weight**: 9.1 kB raw, 3.7 kB gzipped — including math and pixels, which come with it. |
src/engine/index.ts +11
| @@ -121,6 +121,17 @@ export { Cursor, finite, packAngle, unpackAngle, unpackSigned } from "./frame.ts | ||
| 121 | 121 | export { createHub, newRoomCode, roomCodeFrom } from "./rooms.ts"; |
| 122 | 122 | export type { Hub, HubOptions, HubRoom, RoomGame, Wire } from "./rooms.ts"; |
| 123 | 123 | |
| 124 | /** | |
| 125 | * The other end of a room: a page's member of it. One socket asked for bytes, | |
| 126 | * its open, errored and closed lifecycle, the world it fills, the milliseconds | |
| 127 | * since the last snapshot landed, the ticks of input sent, and which of four | |
| 128 | * sentences the connection is. The game hands in its codec as plain functions, | |
| 129 | * the byte a snapshot starts with, what a control message does to its world, | |
| 130 | * and its own sentences and threshold — none of which has a default here. | |
| 131 | */ | |
| 132 | export { createMember } from "./member.ts"; | |
| 133 | export type { Member, MemberCodec, MemberOptions, MemberSocket, Sentences } from "./member.ts"; | |
| 134 | ||
| 124 | 135 | /** The WebGL2 draw: an atlas and named meshes in, a frame of instances out. */ |
| 125 | 136 | export { createRenderer } from "./renderer.ts"; |
| 126 | 137 | export type { Renderer, RendererAssets } from "./renderer.ts"; |
src/engine/member.test.ts added +306
| @@ -0,0 +1,306 @@ | ||
| 1 | /** | |
| 2 | * A room of a game that is not the shooter, played through `createMember` with | |
| 3 | * no browser: a counter of plain objects. Its hello names how many cells the | |
| 4 | * world has, a snapshot is the kind byte `SNAP` and one byte per cell, and a | |
| 5 | * tick of input is three bytes — the kind, the tick and the number pressed. | |
| 6 | * The socket is four callbacks and a `send` that keeps a copy, and the clock is | |
| 7 | * a number the test moves, the way `page.test.ts` and `rooms.test.ts` do it. | |
| 8 | * | |
| 9 | * The clock's arithmetic is written out where it is used: `behind` is | |
| 10 | * `now - landed` in milliseconds, and this game's `behindMs` is 100, so 100 is | |
| 11 | * still playing and 101 is behind. | |
| 12 | */ | |
| 13 | ||
| 14 | import { expect, test } from "bun:test"; | |
| 15 | import { createMember, type Member, type MemberSocket, type Sentences } from "./index.ts"; | |
| 16 | ||
| 17 | type Counter = { cells: number[]; names: string[]; greeted: number }; | |
| 18 | type Said = { k: "hello"; cells: number } | { k: "names"; names: string[] }; | |
| 19 | ||
| 20 | const SNAP = 7; | |
| 21 | const INPUT = 9; | |
| 22 | const BEHIND_MS = 100; | |
| 23 | const WORDS: Sentences = { joining: "wait", playing: "go", behind: "slow", dropped: "gone" }; | |
| 24 | ||
| 25 | /** The counter's text frames, read the way a game reads what it does not trust. */ | |
| 26 | function readText(text: string): Said | null { | |
| 27 | let v: unknown; | |
| 28 | try { | |
| 29 | v = JSON.parse(text); | |
| 30 | } catch { | |
| 31 | return null; | |
| 32 | } | |
| 33 | if (typeof v !== "object" || v === null) return null; | |
| 34 | const m = v as Record<string, unknown>; | |
| 35 | if (m.k === "hello" && Number.isInteger(m.cells) && (m.cells as number) > 0 && (m.cells as number) <= 8) | |
| 36 | return { k: "hello", cells: m.cells as number }; | |
| 37 | if (m.k === "names" && Array.isArray(m.names) && m.names.every((n) => typeof n === "string")) | |
| 38 | return { k: "names", names: m.names as string[] }; | |
| 39 | return null; | |
| 40 | } | |
| 41 | ||
| 42 | /** A snapshot is the kind and one byte a cell; anything shorter is refused whole. */ | |
| 43 | function readSnapshot(world: Counter, bytes: Uint8Array): { sum: number } | null { | |
| 44 | if (bytes.byteLength !== world.cells.length + 1) return null; | |
| 45 | let sum = 0; | |
| 46 | for (let i = 0; i < world.cells.length; i++) { | |
| 47 | world.cells[i] = bytes[i + 1] as number; | |
| 48 | sum += world.cells[i] as number; | |
| 49 | } | |
| 50 | return { sum }; | |
| 51 | } | |
| 52 | ||
| 53 | type Wire = { | |
| 54 | socket: MemberSocket; | |
| 55 | sent: Uint8Array[]; | |
| 56 | closes: number; | |
| 57 | urls: string[]; | |
| 58 | say(data: unknown): void; | |
| 59 | }; | |
| 60 | ||
| 61 | function wire(): Wire { | |
| 62 | const w: Wire = { | |
| 63 | socket: { | |
| 64 | binaryType: "", | |
| 65 | send: (data) => void w.sent.push(new Uint8Array(data)), | |
| 66 | close: () => void (w.closes += 1), | |
| 67 | onopen: null, onmessage: null, onclose: null, onerror: null, | |
| 68 | }, | |
| 69 | sent: [], | |
| 70 | closes: 0, | |
| 71 | urls: [], | |
| 72 | // Deliberately delivers after a close too: the member is what refuses. | |
| 73 | say: (data) => w.socket.onmessage?.({ data }), | |
| 74 | }; | |
| 75 | return w; | |
| 76 | } | |
| 77 | ||
| 78 | type Stage = Wire & { member: Member<Counter, number>; clock: number; heard: Said[]; sums: number[] }; | |
| 79 | ||
| 80 | function stage(): Stage { | |
| 81 | const w = wire(); | |
| 82 | const heard: Said[] = []; | |
| 83 | const sums: number[] = []; | |
| 84 | let clock = 1000; | |
| 85 | const member = createMember<Counter, Said, number, { sum: number }>({ | |
| 86 | connect: (url) => { | |
| 87 | w.urls.push(url); | |
| 88 | return w.socket; | |
| 89 | }, | |
| 90 | url: "/ws?room=ABCDEF", | |
| 91 | now: () => clock, | |
| 92 | codec: { | |
| 93 | snapshot: SNAP, | |
| 94 | inputBytes: 4, | |
| 95 | readText, | |
| 96 | readSnapshot, | |
| 97 | writeInput: (n, tick, into) => { | |
| 98 | into[0] = INPUT; | |
| 99 | into[1] = tick; | |
| 100 | into[2] = n; | |
| 101 | return into.subarray(0, 3); | |
| 102 | }, | |
| 103 | }, | |
| 104 | hear(said, world) { | |
| 105 | heard.push(said); | |
| 106 | if (said.k === "hello") | |
| 107 | return { cells: new Array(said.cells).fill(0), names: [], greeted: (world?.greeted ?? 0) + 1 }; | |
| 108 | if (world !== null) world.names = said.names; | |
| 109 | return world; | |
| 110 | }, | |
| 111 | landed: (got) => void sums.push(got.sum), | |
| 112 | sentences: WORDS, | |
| 113 | behindMs: BEHIND_MS, | |
| 114 | }); | |
| 115 | // Defined rather than assigned: `Object.assign` would read the getter once | |
| 116 | // and copy the number, and a test moving the clock would move nothing. | |
| 117 | return Object.defineProperties(w, { | |
| 118 | member: { value: member }, | |
| 119 | heard: { value: heard }, | |
| 120 | sums: { value: sums }, | |
| 121 | clock: { get: () => clock, set: (ms: number) => void (clock = ms) }, | |
| 122 | }) as Stage; | |
| 123 | } | |
| 124 | ||
| 125 | const open = (s: Stage): void => s.socket.onopen?.(); | |
| 126 | const hello = (cells = 3): string => JSON.stringify({ k: "hello", cells }); | |
| 127 | const snap = (...cells: number[]): Uint8Array => Uint8Array.from([SNAP, ...cells]); | |
| 128 | ||
| 129 | test("a member connects where it was told and asks for bytes", () => { | |
| 130 | const s = stage(); | |
| 131 | expect(s.urls).toEqual(["/ws?room=ABCDEF"]); | |
| 132 | expect(s.socket.binaryType).toBe("arraybuffer"); | |
| 133 | expect(s.member.world()).toBeNull(); | |
| 134 | expect(s.member.live()).toBe(false); | |
| 135 | expect(s.member.closed()).toBe(false); | |
| 136 | expect(s.member.behind(1000)).toBe(-1); | |
| 137 | expect(s.member.sent()).toBe(0); | |
| 138 | expect(s.member.line(1000)).toBe(WORDS.joining); | |
| 139 | }); | |
| 140 | ||
| 141 | test("a whole room of a counter: a hello, the names, snapshots, and a drop", () => { | |
| 142 | const s = stage(); | |
| 143 | open(s); | |
| 144 | expect(s.member.live()).toBe(true); | |
| 145 | // Names before a hello reach the game with no world, and the game keeps none. | |
| 146 | s.say(JSON.stringify({ k: "names", names: ["early"] })); | |
| 147 | expect(s.member.world()).toBeNull(); | |
| 148 | s.say(hello(3)); | |
| 149 | const world = s.member.world() as Counter; | |
| 150 | expect(world.cells).toEqual([0, 0, 0]); | |
| 151 | // A hello is not a snapshot: still joining. | |
| 152 | expect(s.member.line(1000)).toBe(WORDS.joining); | |
| 153 | s.say(JSON.stringify({ k: "names", names: ["ada", "bo"] })); | |
| 154 | expect(world.names).toEqual(["ada", "bo"]); | |
| 155 | ||
| 156 | s.clock = 2000; | |
| 157 | s.say(snap(1, 2, 3)); | |
| 158 | expect(world.cells).toEqual([1, 2, 3]); | |
| 159 | // What the codec handed back is the game's, passed on untouched. | |
| 160 | expect(s.sums).toEqual([6]); | |
| 161 | expect(s.member.behind(2000)).toBe(0); | |
| 162 | expect(s.member.line(2000)).toBe(WORDS.playing); | |
| 163 | expect(s.member.line(2000 + BEHIND_MS)).toBe(WORDS.playing); | |
| 164 | expect(s.member.behind(2000 + BEHIND_MS + 1)).toBe(101); | |
| 165 | expect(s.member.line(2000 + BEHIND_MS + 1)).toBe(WORDS.behind); | |
| 166 | ||
| 167 | // An ArrayBuffer is the same frame, because a browser hands over one. | |
| 168 | s.clock = 2500; | |
| 169 | s.say(snap(4, 5, 6).slice().buffer); | |
| 170 | expect(world.cells).toEqual([4, 5, 6]); | |
| 171 | expect(s.member.line(2500)).toBe(WORDS.playing); | |
| 172 | ||
| 173 | s.member.press(1); | |
| 174 | s.member.press(2); | |
| 175 | expect(s.sent.map((b) => [...b])).toEqual([[INPUT, 1, 1], [INPUT, 2, 2]]); | |
| 176 | expect(s.member.sent()).toBe(2); | |
| 177 | ||
| 178 | // The room hung up: the last sentence wins over every other, for good. | |
| 179 | s.socket.onclose?.(); | |
| 180 | expect(s.member.live()).toBe(false); | |
| 181 | expect(s.member.closed()).toBe(true); | |
| 182 | expect(s.member.line(2500)).toBe(WORDS.dropped); | |
| 183 | s.socket.onopen?.(); | |
| 184 | expect(s.member.live()).toBe(false); | |
| 185 | expect(s.member.line(2500)).toBe(WORDS.dropped); | |
| 186 | }); | |
| 187 | ||
| 188 | test("a second hello is the game's to decide, and it rebuilds here", () => { | |
| 189 | const s = stage(); | |
| 190 | open(s); | |
| 191 | s.say(hello(2)); | |
| 192 | s.say(hello(4)); | |
| 193 | expect((s.member.world() as Counter).cells).toHaveLength(4); | |
| 194 | expect((s.member.world() as Counter).greeted).toBe(2); | |
| 195 | }); | |
| 196 | ||
| 197 | /** A seated member with one snapshot landed at 2000, for the refusals to spoil. */ | |
| 198 | function seated(): { s: Stage; world: Counter; was: string } { | |
| 199 | const s = stage(); | |
| 200 | open(s); | |
| 201 | s.say(hello(3)); | |
| 202 | s.clock = 2000; | |
| 203 | s.say(snap(1, 2, 3)); | |
| 204 | const world = s.member.world() as Counter; | |
| 205 | return { s, world, was: JSON.stringify(world) }; | |
| 206 | } | |
| 207 | ||
| 208 | test("a text frame the codec does not read changes nothing", () => { | |
| 209 | const { s, world, was } = seated(); | |
| 210 | const heard = s.heard.length; | |
| 211 | for (const text of ["", "{not json", "null", "[1]", JSON.stringify({ k: "hello", cells: 99 })]) | |
| 212 | expect(() => s.say(text)).not.toThrow(); | |
| 213 | expect(s.heard).toHaveLength(heard); | |
| 214 | expect(s.member.world()).toBe(world); | |
| 215 | expect(JSON.stringify(world)).toBe(was); | |
| 216 | }); | |
| 217 | ||
| 218 | test("a binary frame whose first byte is not the snapshot kind changes nothing", () => { | |
| 219 | const { s, world, was } = seated(); | |
| 220 | s.clock = 3000; | |
| 221 | expect(() => s.say(Uint8Array.from([SNAP + 1, 9, 9, 9]))).not.toThrow(); | |
| 222 | expect(() => s.say(new Uint8Array(0))).not.toThrow(); | |
| 223 | expect(JSON.stringify(world)).toBe(was); | |
| 224 | expect(s.sums).toEqual([6]); | |
| 225 | expect(s.member.behind(3000)).toBe(1000); | |
| 226 | }); | |
| 227 | ||
| 228 | test("a snapshot the codec refuses changes nothing and does not stamp the clock", () => { | |
| 229 | const { s, world, was } = seated(); | |
| 230 | s.clock = 3000; | |
| 231 | s.say(snap(9, 9)); | |
| 232 | expect(JSON.stringify(world)).toBe(was); | |
| 233 | expect(s.member.behind(3000)).toBe(1000); | |
| 234 | expect(s.member.line(3000)).toBe(WORDS.behind); | |
| 235 | }); | |
| 236 | ||
| 237 | test("a frame before a hello is dropped, and the one after it is not", () => { | |
| 238 | const s = stage(); | |
| 239 | open(s); | |
| 240 | expect(() => s.say(snap(1, 2, 3))).not.toThrow(); | |
| 241 | expect(s.member.world()).toBeNull(); | |
| 242 | expect(s.sums).toEqual([]); | |
| 243 | expect(s.member.behind(1000)).toBe(-1); | |
| 244 | s.say(hello(3)); | |
| 245 | s.say(snap(1, 2, 3)); | |
| 246 | expect(s.sums).toEqual([6]); | |
| 247 | }); | |
| 248 | ||
| 249 | test("a frame when the game kept no world is dropped", () => { | |
| 250 | const s = stage(); | |
| 251 | open(s); | |
| 252 | // Names with no hello: the game hands back null, so there is still no world. | |
| 253 | s.say(JSON.stringify({ k: "names", names: ["x"] })); | |
| 254 | s.say(snap(1)); | |
| 255 | expect(s.sums).toEqual([]); | |
| 256 | expect(s.member.line(1000)).toBe(WORDS.joining); | |
| 257 | }); | |
| 258 | ||
| 259 | test("data that is neither a string, an ArrayBuffer nor a Uint8Array is dropped", () => { | |
| 260 | const { s, world, was } = seated(); | |
| 261 | for (const data of [42, null, undefined, true, {}, [SNAP, 1, 2, 3], new Date(0), new Uint16Array([SNAP, 1, 2])]) | |
| 262 | expect(() => s.say(data)).not.toThrow(); | |
| 263 | expect(JSON.stringify(world)).toBe(was); | |
| 264 | expect(s.sums).toEqual([6]); | |
| 265 | }); | |
| 266 | ||
| 267 | test("nothing a socket delivers after close reaches the world", () => { | |
| 268 | const { s, world, was } = seated(); | |
| 269 | s.member.close(); | |
| 270 | expect(s.closes).toBe(1); | |
| 271 | expect(s.member.closed()).toBe(true); | |
| 272 | expect(s.member.line(2000)).toBe(WORDS.dropped); | |
| 273 | s.say(snap(7, 7, 7)); | |
| 274 | s.say(hello(5)); | |
| 275 | expect(s.member.world()).toBe(world); | |
| 276 | expect(JSON.stringify(world)).toBe(was); | |
| 277 | }); | |
| 278 | ||
| 279 | test("a press while the socket is not open goes nowhere and is not counted", () => { | |
| 280 | const s = stage(); | |
| 281 | s.member.press(5); | |
| 282 | expect(s.sent).toEqual([]); | |
| 283 | open(s); | |
| 284 | s.member.press(5); | |
| 285 | expect(s.sent.map((b) => [...b])).toEqual([[INPUT, 1, 5]]); | |
| 286 | // An error is a connection that is no longer live, though not a closed one. | |
| 287 | s.socket.onerror?.(); | |
| 288 | expect(s.member.live()).toBe(false); | |
| 289 | expect(s.member.closed()).toBe(false); | |
| 290 | s.member.press(6); | |
| 291 | s.member.close(); | |
| 292 | s.member.press(7); | |
| 293 | expect(s.sent).toHaveLength(1); | |
| 294 | expect(s.member.sent()).toBe(1); | |
| 295 | }); | |
| 296 | ||
| 297 | test("one buffer is reused for every tick, at the size the game asked for", () => { | |
| 298 | const s = stage(); | |
| 299 | open(s); | |
| 300 | const seen: ArrayBufferLike[] = []; | |
| 301 | s.socket.send = (data) => void seen.push(data.buffer); | |
| 302 | s.member.press(1); | |
| 303 | s.member.press(2); | |
| 304 | expect(seen[0]).toBe(seen[1] as ArrayBufferLike); | |
| 305 | expect(seen[0]?.byteLength).toBe(4); | |
| 306 | }); |
src/engine/member.ts added +168
| @@ -0,0 +1,168 @@ | ||
| 1 | /** | |
| 2 | * The page's end of a room of *a* game: one socket, the world it fills, a clock | |
| 3 | * since the last frame landed, a count of ticks sent, and the sentence a page | |
| 4 | * shows about the connection. The other end is `rooms.ts`. | |
| 5 | * | |
| 6 | * Nothing in here knows a world, a seat, a hello or a snapshot's layout. The | |
| 7 | * game hands in its codec as plain functions — a line of text into a control | |
| 8 | * message, a snapshot's bytes into its world, one tick of input into bytes — | |
| 9 | * and says which first byte a snapshot starts with. What a control message | |
| 10 | * does is the game's too: `hear` is handed the message and the world so far and | |
| 11 | * hands back the world the member fills from then on, so a greeting builds one | |
| 12 | * and everything else keeps the one it had. What a snapshot hands back — the | |
| 13 | * shooter's own body, which its page reconciles against rather than takes — is | |
| 14 | * passed to `landed` untouched: the member decides nothing about it. | |
| 15 | * | |
| 16 | * **The sentences and the threshold are the game's text under the engine's | |
| 17 | * rule.** There is no default: a game hands in its four `Sentences` and the | |
| 18 | * milliseconds `behindMs` past which the connection is behind, and this file | |
| 19 | * decides only which one is said — dropped once closed, joining until the | |
| 20 | * socket is open and a frame has landed, behind once more than `behindMs` has | |
| 21 | * passed since the last one, and in the room otherwise. `member.test.ts` reads | |
| 22 | * that rule with a fake game's words. | |
| 23 | * | |
| 24 | * Every refusal is here, and none of them throws: a line the codec does not | |
| 25 | * read, a binary frame whose first byte is not the snapshot kind, a snapshot | |
| 26 | * before there is a world, a snapshot the codec refuses (which also leaves the | |
| 27 | * clock where it was), data that is neither a string, an `ArrayBuffer` nor a | |
| 28 | * `Uint8Array`, anything at all after the socket closed, and a `press` while | |
| 29 | * the socket is not open — which is not counted as a tick either. | |
| 30 | * | |
| 31 | * Nothing in here is a browser: `connect` is handed in and so is the clock, so | |
| 32 | * a whole room plays through a socket of plain objects on a number a test moves. | |
| 33 | */ | |
| 34 | ||
| 35 | /** All a member asks a WebSocket to be. A browser's is one of these. */ | |
| 36 | export type MemberSocket = { | |
| 37 | binaryType: string; | |
| 38 | send(data: Uint8Array): void; | |
| 39 | close(): void; | |
| 40 | onopen: (() => void) | null; | |
| 41 | onmessage: ((event: { data: unknown }) => void) | null; | |
| 42 | onclose: (() => void) | null; | |
| 43 | onerror: (() => void) | null; | |
| 44 | }; | |
| 45 | ||
| 46 | /** The four things a page can say about its connection, in the game's words. */ | |
| 47 | export type Sentences = { | |
| 48 | /** The socket is not open yet, or no frame has landed since it was. */ | |
| 49 | joining: string; | |
| 50 | /** Frames are landing. */ | |
| 51 | playing: string; | |
| 52 | /** The last frame landed more than `behindMs` ago. */ | |
| 53 | behind: string; | |
| 54 | /** The socket closed, from either end. Said over every other. */ | |
| 55 | dropped: string; | |
| 56 | }; | |
| 57 | ||
| 58 | /** | |
| 59 | * The game's wire, as plain functions. `W` is its world, `M` a control | |
| 60 | * message, `P` one tick of input without its tick, `R` what a snapshot hands | |
| 61 | * back to the page. | |
| 62 | */ | |
| 63 | export type MemberCodec<W, M, P, R> = { | |
| 64 | /** The first byte of a snapshot. A binary frame that starts otherwise is dropped. */ | |
| 65 | snapshot: number; | |
| 66 | /** How many bytes the one reused input buffer is. */ | |
| 67 | inputBytes: number; | |
| 68 | /** A text frame into a control message, or null when it is not one. */ | |
| 69 | readText(text: string): M | null; | |
| 70 | /** A snapshot's bytes into the world, or null — changing nothing — when they are not one. */ | |
| 71 | readSnapshot(world: W, bytes: Uint8Array): R | null; | |
| 72 | /** One tick of input written into `into`, stamped `tick`: the bytes to send. */ | |
| 73 | writeInput(pressed: P, tick: number, into: Uint8Array<ArrayBuffer>): Uint8Array; | |
| 74 | }; | |
| 75 | ||
| 76 | export type MemberOptions<W, M, P, R> = { | |
| 77 | connect(url: string): MemberSocket; | |
| 78 | url: string; | |
| 79 | /** Milliseconds on any clock that only moves forward: `performance.now()` in a page. */ | |
| 80 | now(): number; | |
| 81 | codec: MemberCodec<W, M, P, R>; | |
| 82 | /** A control message arrived: the world to fill from now on, given the one so far (null before). */ | |
| 83 | hear(message: M, world: W | null): W | null; | |
| 84 | /** A snapshot was read into the world: what the codec handed back, for the game to use. */ | |
| 85 | landed?(got: R, world: W): void; | |
| 86 | sentences: Sentences; | |
| 87 | /** How long since the last frame before the connection is behind, in ms. Exactly this much is not. */ | |
| 88 | behindMs: number; | |
| 89 | }; | |
| 90 | ||
| 91 | export type Member<W, P> = { | |
| 92 | /** The world the frames are filling, or null until a control message builds one. */ | |
| 93 | world(): W | null; | |
| 94 | /** Whether the socket is open right now. */ | |
| 95 | live(): boolean; | |
| 96 | /** Whether the socket has closed, from either end. It does not reopen. */ | |
| 97 | closed(): boolean; | |
| 98 | /** Milliseconds since the last snapshot landed, or −1 before the first. */ | |
| 99 | behind(now: number): number; | |
| 100 | /** How many ticks of input have gone upstream: the last one's stamp. */ | |
| 101 | sent(): number; | |
| 102 | /** The one sentence for the connection at `now`. */ | |
| 103 | line(now: number): string; | |
| 104 | /** One tick of input upstream. Dropped, and not counted, while the socket is not open. */ | |
| 105 | press(pressed: P): void; | |
| 106 | close(): void; | |
| 107 | }; | |
| 108 | ||
| 109 | export function createMember<W, M, P, R>(options: MemberOptions<W, M, P, R>): Member<W, P> { | |
| 110 | const { codec, sentences } = options; | |
| 111 | const socket = options.connect(options.url); | |
| 112 | // Without this a browser hands `onmessage` a Blob, which nothing here reads. | |
| 113 | socket.binaryType = "arraybuffer"; | |
| 114 | let world: W | null = null; | |
| 115 | let live = false; | |
| 116 | let closed = false; | |
| 117 | let landed = -1; | |
| 118 | let sent = 0; | |
| 119 | const frame = new Uint8Array(codec.inputBytes); | |
| 120 | ||
| 121 | socket.onopen = (): void => void (live = !closed); | |
| 122 | socket.onclose = (): void => { | |
| 123 | live = false; | |
| 124 | closed = true; | |
| 125 | }; | |
| 126 | socket.onerror = (): void => void (live = false); | |
| 127 | socket.onmessage = (event): void => { | |
| 128 | if (closed) return; | |
| 129 | const data = event.data; | |
| 130 | if (typeof data === "string") { | |
| 131 | const message = codec.readText(data); | |
| 132 | if (message !== null) world = options.hear(message, world); | |
| 133 | return; | |
| 134 | } | |
| 135 | if (world === null) return; | |
| 136 | const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data instanceof Uint8Array ? data : null; | |
| 137 | if (bytes === null || bytes[0] !== codec.snapshot) return; | |
| 138 | const got = codec.readSnapshot(world, bytes); | |
| 139 | if (got === null) return; | |
| 140 | landed = options.now(); | |
| 141 | options.landed?.(got, world); | |
| 142 | }; | |
| 143 | ||
| 144 | const behind = (now: number): number => (landed < 0 ? -1 : now - landed); | |
| 145 | ||
| 146 | return { | |
| 147 | world: () => world, | |
| 148 | live: () => live, | |
| 149 | closed: () => closed, | |
| 150 | behind, | |
| 151 | sent: () => sent, | |
| 152 | line(now) { | |
| 153 | if (closed) return sentences.dropped; | |
| 154 | if (!live || landed < 0) return sentences.joining; | |
| 155 | return behind(now) > options.behindMs ? sentences.behind : sentences.playing; | |
| 156 | }, | |
| 157 | press(pressed) { | |
| 158 | if (!live) return; | |
| 159 | sent += 1; | |
| 160 | socket.send(codec.writeInput(pressed, sent, frame)); | |
| 161 | }, | |
| 162 | close() { | |
| 163 | closed = true; | |
| 164 | live = false; | |
| 165 | socket.close(); | |
| 166 | }, | |
| 167 | }; | |
| 168 | } |
src/engine/surface.json +7 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | { |
| 2 | "version": "0.9.0", | |
| 2 | "version": "0.10.0", | |
| 3 | 3 | "names": [ |
| 4 | 4 | "Activations", |
| 5 | 5 | "Adam", |
| @@ -54,6 +54,10 @@ | ||
| 54 | 54 | "LoopOptions", |
| 55 | 55 | "MAX_RATIO", |
| 56 | 56 | "Mat4", |
| 57 | "Member", | |
| 58 | "MemberCodec", | |
| 59 | "MemberOptions", | |
| 60 | "MemberSocket", | |
| 57 | 61 | "Mesh", |
| 58 | 62 | "MouseEventish", |
| 59 | 63 | "MoveTuning", |
| @@ -74,6 +78,7 @@ | ||
| 74 | 78 | "Scene", |
| 75 | 79 | "SceneCapacity", |
| 76 | 80 | "Screen", |
| 81 | "Sentences", | |
| 77 | 82 | "Shader", |
| 78 | 83 | "Shadow", |
| 79 | 84 | "Shape", |
| @@ -115,6 +120,7 @@ | ||
| 115 | 120 | "createFullscreen", |
| 116 | 121 | "createHub", |
| 117 | 122 | "createLoop", |
| 123 | "createMember", | |
| 118 | 124 | "createPolicy", |
| 119 | 125 | "createRenderer", |
| 120 | 126 | "createRollout", |
new above
offline