Public: anyone can read this; the crew can change it.
forked from trevato/cs
The hub becomes the engine's: a room of any game is opened, stepped, swept and capped by one module behind the door #353
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/321-the-hub-becomes-the-engine-s-a-room-of-a → main 1 commit fork
14 files
changed +802
−260
README.md +19 −2
| @@ -703,7 +703,14 @@ never checked itself. | ||
| 703 | 703 | `action`, and both stay with the game) and `digest` (a run folded into a |
| 704 | 704 | hash: the quantiser, the fold with a checkpoint every so many ticks, the pin |
| 705 | 705 | and the complaint that says which span two runs parted at — the tool both |
| 706 | games pin themselves with, and the one no browser loads). | |
| 706 | games pin themselves with, and the one no browser loads) | |
| 707 | and `rooms` (a game played with somebody: the hub that opens rooms, steps | |
| 708 | them all on one clock, sends each member their snapshot, sweeps what is | |
| 709 | idle and caps what one process holds, and the six readable characters a | |
| 710 | room is named by — the room type, the options it is opened with and every | |
| 711 | cap arrive as arguments, so it knows no world, no map and no seat, and it | |
| 712 | is the only group behind the door that runs on a server rather than in a | |
| 713 | browser). | |
| 707 | 714 | Five rules keep it a package, and a test holds each: the engine imports |
| 708 | 715 | nothing outside itself, it needs nothing from a browser to load, everything |
| 709 | 716 | outside it comes in through one door, the version of a pack moves when that |
| @@ -752,7 +759,10 @@ never checked itself. | ||
| 752 | 759 | `Effects`; from `renderer` `createRenderer` with `Renderer` and |
| 753 | 760 | `RendererAssets`, which draws that frame; and from `digest` `QUANTUM`, |
| 754 | 761 | `quantise`, `CHECKPOINT_CHARS`, `fold`, `compare`, `Fold`, `Folded`, `Pin` |
| 755 | and `Complaint`, which both games' pinned runs are folded by. | |
| 762 | and `Complaint`, which both games' pinned runs are folded by; and from | |
| 763 | `rooms` `createHub`, `newRoomCode` and `roomCodeFrom` with `Hub`, | |
| 764 | `HubRoom`, `RoomGame`, `HubOptions` and the `Wire` a hub delivers through, | |
| 765 | which is what the app's own rooms are held by. | |
| 756 | 766 | `boundary.test.ts` fails a deep import from outside, a |
| 757 | 767 | module that is not behind the door, and any change to that list of names that |
| 758 | 768 | is not also a change to the list written out in the test. One exception, and |
| @@ -1583,6 +1593,13 @@ times a second (`src/rooms/wire.ts`), so nobody in a room can disagree about wha | ||
| 1583 | 1593 | happened. Joining takes a bot's seat, so a round is always five against five, and |
| 1584 | 1594 | leaving hands the body straight back to the bot with the money and guns it had. |
| 1585 | 1595 | |
| 1596 | The hub under that — the rooms a process holds, the one clock that steps | |
| 1597 | them, the snapshot cadence, the idle sweep and the caps — is the engine's: | |
| 1598 | `createHub` behind `src/engine/index.ts`, handed a table of the functions to | |
| 1599 | call on a room and every cap as an argument. `src/rooms/hub.ts` is what is | |
| 1600 | left of it here, which is this game's table and this game's numbers with | |
| 1601 | the measurements that argue for them. | |
| 1602 | ||
| 1586 | 1603 | What is real and what is not, today: |
| 1587 | 1604 | |
| 1588 | 1605 | - Real: shared rounds, shots, damage, deaths, the bomb, the kill feed, the |
src/client/main.ts +1 −2
| @@ -103,10 +103,9 @@ import { buy, canBuy, priceOf, type BuyItem } from "./economy.ts"; | ||
| 103 | 103 | import { atRound, createLoadout, noteBuy, rebuy, saidRebuy } from "./loadout.ts"; |
| 104 | 104 | import { createWorld, eyePos, say as showMessage, update, type Player, type World } from "./world.ts"; |
| 105 | 105 | import { createSession, type Session, type Socket } from "./session.ts"; |
| 106 | import { roomCodeFrom } from "../rooms/code.ts"; | |
| 107 | 106 | import { SWITCH_KNIFE, SWITCH_NONE, SWITCH_PRIMARY, SWITCH_SECONDARY } from "../rooms/wire.ts"; |
| 108 | 107 | import type { BuyItem as Item } from "./economy.ts"; |
| 109 | import { unpack } from "../engine/index.ts"; | |
| 108 | import { roomCodeFrom, unpack } from "../engine/index.ts"; | |
| 110 | 109 | |
| 111 | 110 | const TICK = 1 / 64; |
| 112 | 111 |
src/engine/GUIDE.md +57
| @@ -736,6 +736,63 @@ what an input frame or a snapshot even contains — is `src/rooms/wire.ts` for t | ||
| 736 | 736 | shooter, a file that now defines no byte plumbing of its own and holds only the |
| 737 | 737 | part that is this game's. |
| 738 | 738 | |
| 739 | ### rooms | |
| 740 | ||
| 741 | **Weight**: 2.1 kB raw, 1.1 kB gzipped — this module alone. | |
| 742 | ||
| 743 | A game played with somebody else, on a server: the rooms one process is | |
| 744 | holding, the one clock that steps them all, and the six characters a person | |
| 745 | reads a room out loud by. It is the only part of the door a browser never runs | |
| 746 | — a game imports it from the process that serves it, not from the bundle it | |
| 747 | ships — and the reason it is behind the door at all is that the alternative is a | |
| 748 | second copy of it in the second game that wants to be played with a friend. | |
| 749 | ||
| 750 | **The hub is generic in the room.** `createHub` takes three things: a `Wire`, a | |
| 751 | `RoomGame`, and a `HubOptions`. The `Wire` is a connection reduced to two | |
| 752 | functions — deliver these bytes or this line of text to that member, close that | |
| 753 | member with a code and a sentence — so nothing in here is a socket, and a test | |
| 754 | hands over a wire that remembers instead of sending. The `RoomGame` is the table | |
| 755 | of everything the hub calls on a room: create one, sit somebody down, stand them | |
| 756 | up, drive a frame into it, step it, write a member their snapshot, say what one | |
| 757 | member is told on arrival and who else is in the room, say what has been said | |
| 758 | since the last beat, say whether it is idle, and describe it in a line for a | |
| 759 | terminal — plus the two rates, the seconds in a step and the snapshots a second. | |
| 760 | The room type is yours; the type the hub needs it to be is `HubRoom`, which is | |
| 761 | three fields — the code, the members by id, and when somebody was last in it — | |
| 762 | and the options a room is opened with are opaque, so a map name, a seed and a | |
| 763 | round length travel from the caller of `Hub.open` to your own create without the | |
| 764 | engine reading one of them. What comes back is a `Hub`: the rooms by code, the | |
| 765 | four calls a server makes on it when a connection opens, speaks, goes quiet or | |
| 766 | closes, the clock below, and two readings — how many members this process is | |
| 767 | holding, and a line for each room. | |
| 768 | ||
| 769 | **Every number is yours too.** `HubOptions` carries the room cap, the idle and | |
| 770 | silent times, the biggest frame a member may send, the catch-up clamp, the three | |
| 771 | close codes and the limiter that says how often one connection may speak — and | |
| 772 | not one of them has a default here. A cap is a measurement of what a room of | |
| 773 | *your* game costs a box, and the engine has measured nothing: the shooter's own | |
| 774 | live in `src/rooms/hub.ts` next to the milliseconds of CPU that argue for them. | |
| 775 | ||
| 776 | **What the clock does.** `Hub.advance` is called with the wall clock — from one | |
| 777 | interval for the whole process, not one per room — and steps every room by the | |
| 778 | steps that have fallen due since the last call, at most the clamp's worth, so a | |
| 779 | process that was descheduled for a second does not then burn a second catching | |
| 780 | up. Then the rooms whose turn it is send each member a snapshot and, when there | |
| 781 | is one, the line of words since the last beat; then a member nobody has heard | |
| 782 | from inside the silent time is closed, because the seat they are holding belongs | |
| 783 | to somebody who is here; then a room nobody is in is dropped. Rooms live in | |
| 784 | memory and die with the process: nothing in here reads or writes a file. | |
| 785 | ||
| 786 | **And a room has a name a person can say.** `newRoomCode` draws six characters | |
| 787 | from an alphabet with no O or 0, no I, l or 1 and no S against 5, out of a | |
| 788 | source of random bytes you may hand in — so a test can choose a code, and | |
| 789 | importing this module reaches for no global. `roomCodeFrom` is the other | |
| 790 | direction and is a boundary rather than a convenience: it upper-cases what a | |
| 791 | person typed and answers null for anything that is not a code, and both ends of | |
| 792 | a room — the query on a socket upgrade and the field in a menu — go through it, | |
| 793 | neither trusting the other. A code is never a path and never reaches markup | |
| 794 | unescaped. | |
| 795 | ||
| 739 | 796 | ### renderer |
| 740 | 797 | |
| 741 | 798 | **Weight**: 9.1 kB raw, 3.7 kB gzipped — including math and pixels, which come with it. |
src/engine/index.ts +14
| @@ -107,6 +107,20 @@ export type { Effects } from "./effects.ts"; | ||
| 107 | 107 | */ |
| 108 | 108 | export { Cursor, finite, packAngle, unpackAngle, unpackSigned } from "./frame.ts"; |
| 109 | 109 | |
| 110 | /** | |
| 111 | * A room of a game played with somebody: the hub that opens rooms, steps them | |
| 112 | * all on one clock, sends each member their snapshot, sweeps what is idle and | |
| 113 | * caps what one process holds — and the six readable characters a room is named | |
| 114 | * by, validated on the way in wherever a code arrives. The game arrives as a | |
| 115 | * `RoomGame`: the functions the hub calls on a room, whose type is the game's | |
| 116 | * own and whose options are opaque here. Every number is the game's too, in | |
| 117 | * `HubOptions` and with no default — a cap is a measurement, and the engine has | |
| 118 | * measured nothing. Nothing in here is a socket: a `Wire` is two functions, and | |
| 119 | * what one really is stays in the server that made it. | |
| 120 | */ | |
| 121 | export { createHub, newRoomCode, roomCodeFrom } from "./rooms.ts"; | |
| 122 | export type { Hub, HubOptions, HubRoom, RoomGame, Wire } from "./rooms.ts"; | |
| 123 | ||
| 110 | 124 | /** The WebGL2 draw: an atlas and named meshes in, a frame of instances out. */ |
| 111 | 125 | export { createRenderer } from "./renderer.ts"; |
| 112 | 126 | export type { Renderer, RendererAssets } from "./renderer.ts"; |
src/engine/rooms.test.ts added +296
| @@ -0,0 +1,296 @@ | ||
| 1 | /** | |
| 2 | * The hub under a game that is not the shooter: a counter with seats, twenty | |
| 3 | * lines of it, played through the same `createHub` the app's rooms run on. | |
| 4 | * | |
| 5 | * That is the whole point of the file. The hub moved behind the door because a | |
| 6 | * second game cannot be played with somebody without a second copy of it, and | |
| 7 | * the proof that it moved *far enough* is that this file names no world, no map | |
| 8 | * and no weapon — it imports `bun:test` and the module under it, and nothing | |
| 9 | * else, which `boundary.test.ts` refuses to let it do anyway. Everything the | |
| 10 | * hub actually does is read off that counter: the room cap refusing politely, | |
| 11 | * an empty room collected on the sweep, a member nobody has heard from losing | |
| 12 | * the seat they hold, a frame over the ceiling and a flood of frames both | |
| 13 | * closing the connection with the game's own codes, the catch-up clamp on a | |
| 14 | * process that was descheduled, and one clock stepping two rooms that never | |
| 15 | * hear each other. | |
| 16 | * | |
| 17 | * The numbers below are this fake game's and are chosen to be read rather than | |
| 18 | * measured: ten steps a second, four snapshots a second, three rooms, two | |
| 19 | * seats. The shooter's own — `ROOM_CAP`, `IDLE_MS` and the rest — stay in | |
| 20 | * `src/rooms/hub.ts` with the measurements that argue for them, and | |
| 21 | * `src/rooms/hub.test.ts` is where they are played by the game they belong to. | |
| 22 | */ | |
| 23 | ||
| 24 | import { expect, test } from "bun:test"; | |
| 25 | import { | |
| 26 | ALPHABET, createHub, isRoomCode, LENGTH, newRoomCode, roomCodeFrom, | |
| 27 | type HubOptions, type RoomGame, type Wire, | |
| 28 | } from "./rooms.ts"; | |
| 29 | ||
| 30 | // --- a whole game, for a hub to hold --------------------------------------- | |
| 31 | ||
| 32 | /** One person at this game: an id the wire delivers to, and the seat they took. */ | |
| 33 | type Seat = { id: string; seat: number }; | |
| 34 | ||
| 35 | /** A room of it: the three fields a hub reads, a count, and what it was told. */ | |
| 36 | type Counter = { | |
| 37 | code: string; | |
| 38 | members: Map<string, Seat>; | |
| 39 | usedAt: number; | |
| 40 | seats: number; | |
| 41 | steps: number; | |
| 42 | /** The first byte of every frame driven into this room, in order. */ | |
| 43 | driven: number[]; | |
| 44 | }; | |
| 45 | ||
| 46 | /** What a room of it is opened with. Opaque to the hub, which passes it on. */ | |
| 47 | type Options = { seats: number }; | |
| 48 | ||
| 49 | const counter: RoomGame<Counter, Seat, Options> = { | |
| 50 | tick: 1 / 10, | |
| 51 | snapshotHz: 4, | |
| 52 | create: (code, o, now) => ({ code, members: new Map(), usedAt: now, seats: o.seats, steps: 0, driven: [] }), | |
| 53 | join: (room, id, _name, now) => { | |
| 54 | if (room.members.size >= room.seats) return null; | |
| 55 | const seat = { id, seat: room.members.size }; | |
| 56 | room.members.set(id, seat); | |
| 57 | room.usedAt = now; | |
| 58 | return seat; | |
| 59 | }, | |
| 60 | leave: (room, id, now) => { | |
| 61 | const seat = room.members.get(id) ?? null; | |
| 62 | room.members.delete(id); | |
| 63 | room.usedAt = now; | |
| 64 | return seat; | |
| 65 | }, | |
| 66 | drive: (room, id, bytes) => room.members.has(id) && room.driven.push(bytes[0] as number) > 0, | |
| 67 | step: (room) => { room.steps += 1; }, | |
| 68 | snapshot: (room, member) => new Uint8Array([room.steps & 0xff, member.seat]), | |
| 69 | hello: (room, member) => ({ k: "hello", code: room.code, seat: member.seat }), | |
| 70 | roster: (room) => ({ k: "roster", seats: [...room.members.values()].map((m) => m.seat) }), | |
| 71 | words: (room) => (room.steps % 5 === 0 ? { k: "log", steps: room.steps } : null), | |
| 72 | idle: (room, now, idle) => room.members.size === 0 && now - room.usedAt > idle, | |
| 73 | describe: (room, now) => `${room.code} ${room.members.size}/${room.seats} at ${now}`, | |
| 74 | }; | |
| 75 | ||
| 76 | // --- the stage -------------------------------------------------------------- | |
| 77 | ||
| 78 | type SpyWire = Wire & { | |
| 79 | sent: Map<string, (Uint8Array | string)[]>; | |
| 80 | closed: { id: string; code: number; reason: string }[]; | |
| 81 | }; | |
| 82 | ||
| 83 | function spy(): SpyWire { | |
| 84 | const sent = new Map<string, (Uint8Array | string)[]>(); | |
| 85 | const closed: { id: string; code: number; reason: string }[] = []; | |
| 86 | return { | |
| 87 | sent, closed, | |
| 88 | deliver(id, frame) { | |
| 89 | const list = sent.get(id) ?? []; | |
| 90 | list.push(frame); | |
| 91 | sent.set(id, list); | |
| 92 | }, | |
| 93 | close(id, code, reason) { closed.push({ id, code, reason }); }, | |
| 94 | }; | |
| 95 | } | |
| 96 | ||
| 97 | /** A limiter that allows `allowed` frames and then stops, clock ignored. */ | |
| 98 | const allowing = (allowed: number): HubOptions["limiter"] => { | |
| 99 | let left = allowed; | |
| 100 | return { take: () => left-- > 0 }; | |
| 101 | }; | |
| 102 | ||
| 103 | /** This fake game's numbers, none of them the engine's. */ | |
| 104 | const CAPS = { | |
| 105 | rooms: 3, idleMs: 1_000, silentMs: 2_000, messageMax: 4, maxCatchup: 5, | |
| 106 | closeTooBig: 4101, closeFlood: 4102, closeSilent: 4103, | |
| 107 | } as const; | |
| 108 | ||
| 109 | const options = (over: Partial<HubOptions> = {}): HubOptions => | |
| 110 | ({ now: 0, limiter: allowing(1_000), ...CAPS, ...over }); | |
| 111 | ||
| 112 | const hubFor = (wire: Wire, over: Partial<HubOptions> = {}) => createHub(wire, counter, options(over)); | |
| 113 | ||
| 114 | /** How many snapshots — frames of bytes rather than lines of JSON — a member got. */ | |
| 115 | const snapshots = (wire: SpyWire, id: string): number => | |
| 116 | (wire.sent.get(id) ?? []).filter((frame) => frame instanceof Uint8Array).length; | |
| 117 | ||
| 118 | /** And the lines of JSON, parsed, in the order they were delivered. */ | |
| 119 | const lines = (wire: SpyWire, id: string): Record<string, unknown>[] => | |
| 120 | (wire.sent.get(id) ?? []).filter((f): f is string => typeof f === "string").map((f) => JSON.parse(f)); | |
| 121 | ||
| 122 | // --- what the hub does ------------------------------------------------------ | |
| 123 | ||
| 124 | test("one clock steps two rooms, and a member hears their own and never the other", () => { | |
| 125 | const wire = spy(); | |
| 126 | const hub = hubFor(wire); | |
| 127 | const first = hub.open({ seats: 2 }, 0); | |
| 128 | const second = hub.open({ seats: 2 }, 0); | |
| 129 | expect(first).not.toBe(null); | |
| 130 | expect(second).not.toBe(null); | |
| 131 | const a = hub.enter((first as Counter).code, "s1", "sam", 0); | |
| 132 | const b = hub.enter((second as Counter).code, "s2", "kim", 0); | |
| 133 | expect(a?.member.seat).toBe(0); | |
| 134 | expect(b?.room).toBe(second as Counter); | |
| 135 | // Each was told which seat is theirs, in which room, and who is in it. | |
| 136 | expect(lines(wire, "s1")[0]).toEqual({ k: "hello", code: (first as Counter).code, seat: 0 }); | |
| 137 | expect(lines(wire, "s1")[1]).toEqual({ k: "roster", seats: [0] }); | |
| 138 | // A code nobody opened, and a room that is full, are both a polite null. | |
| 139 | expect(hub.enter("ZZZZZZ", "s3", "who", 0)).toBe(null); | |
| 140 | hub.enter((first as Counter).code, "s3", "ash", 0); | |
| 141 | expect(hub.enter((first as Counter).code, "s4", "jo", 0)).toBe(null); | |
| 142 | ||
| 143 | // A second of the clock: ten steps a room, four snapshots a member. | |
| 144 | for (let ms = 50; ms <= 1000; ms += 50) hub.advance(ms); | |
| 145 | expect((first as Counter).steps).toBe(10); | |
| 146 | expect((second as Counter).steps).toBe(10); | |
| 147 | expect(snapshots(wire, "s1")).toBe(4); | |
| 148 | expect(snapshots(wire, "s2")).toBe(4); | |
| 149 | // The words of the round travel beside the snapshots, and only when there are some. | |
| 150 | expect(lines(wire, "s1").filter((l) => l.k === "log").length).toBeGreaterThan(0); | |
| 151 | expect(lines(wire, "s1").filter((l) => l.k === "log").length).toBeLessThan(4); | |
| 152 | ||
| 153 | // One member's frame reaches their own room and no other. | |
| 154 | expect(hub.frame("s1", new Uint8Array([7]), 1010)).toBe(true); | |
| 155 | expect((first as Counter).driven).toEqual([7]); | |
| 156 | expect((second as Counter).driven).toEqual([]); | |
| 157 | // A frame from somebody who is in no room at all is refused without a close. | |
| 158 | expect(hub.frame("nobody", new Uint8Array([7]), 1010)).toBe(false); | |
| 159 | expect(wire.closed).toEqual([]); | |
| 160 | ||
| 161 | expect(hub.members()).toBe(3); | |
| 162 | expect(hub.find((first as Counter).code)).toBe(first as Counter); | |
| 163 | expect(hub.find("ZZZZZZ")).toBe(undefined); | |
| 164 | expect(hub.lines(1000)).toEqual([ | |
| 165 | `${(first as Counter).code} 2/2 at 1000`, | |
| 166 | `${(second as Counter).code} 1/2 at 1000`, | |
| 167 | ]); | |
| 168 | }); | |
| 169 | ||
| 170 | test("the room cap refuses politely, and an empty room is collected on the sweep", () => { | |
| 171 | const wire = spy(); | |
| 172 | const hub = hubFor(wire); | |
| 173 | for (let i = 0; i < CAPS.rooms; i++) expect(hub.open({ seats: 2 }, 0)).not.toBe(null); | |
| 174 | expect(hub.open({ seats: 2 }, 0)).toBe(null); | |
| 175 | expect(hub.rooms.size).toBe(CAPS.rooms); | |
| 176 | // Nobody ever joined any of them, so they go on the first sweep past the idle | |
| 177 | // time — and the cap is a cap again. | |
| 178 | hub.advance(CAPS.idleMs + 1); | |
| 179 | expect(hub.rooms.size).toBe(0); | |
| 180 | const room = hub.open({ seats: 2 }, CAPS.idleMs + 1) as Counter; | |
| 181 | hub.enter(room.code, "s1", "sam", CAPS.idleMs + 1); | |
| 182 | // A room with somebody in it is never idle, however long it runs. | |
| 183 | hub.advance(CAPS.idleMs * 5); | |
| 184 | expect(hub.rooms.size).toBe(1); | |
| 185 | // They leave, and it is collected on the sweep after the idle time. | |
| 186 | hub.exit("s1", CAPS.idleMs * 5); | |
| 187 | expect(room.members.size).toBe(0); | |
| 188 | hub.advance(CAPS.idleMs * 6 + 1); | |
| 189 | expect(hub.rooms.size).toBe(0); | |
| 190 | // Leaving a room that is gone, twice, is not an error and holds no seat. | |
| 191 | hub.exit("s1", CAPS.idleMs * 7); | |
| 192 | expect(hub.members()).toBe(0); | |
| 193 | }); | |
| 194 | ||
| 195 | test("a frame over the ceiling or over the limiter closes the connection", () => { | |
| 196 | const wire = spy(); | |
| 197 | const hub = hubFor(wire); | |
| 198 | const room = hub.open({ seats: 2 }, 0) as Counter; | |
| 199 | hub.enter(room.code, "s1", "sam", 0); | |
| 200 | expect(hub.frame("s1", new Uint8Array(CAPS.messageMax + 1), 1)).toBe(false); | |
| 201 | expect(wire.closed).toEqual([{ id: "s1", code: CAPS.closeTooBig, reason: "frame too large" }]); | |
| 202 | expect(room.driven).toEqual([]); | |
| 203 | ||
| 204 | // And a member who sends more than the limiter allows: closed, with the | |
| 205 | // game's own code, and the hub is still here to say so. | |
| 206 | const flooded = spy(); | |
| 207 | const hub2 = createHub(flooded, counter, options({ limiter: allowing(2) })); | |
| 208 | const room2 = hub2.open({ seats: 2 }, 0) as Counter; | |
| 209 | hub2.enter(room2.code, "s2", "kim", 0); | |
| 210 | let refused = 0; | |
| 211 | for (let i = 0; i < 5; i++) if (!hub2.frame("s2", new Uint8Array([i]), 1)) refused += 1; | |
| 212 | expect(refused).toBe(3); | |
| 213 | expect(room2.driven).toEqual([0, 1]); | |
| 214 | expect(flooded.closed.every((c) => c.code === CAPS.closeFlood)).toBe(true); | |
| 215 | }); | |
| 216 | ||
| 217 | test("a member nobody has heard from loses the seat they are holding", () => { | |
| 218 | const wire = spy(); | |
| 219 | const hub = hubFor(wire); | |
| 220 | const room = hub.open({ seats: 2 }, 0) as Counter; | |
| 221 | hub.enter(room.code, "s1", "sam", 0); | |
| 222 | hub.advance(1_500); | |
| 223 | expect(wire.closed).toEqual([]); | |
| 224 | // A frame is being heard from, so the silence is measured from the last one. | |
| 225 | hub.frame("s1", new Uint8Array([1]), 1_500); | |
| 226 | hub.advance(3_000); | |
| 227 | expect(wire.closed).toEqual([]); | |
| 228 | hub.advance(3_600); | |
| 229 | expect(wire.closed).toEqual([{ id: "s1", code: CAPS.closeSilent, reason: "no input for 2 seconds" }]); | |
| 230 | // The close is what takes them out, the way a browser closing does. | |
| 231 | hub.exit("s1", 3_600); | |
| 232 | expect(room.members.size).toBe(0); | |
| 233 | expect(hub.members()).toBe(0); | |
| 234 | }); | |
| 235 | ||
| 236 | test("a process that was descheduled catches up by the clamp and not by the gap", () => { | |
| 237 | const wire = spy(); | |
| 238 | const hub = hubFor(wire); | |
| 239 | const room = hub.open({ seats: 2 }, 0) as Counter; | |
| 240 | hub.enter(room.code, "s1", "sam", 0); | |
| 241 | // Ten seconds of wall clock is a hundred steps due; the clamp is five, so the | |
| 242 | // room's clock runs slow for that moment rather than stealing the process. | |
| 243 | hub.advance(10_000); | |
| 244 | expect(room.steps).toBe(CAPS.maxCatchup); | |
| 245 | // And the clock is not owed the rest: the next call steps the ticks due since | |
| 246 | // the last step it actually took, which is five again and not ninety-five. | |
| 247 | hub.advance(20_000); | |
| 248 | expect(room.steps).toBe(CAPS.maxCatchup * 2); | |
| 249 | // A step is a step of the game's, at the game's own rate: half a tick is none. | |
| 250 | const fresh = hubFor(spy()); | |
| 251 | const slow = fresh.open({ seats: 1 }, 0) as Counter; | |
| 252 | fresh.advance(50); | |
| 253 | expect(slow.steps).toBe(0); | |
| 254 | fresh.advance(120); | |
| 255 | expect(slow.steps).toBe(1); | |
| 256 | }); | |
| 257 | ||
| 258 | // --- what a room is called -------------------------------------------------- | |
| 259 | ||
| 260 | test("a room code is six readable characters, and nothing else is one", () => { | |
| 261 | expect(isRoomCode("ABCDEF")).toBe(true); | |
| 262 | expect(isRoomCode("abcdef")).toBe(false); | |
| 263 | expect(isRoomCode("ABCDE")).toBe(false); | |
| 264 | // The characters a person reads wrong are not in the alphabet at all. | |
| 265 | for (const wrong of ["O", "0", "I", "1", "L", "S", "5"]) expect(ALPHABET.includes(wrong)).toBe(false); | |
| 266 | expect(isRoomCode("ABCDE0")).toBe(false); | |
| 267 | expect(isRoomCode({ code: "ABCDEF" })).toBe(false); | |
| 268 | // The boundary upper-cases what a person typed and refuses what is not a code. | |
| 269 | expect(roomCodeFrom(" abcdef ")).toBe("ABCDEF"); | |
| 270 | expect(roomCodeFrom("../../etc/passwd")).toBe(null); | |
| 271 | expect(roomCodeFrom(null)).toBe(null); | |
| 272 | // The shape is a written-out literal so that a bundle can shake it away, so | |
| 273 | // every character of the alphabet is held against it here, and the length is | |
| 274 | // held on both sides of itself. | |
| 275 | for (const symbol of ALPHABET) expect(isRoomCode(symbol.repeat(LENGTH))).toBe(true); | |
| 276 | expect(isRoomCode(("A" as string).repeat(LENGTH - 1))).toBe(false); | |
| 277 | expect(isRoomCode(("A" as string).repeat(LENGTH + 1))).toBe(false); | |
| 278 | }); | |
| 279 | ||
| 280 | test("a fresh code is drawn from the alphabet and is the stated length", () => { | |
| 281 | for (let i = 0; i < 50; i++) { | |
| 282 | const code = newRoomCode(); | |
| 283 | expect(code.length).toBe(LENGTH); | |
| 284 | expect(isRoomCode(code)).toBe(true); | |
| 285 | } | |
| 286 | // And it is drawn from the bytes it is handed, so a test can choose one. | |
| 287 | expect(newRoomCode((into) => into.fill(0))).toBe((ALPHABET[0] as string).repeat(LENGTH)); | |
| 288 | }); | |
| 289 | ||
| 290 | test("a room's code is the hub's own, and a collision is drawn again", () => { | |
| 291 | const wire = spy(); | |
| 292 | const hub = hubFor(wire); | |
| 293 | const opened = [0, 1, 2].map(() => hub.open({ seats: 2 }, 0) as Counter); | |
| 294 | for (const room of opened) expect(isRoomCode(room.code)).toBe(true); | |
| 295 | expect(new Set(opened.map((room) => room.code)).size).toBe(3); | |
| 296 | }); |
src/engine/rooms.ts added +314
| @@ -0,0 +1,314 @@ | ||
| 1 | /** | |
| 2 | * A room of *a* game, opened, stepped, swept and capped: every room one process | |
| 3 | * is holding, the one clock that steps them all, and the six readable characters | |
| 4 | * a person reads a room out loud by. | |
| 5 | * | |
| 6 | * Nothing in here knows a world, a map, a weapon or a seat. The hub is handed a | |
| 7 | * table of the functions it calls on a room — create it, sit somebody down, | |
| 8 | * stand them up, drive a frame into it, step it, write a snapshot for a member, | |
| 9 | * ask what was said since the last beat, ask whether it is idle, describe it — | |
| 10 | * and the room itself is the game's own type, opened with the game's own opaque | |
| 11 | * options. So the hub is the same code whether a room is a ten-body shooter or a | |
| 12 | * counter with two seats, and `rooms.test.ts` plays the second one. | |
| 13 | * | |
| 14 | * Nor is anything in here a socket. The hub is handed a `Wire` — deliver these | |
| 15 | * bytes to that member, close that member — and the server is the only file that | |
| 16 | * knows what a connection really is. That is what lets a hub of five members, a | |
| 17 | * flood, an idle sweep and a room cap be tested without opening a port. | |
| 18 | * | |
| 19 | * **Every number is the game's.** A room is CPU on somebody's box, and how much | |
| 20 | * of it a room costs is a fact about that game and not about this file: the room | |
| 21 | * cap, the idle and silent times, the frame ceiling, the catch-up clamp and the | |
| 22 | * close codes all arrive as `HubOptions`, and so does the limiter that says how | |
| 23 | * often one connection may speak. The engine decides none of them and has no | |
| 24 | * default for any of them — a default here would be a number with no measurement | |
| 25 | * behind it, which is the one thing a cap must never be. | |
| 26 | * | |
| 27 | * Rooms live in memory and die with the process: nothing here reads or writes a | |
| 28 | * file, and a hub that is dropped is a hub that is gone. | |
| 29 | */ | |
| 30 | ||
| 31 | // --- what a room is called ------------------------------------------------- | |
| 32 | ||
| 33 | /** | |
| 34 | * The symbols a code is made of, and the only ones. The alphabet leaves out the | |
| 35 | * characters a person reads wrong: no O or 0, no I, l or 1, no S against 5. | |
| 36 | * That is 29 symbols, so a code is 29^6 ≈ 594 million, and against the twenty or | |
| 37 | * so rooms a process holds a blind guess finds one about once in thirty million | |
| 38 | * tries — against a limiter that allows nowhere near that many. | |
| 39 | */ | |
| 40 | export const ALPHABET = "ABCDEFGHJKMNPQRTUVWXYZ2346789"; | |
| 41 | ||
| 42 | /** How many characters a code is. */ | |
| 43 | export const LENGTH = 6; | |
| 44 | ||
| 45 | /** | |
| 46 | * The shape of a code, written out rather than built from the two constants | |
| 47 | * above. A `new RegExp` at module scope is a side effect a minifier may not | |
| 48 | * drop, so building it would put a hundred bytes of room code into the bundle | |
| 49 | * of every game that imports the door and never opens a room; a literal is | |
| 50 | * shaken away with the rest of this module. `rooms.test.ts` holds it against | |
| 51 | * `ALPHABET` and `LENGTH` character by character, which is the drift a written | |
| 52 | * copy would otherwise invite. | |
| 53 | */ | |
| 54 | const SHAPE = /^[ABCDEFGHJKMNPQRTUVWXYZ2346789]{6}$/; | |
| 55 | ||
| 56 | /** Is this a room code at all? The shape above, and nothing else. */ | |
| 57 | export const isRoomCode = (value: unknown): value is string => | |
| 58 | typeof value === "string" && SHAPE.test(value); | |
| 59 | ||
| 60 | /** | |
| 61 | * The code a caller handed over, upper-cased first because a person typing one | |
| 62 | * types it however their keyboard was, or null when it is not a code. This is | |
| 63 | * the boundary: a server's socket upgrade goes through it and a page's field | |
| 64 | * goes through it again, and neither trusts the other. A code is never a path, | |
| 65 | * never a key in anything on disk, and never interpolated into markup without | |
| 66 | * escaping. | |
| 67 | */ | |
| 68 | export function roomCodeFrom(value: unknown): string | null { | |
| 69 | if (typeof value !== "string") return null; | |
| 70 | const code = value.trim().toUpperCase(); | |
| 71 | return isRoomCode(code) ? code : null; | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * A fresh code. `bytes` is a source of random bytes — `crypto.getRandomValues` | |
| 76 | * in both a browser and a server — and is a parameter so a test can hand over a | |
| 77 | * sequence it chose, and so importing this module reaches for no global. | |
| 78 | * | |
| 79 | * The modulo is not quite uniform over 256: with 29 symbols the first 24 are | |
| 80 | * drawn slightly more often than the last 5, by about four per cent. That is | |
| 81 | * not a weakness worth code here — it costs a fraction of a bit out of the | |
| 82 | * thirty a code carries — and rejection sampling would be a loop with a | |
| 83 | * pathological case in the one place this cannot afford one. | |
| 84 | */ | |
| 85 | export function newRoomCode(bytes: (into: Uint8Array) => Uint8Array = (into) => crypto.getRandomValues(into)): string { | |
| 86 | const drawn = bytes(new Uint8Array(LENGTH)); | |
| 87 | let code = ""; | |
| 88 | for (let i = 0; i < LENGTH; i++) code += ALPHABET[(drawn[i] as number) % ALPHABET.length]; | |
| 89 | return code; | |
| 90 | } | |
| 91 | ||
| 92 | // --- the hub --------------------------------------------------------------- | |
| 93 | ||
| 94 | /** What the hub needs a connection to be, which is two functions. */ | |
| 95 | export type Wire = { | |
| 96 | /** These bytes, or this line of JSON, to that member. */ | |
| 97 | deliver(id: string, frame: Uint8Array | string): void; | |
| 98 | /** That member, disconnected, with a code and a sentence. */ | |
| 99 | close(id: string, code: number, reason: string): void; | |
| 100 | }; | |
| 101 | ||
| 102 | /** | |
| 103 | * What the hub needs a *room* to be, which is three fields: the code it answers | |
| 104 | * to, who is in it, and when somebody last was. Everything else about a room — | |
| 105 | * the world, the level, the round, the buffers — is the game's and is never read | |
| 106 | * here. `M`, a member, is likewise the game's own, and the hub reads one field | |
| 107 | * of it: the id the wire delivers to. | |
| 108 | */ | |
| 109 | export type HubRoom<M extends { id: string }> = { | |
| 110 | code: string; | |
| 111 | members: Map<string, M>; | |
| 112 | /** When the room last had a member in it, on the wall clock. */ | |
| 113 | usedAt: number; | |
| 114 | }; | |
| 115 | ||
| 116 | /** | |
| 117 | * The game, as the hub sees it: the functions it calls on a room, the step its | |
| 118 | * world takes and how often a member is sent one. `O` is whatever a room is | |
| 119 | * opened with — a map, a seed, a length, a nothing — and travels from the caller | |
| 120 | * of `open` to `create` without being read on the way. | |
| 121 | */ | |
| 122 | export type RoomGame<R extends HubRoom<M>, M extends { id: string }, O> = { | |
| 123 | /** Seconds in one step of a room's world. */ | |
| 124 | tick: number; | |
| 125 | /** How many snapshots a member is sent a second. */ | |
| 126 | snapshotHz: number; | |
| 127 | /** A fresh room under that code, opened with the game's own options. */ | |
| 128 | create(code: string, options: O, now: number): R; | |
| 129 | /** Somebody sits down. Null is a full room, which is the member cap. */ | |
| 130 | join(room: R, id: string, name: unknown, now: number): M | null; | |
| 131 | /** And stands up again. Null is somebody who was not in it. */ | |
| 132 | leave(room: R, id: string, now: number): M | null; | |
| 133 | /** One frame off the wire, for the member it came from. False is refused. */ | |
| 134 | drive(room: R, id: string, bytes: Uint8Array): boolean; | |
| 135 | /** One step of this room's world. */ | |
| 136 | step(room: R): void; | |
| 137 | /** The bytes this member is sent now. */ | |
| 138 | snapshot(room: R, member: M): Uint8Array; | |
| 139 | /** What one member is told the moment they are in. Delivered as JSON. */ | |
| 140 | hello(room: R, member: M): unknown; | |
| 141 | /** Who is in the room, for everybody in it. Delivered as JSON when it changes. */ | |
| 142 | roster(room: R): unknown; | |
| 143 | /** What was said since the last beat, or null when nothing was. JSON. */ | |
| 144 | words(room: R): unknown; | |
| 145 | /** A room nobody is in, for longer than `idle` milliseconds. */ | |
| 146 | idle(room: R, now: number, idle: number): boolean; | |
| 147 | /** One line about a room, for a terminal. */ | |
| 148 | describe(room: R, now: number): string; | |
| 149 | }; | |
| 150 | ||
| 151 | /** | |
| 152 | * The numbers a hub runs on, every one of them the game's. None has a default: | |
| 153 | * a cap is a measurement, and a measurement belongs to whoever measured it. | |
| 154 | */ | |
| 155 | export type HubOptions = { | |
| 156 | /** The wall clock the hub starts stepping from. */ | |
| 157 | now: number; | |
| 158 | /** How often one connection may send a frame. The key is the member's id. */ | |
| 159 | limiter: { take(key: string, now: number): boolean }; | |
| 160 | /** How many rooms this process holds at once. Past it `open` answers null. */ | |
| 161 | rooms: number; | |
| 162 | /** How long a room with nobody in it is kept, in milliseconds. */ | |
| 163 | idleMs: number; | |
| 164 | /** How long a connection that says nothing at all is kept, in milliseconds. */ | |
| 165 | silentMs: number; | |
| 166 | /** The most bytes one frame from a member may be. Past it, closed. */ | |
| 167 | messageMax: number; | |
| 168 | /** The most steps one call of `advance` will take a room through. */ | |
| 169 | maxCatchup: number; | |
| 170 | /** The close code for a frame over `messageMax`. */ | |
| 171 | closeTooBig: number; | |
| 172 | /** The close code for a member over the limiter. */ | |
| 173 | closeFlood: number; | |
| 174 | /** The close code for a member nobody has heard from in `silentMs`. */ | |
| 175 | closeSilent: number; | |
| 176 | }; | |
| 177 | ||
| 178 | export type Hub<R extends HubRoom<M>, M extends { id: string }, O> = { | |
| 179 | rooms: Map<string, R>; | |
| 180 | /** A fresh room, or null when this process is already holding its cap. */ | |
| 181 | open(options: O, now: number): R | null; | |
| 182 | find(code: string): R | undefined; | |
| 183 | /** Somebody joins a room by code. Null is a full room or a room that is gone. */ | |
| 184 | enter(code: string, id: string, name: unknown, now: number): { room: R; member: M } | null; | |
| 185 | /** And leaves it, whichever room they were in. */ | |
| 186 | exit(id: string, now: number): void; | |
| 187 | /** One frame off the wire from a member. False when it was refused. */ | |
| 188 | frame(id: string, bytes: Uint8Array, now: number): boolean; | |
| 189 | /** | |
| 190 | * The clock: step every room by however many ticks are due at `now`, send the | |
| 191 | * snapshots that fall due, and drop what is idle or silent. Called from one | |
| 192 | * interval in a server and from a test with a clock it moves itself. | |
| 193 | */ | |
| 194 | advance(now: number): void; | |
| 195 | /** How many members this process is holding, over every room. */ | |
| 196 | members(): number; | |
| 197 | /** One line per room, for a terminal. */ | |
| 198 | lines(now: number): string[]; | |
| 199 | }; | |
| 200 | ||
| 201 | /** Where a member is: which room, and since when they were last heard from. */ | |
| 202 | type Seated = { code: string; heardAt: number }; | |
| 203 | ||
| 204 | export function createHub<R extends HubRoom<M>, M extends { id: string }, O>( | |
| 205 | wire: Wire, | |
| 206 | game: RoomGame<R, M, O>, | |
| 207 | options: HubOptions, | |
| 208 | ): Hub<R, M, O> { | |
| 209 | const rooms = new Map<string, R>(); | |
| 210 | const where = new Map<string, Seated>(); | |
| 211 | const { limiter } = options; | |
| 212 | /** The wall clock of the last step, so a tick is never stepped twice. */ | |
| 213 | let steppedAt = options.now; | |
| 214 | /** When each room last sent its snapshots, on the wall clock. */ | |
| 215 | const sentAt = new Map<string, number>(); | |
| 216 | /** The sentence a silent member is closed with, in the game's own seconds. */ | |
| 217 | const silent = `no input for ${Math.round(options.silentMs / 1000)} seconds`; | |
| 218 | ||
| 219 | /** Everybody in the room hears the same line of JSON. */ | |
| 220 | const tell = (room: R, message: unknown): void => { | |
| 221 | const line = JSON.stringify(message); | |
| 222 | for (const member of room.members.values()) wire.deliver(member.id, line); | |
| 223 | }; | |
| 224 | ||
| 225 | const hub: Hub<R, M, O> = { | |
| 226 | rooms, | |
| 227 | open(o, now) { | |
| 228 | if (rooms.size >= options.rooms) return null; | |
| 229 | // A collision is a room that exists, not an error: draw again. Thirty bits | |
| 230 | // against a handful of rooms means this loop runs once, and it is bounded | |
| 231 | // anyway. | |
| 232 | let code = newRoomCode(); | |
| 233 | for (let tries = 0; tries < 8 && rooms.has(code); tries++) code = newRoomCode(); | |
| 234 | if (rooms.has(code)) return null; | |
| 235 | const room = game.create(code, o, now); | |
| 236 | rooms.set(code, room); | |
| 237 | sentAt.set(code, now); | |
| 238 | return room; | |
| 239 | }, | |
| 240 | find: (code) => rooms.get(code), | |
| 241 | enter(code, id, name, now) { | |
| 242 | const room = rooms.get(code); | |
| 243 | if (room === undefined) return null; | |
| 244 | const member = game.join(room, id, name, now); | |
| 245 | if (member === null) return null; | |
| 246 | where.set(id, { code, heardAt: now }); | |
| 247 | wire.deliver(id, JSON.stringify(game.hello(room, member))); | |
| 248 | // Everybody, the arrival included, gets the roster the arrival changed. | |
| 249 | tell(room, game.roster(room)); | |
| 250 | return { room, member }; | |
| 251 | }, | |
| 252 | exit(id, now) { | |
| 253 | const seated = where.get(id); | |
| 254 | where.delete(id); | |
| 255 | if (seated === undefined) return; | |
| 256 | const room = rooms.get(seated.code); | |
| 257 | if (room === undefined) return; | |
| 258 | if (game.leave(room, id, now) === null) return; | |
| 259 | tell(room, game.roster(room)); | |
| 260 | }, | |
| 261 | frame(id, bytes, now) { | |
| 262 | const seated = where.get(id); | |
| 263 | if (seated === undefined) return false; | |
| 264 | if (bytes.byteLength > options.messageMax) { | |
| 265 | wire.close(id, options.closeTooBig, "frame too large"); | |
| 266 | return false; | |
| 267 | } | |
| 268 | if (!limiter.take(`ws:${id}`, now)) { | |
| 269 | wire.close(id, options.closeFlood, "too many frames"); | |
| 270 | return false; | |
| 271 | } | |
| 272 | seated.heardAt = now; | |
| 273 | const room = rooms.get(seated.code); | |
| 274 | return room !== undefined && game.drive(room, id, bytes); | |
| 275 | }, | |
| 276 | advance(now) { | |
| 277 | // Every step that has fallen due since the last call, capped: a process | |
| 278 | // that was descheduled for a second does not then burn a second of CPU | |
| 279 | // catching up on every room at once. Past the cap the rooms' clocks simply | |
| 280 | // run slow for that moment, which is a hitch and not a hole. | |
| 281 | const due = Math.min(Math.floor((now - steppedAt) / (game.tick * 1000)), options.maxCatchup); | |
| 282 | if (due > 0) steppedAt += due * game.tick * 1000; | |
| 283 | const interval = 1000 / game.snapshotHz; | |
| 284 | for (const [code, room] of rooms) { | |
| 285 | for (let i = 0; i < due; i++) game.step(room); | |
| 286 | if (room.members.size > 0) room.usedAt = now; | |
| 287 | const last = sentAt.get(code) ?? now; | |
| 288 | if (now - last >= interval) { | |
| 289 | sentAt.set(code, now); | |
| 290 | const said = game.words(room); | |
| 291 | const words = said === null ? null : JSON.stringify(said); | |
| 292 | for (const member of room.members.values()) { | |
| 293 | wire.deliver(member.id, game.snapshot(room, member)); | |
| 294 | if (words !== null) wire.deliver(member.id, words); | |
| 295 | } | |
| 296 | } | |
| 297 | } | |
| 298 | // A member nobody has heard from is holding a seat somebody else could | |
| 299 | // have: closed here, and the close is what takes them out of the room. | |
| 300 | for (const [id, seated] of where) { | |
| 301 | if (now - seated.heardAt > options.silentMs) wire.close(id, options.closeSilent, silent); | |
| 302 | } | |
| 303 | for (const [code, room] of rooms) { | |
| 304 | if (game.idle(room, now, options.idleMs)) { | |
| 305 | rooms.delete(code); | |
| 306 | sentAt.delete(code); | |
| 307 | } | |
| 308 | } | |
| 309 | }, | |
| 310 | members: () => where.size, | |
| 311 | lines: (now) => [...rooms.values()].map((room) => game.describe(room, now)), | |
| 312 | }; | |
| 313 | return hub; | |
| 314 | } |
src/engine/surface.json +9 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | { |
| 2 | "version": "0.7.0", | |
| 2 | "version": "0.8.0", | |
| 3 | 3 | "names": [ |
| 4 | 4 | "Activations", |
| 5 | 5 | "Adam", |
| @@ -39,6 +39,9 @@ | ||
| 39 | 39 | "Grid", |
| 40 | 40 | "Hit", |
| 41 | 41 | "Host", |
| 42 | "Hub", | |
| 43 | "HubOptions", | |
| 44 | "HubRoom", | |
| 42 | 45 | "Hull", |
| 43 | 46 | "Hyper", |
| 44 | 47 | "Instance", |
| @@ -66,6 +69,7 @@ | ||
| 66 | 69 | "Renderer", |
| 67 | 70 | "RendererAssets", |
| 68 | 71 | "Rollout", |
| 72 | "RoomGame", | |
| 69 | 73 | "Scene", |
| 70 | 74 | "SceneCapacity", |
| 71 | 75 | "Screen", |
| @@ -81,6 +85,7 @@ | ||
| 81 | 85 | "UpdateStats", |
| 82 | 86 | "Vec3", |
| 83 | 87 | "WheelEventish", |
| 88 | "Wire", | |
| 84 | 89 | "Wish", |
| 85 | 90 | "accelerate", |
| 86 | 91 | "add", |
| @@ -107,6 +112,7 @@ | ||
| 107 | 112 | "createEffects", |
| 108 | 113 | "createEscape", |
| 109 | 114 | "createFullscreen", |
| 115 | "createHub", | |
| 110 | 116 | "createLoop", |
| 111 | 117 | "createPolicy", |
| 112 | 118 | "createRenderer", |
| @@ -145,6 +151,7 @@ | ||
| 145 | 151 | "mul", |
| 146 | 152 | "multiply", |
| 147 | 153 | "newActivations", |
| 154 | "newRoomCode", | |
| 148 | 155 | "nextInstance", |
| 149 | 156 | "noise", |
| 150 | 157 | "normalize", |
| @@ -169,6 +176,7 @@ | ||
| 169 | 176 | "resetEffects", |
| 170 | 177 | "resetScene", |
| 171 | 178 | "rng", |
| 179 | "roomCodeFrom", | |
| 172 | 180 | "rowOf", |
| 173 | 181 | "sample", |
| 174 | 182 | "scale", |
src/independence.test.ts +11 −11
| @@ -67,9 +67,10 @@ const TOOLS = [resolve(ROOT, "scripts", "build.ts"), resolve(ROOT, "scripts", "b | ||
| 67 | 67 | * played by more than one person, not a service beside it. Left as the app's — |
| 68 | 68 | * which is what it was, because nothing said otherwise — `room.ts` could import |
| 69 | 69 | * `src/range/` as freely as `src/client/`, and that one line is exactly what |
| 70 | * this file exists to refuse. Two of the four modules here reach for no game at | |
| 71 | * all and are the engine's to take; the test below reads them and says so, | |
| 72 | * rather than the next issue starting from a reading. | |
| 70 | * this file exists to refuse. Two of the four modules that were here reached | |
| 71 | * for no game at all and have been taken by the engine — the hub's machinery | |
| 72 | * and the room code are `src/engine/rooms.ts` now — and the three that are left | |
| 73 | * are this game's; the test below reads them and says so. | |
| 73 | 74 | */ |
| 74 | 75 | const ALSO: Record<string, readonly string[]> = { app: ["src/rl", "src/rooms"] }; |
| 75 | 76 | |
| @@ -112,7 +113,6 @@ test("every game has the sources this test is about, and the app has none", () = | ||
| 112 | 113 | expect(of("app")).toContain("src/rooms/room.ts"); |
| 113 | 114 | expect(of("app")).toContain("src/rooms/wire.ts"); |
| 114 | 115 | expect(of("app")).toContain("src/rooms/hub.ts"); |
| 115 | expect(of("app")).toContain("src/rooms/code.ts"); | |
| 116 | 116 | expect(of("range")).toContain("src/range/targets.ts"); |
| 117 | 117 | expect(of("range")).toContain("src/range/main.ts"); |
| 118 | 118 | // The third game was covered by its registry entry and nothing else: this |
| @@ -233,13 +233,13 @@ function reachesAGame(file: string): string[] { | ||
| 233 | 233 | .filter((target) => gameOf(target) !== null && !target.startsWith(`${own}/`)); |
| 234 | 234 | } |
| 235 | 235 | |
| 236 | test("the two room modules that reach for no game are read rather than believed", () => { | |
| 237 | // The step after this one moves `code.ts` and `hub.ts` into the engine, where | |
| 238 | // a module may import no game at all. That they already import none is a fact | |
| 239 | // about the sources, so it is read out of them here: the move starts from | |
| 240 | // this line, and the day somebody adds the convenient import it fails here | |
| 241 | // rather than in the middle of the move. | |
| 242 | expect(reachesAGame("src/rooms/code.ts")).toEqual([]); | |
| 236 | test("what is left of the rooms is the shooter's, and is read rather than believed", () => { | |
| 237 | // The move happened: `code.ts` and the hub's machinery are `src/engine/ | |
| 238 | // rooms.ts`, behind the door, where a module may import no game at all. What | |
| 239 | // is left under `src/rooms/hub.ts` is this game's table and this game's | |
| 240 | // numbers, so it reaches for `room.ts` — its own sibling — and for the door, | |
| 241 | // and for no other game's source. That is a fact about the sources, so it is | |
| 242 | // read out of them here rather than believed. | |
| 243 | 243 | expect(reachesAGame("src/rooms/hub.ts")).toEqual([]); |
| 244 | 244 | // The other two are the shooter's, and say so themselves. |
| 245 | 245 | expect(reachesAGame("src/rooms/room.ts")).toContain("src/client/world.ts"); |
src/rooms/code.test.ts deleted −36
| @@ -1,36 +0,0 @@ | ||
| 1 | /** | |
| 2 | * What a room is called, both ways through the door: what is a code, what is | |
| 3 | * not one, and that a fresh one is drawn from the alphabet a person can read | |
| 4 | * out loud without asking which letter that was. | |
| 5 | * | |
| 6 | * `code.ts` imports nothing at all, so this file needs no world, no clock and | |
| 7 | * no fixture — it is the whole of the module's surface in two tests, which is | |
| 8 | * the point of the module being that small. | |
| 9 | */ | |
| 10 | ||
| 11 | import { expect, test } from "bun:test"; | |
| 12 | import { ALPHABET, isRoomCode, LENGTH, newRoomCode, roomCodeFrom } from "./code.ts"; | |
| 13 | ||
| 14 | test("a room code is six readable characters, and nothing else is one", () => { | |
| 15 | expect(isRoomCode("ABCDEF")).toBe(true); | |
| 16 | expect(isRoomCode("abcdef")).toBe(false); | |
| 17 | expect(isRoomCode("ABCDE")).toBe(false); | |
| 18 | // The characters a person reads wrong are not in the alphabet at all. | |
| 19 | for (const wrong of ["O", "0", "I", "1", "L", "S", "5"]) expect(ALPHABET.includes(wrong)).toBe(false); | |
| 20 | expect(isRoomCode("ABCDE0")).toBe(false); | |
| 21 | expect(isRoomCode({ code: "ABCDEF" })).toBe(false); | |
| 22 | // The boundary upper-cases what a person typed and refuses what is not a code. | |
| 23 | expect(roomCodeFrom(" abcdef ")).toBe("ABCDEF"); | |
| 24 | expect(roomCodeFrom("../../etc/passwd")).toBe(null); | |
| 25 | expect(roomCodeFrom(null)).toBe(null); | |
| 26 | }); | |
| 27 | ||
| 28 | test("a fresh code is drawn from the alphabet and is the stated length", () => { | |
| 29 | for (let i = 0; i < 50; i++) { | |
| 30 | const code = newRoomCode(); | |
| 31 | expect(code.length).toBe(LENGTH); | |
| 32 | expect(isRoomCode(code)).toBe(true); | |
| 33 | } | |
| 34 | // And it is drawn from the bytes it is handed, so a test can choose one. | |
| 35 | expect(newRoomCode((into) => into.fill(0))).toBe((ALPHABET[0] as string).repeat(LENGTH)); | |
| 36 | }); |
src/rooms/code.ts deleted −63
| @@ -1,63 +0,0 @@ | ||
| 1 | /** | |
| 2 | * What a room is called: six characters a person can read out loud over a | |
| 3 | * voice call and type without asking which letter that was. | |
| 4 | * | |
| 5 | * It is the whole of the public name of a room, so it is an input twice over — | |
| 6 | * the query on the socket upgrade, and the field in the menu — and it is | |
| 7 | * validated here on both sides, the way `src/matchid.ts` is the one door a match | |
| 8 | * id goes through. A code is never a path, never a key in anything on disk, and | |
| 9 | * never interpolated into markup without escaping. | |
| 10 | * | |
| 11 | * The alphabet leaves out the characters a person reads wrong: no O or 0, no I, | |
| 12 | * l or 1, no S against 5. That is 32 symbols, so a code is 32^6 ≈ 1.07 billion, | |
| 13 | * and with the room cap this process holds — a hundred — a blind guess finds a | |
| 14 | * room about once in ten million tries, against a limiter that allows nowhere | |
| 15 | * near that many. The code is drawn from `crypto.getRandomValues`, not from | |
| 16 | * `Math.random`: it is the only thing standing between a room and a stranger. | |
| 17 | * | |
| 18 | * This file imports nothing — not the world, not the hub, not a package — so | |
| 19 | * `code.test.ts` is the whole of it in two tests: what is a code, and that a | |
| 20 | * fresh one is drawn from the alphabet above and is the stated length. | |
| 21 | */ | |
| 22 | ||
| 23 | /** The symbols a code is made of, and the only ones. */ | |
| 24 | export const ALPHABET = "ABCDEFGHJKMNPQRTUVWXYZ2346789"; | |
| 25 | ||
| 26 | /** How many characters a code is. */ | |
| 27 | export const LENGTH = 6; | |
| 28 | ||
| 29 | const SHAPE = new RegExp(`^[${ALPHABET}]{${LENGTH}}$`); | |
| 30 | ||
| 31 | /** Is this a room code at all? The shape above, and nothing else. */ | |
| 32 | export const isRoomCode = (value: unknown): value is string => | |
| 33 | typeof value === "string" && SHAPE.test(value); | |
| 34 | ||
| 35 | /** | |
| 36 | * The code a caller handed over, upper-cased first because a person typing one | |
| 37 | * types it however their keyboard was, or null when it is not a code. This is | |
| 38 | * the boundary: the server's upgrade goes through it and the page's field goes | |
| 39 | * through it again, and neither trusts the other. | |
| 40 | */ | |
| 41 | export function roomCodeFrom(value: unknown): string | null { | |
| 42 | if (typeof value !== "string") return null; | |
| 43 | const code = value.trim().toUpperCase(); | |
| 44 | return isRoomCode(code) ? code : null; | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * A fresh code. `bytes` is a source of random bytes — `crypto.getRandomValues` | |
| 49 | * in both a browser and Bun — and is a parameter so a test can hand over a | |
| 50 | * sequence it chose. | |
| 51 | * | |
| 52 | * The modulo is not quite uniform over 256: with 29 symbols the first 24 are | |
| 53 | * drawn slightly more often than the last 5, by about four per cent. That is | |
| 54 | * not a weakness worth code here — it costs a fraction of a bit out of the | |
| 55 | * thirty a code carries — and rejection sampling would be a loop with a | |
| 56 | * pathological case in the one place this app cannot afford one. | |
| 57 | */ | |
| 58 | export function newRoomCode(bytes: (into: Uint8Array) => Uint8Array = (into) => crypto.getRandomValues(into)): string { | |
| 59 | const drawn = bytes(new Uint8Array(LENGTH)); | |
| 60 | let code = ""; | |
| 61 | for (let i = 0; i < LENGTH; i++) code += ALPHABET[(drawn[i] as number) % ALPHABET.length]; | |
| 62 | return code; | |
| 63 | } |
src/rooms/hub.test.ts +18 −5
| @@ -8,9 +8,16 @@ | ||
| 8 | 8 | * sweep past the idle time, and a connection that says nothing losing the seat |
| 9 | 9 | * it is holding. |
| 10 | 10 | * |
| 11 | * The machinery under all of that is the engine's now, and | |
| 12 | * `src/engine/rooms.test.ts` is where it is proved against a game that is not | |
| 13 | * this one. What is proved *here* is this game wired to it: the shooter's table | |
| 14 | * and the shooter's numbers, playing a real round of the real world. The two | |
| 15 | * files are the same behaviours read twice on purpose — the engine's says the | |
| 16 | * hub is generic, this one says the shooter still plays. | |
| 17 | * | |
| 11 | 18 | * It is also where the directory is held to its own documentation: every test |
| 12 | 19 | * file a source under `src/rooms/` names in backticks is a file that exists, |
| 13 | * and each of the four modules names the test file that is about it. The habit | |
| 20 | * and each of the three modules names the test file that is about it. The habit | |
| 14 | 21 | * is `src/engine/guide.test.ts`'s — prose that names a file is a claim, and a |
| 15 | 22 | * claim is read by a test — and it lives here because the hub is the module |
| 16 | 23 | * that names a test outside its own directory. |
| @@ -121,8 +128,12 @@ test("a member nobody has heard from loses the seat they are holding", () => { | ||
| 121 | 128 | const ROOMS = import.meta.dir; |
| 122 | 129 | const ROOT = resolve(ROOMS, "..", ".."); |
| 123 | 130 | |
| 124 | /** The four modules of this directory, each of which names its own test file. */ | |
| 125 | const MODULES = ["code", "wire", "room", "hub"]; | |
| 131 | /** | |
| 132 | * The three modules of this directory, each of which names its own test file. | |
| 133 | * There were four: `code.ts` went behind the engine's door with the hub's | |
| 134 | * machinery, and what is left in here is the shooter's own. | |
| 135 | */ | |
| 136 | const MODULES = ["wire", "room", "hub"]; | |
| 126 | 137 | |
| 127 | 138 | /** |
| 128 | 139 | * The test files a source names in backticks: either a sibling under this |
| @@ -158,9 +169,11 @@ test("each module names the test file that is about it, and that file tests it", | ||
| 158 | 169 | const spec = readFileSync(join(ROOMS, `${module}.test.ts`), "utf8"); |
| 159 | 170 | expect(spec).toContain(`"./${module}.ts"`); |
| 160 | 171 | } |
| 161 | // The file the four replaced is gone, rather than left beside them saying | |
| 162 | // the same things twice. | |
| 172 | // The file the split replaced is gone, rather than left beside them saying | |
| 173 | // the same things twice — and so is `code.ts`, which the engine holds now. | |
| 163 | 174 | expect(existsSync(join(ROOMS, "rooms.test.ts"))).toBe(false); |
| 175 | expect(existsSync(join(ROOMS, "code.ts"))).toBe(false); | |
| 176 | expect(existsSync(join(ROOMS, "code.test.ts"))).toBe(false); | |
| 164 | 177 | }); |
| 165 | 178 | |
| 166 | 179 | test("the reader reads a name it is handed, and only a name", () => { |
src/rooms/hub.ts +59 −136
| @@ -1,5 +1,12 @@ | ||
| 1 | 1 | /** |
| 2 | * Every room this process is holding, and the one clock that steps them. | |
| 2 | * This game's rooms: the numbers a room of *this* shooter costs, and the table | |
| 3 | * that hands the shooter's own room to the engine's hub. | |
| 4 | * | |
| 5 | * The hub itself is `createHub` behind the engine's door — it opens rooms, steps | |
| 6 | * them on one clock, sends the snapshots that fall due, sweeps what is idle and | |
| 7 | * caps what one process holds, and it knows none of that is a shooter. What is | |
| 8 | * left here is what is genuinely this game's: the functions of `room.ts` it | |
| 9 | * calls, and the numbers below. | |
| 3 | 10 | * |
| 4 | 11 | * A room is CPU on a shared box, so this file is mostly numbers with reasons, |
| 5 | 12 | * and the numbers are measured rather than assumed: `bun run scripts/room.ts` |
| @@ -10,14 +17,16 @@ | ||
| 10 | 17 | * core. `ROOM_CAP` rooms at once is therefore about a sixth of a core, and one |
| 11 | 18 | * core would hold fifty to eighty of them. That is the whole argument for the |
| 12 | 19 | * cap being where it is: it is a choice about a box that also serves pages, not |
| 13 | * a ceiling the simulation put there. `src/budget.test.ts` holds a room-second | |
| 20 | * a ceiling the simulation put there, and it is the argument for these numbers | |
| 21 | * living here rather than in the engine. `src/budget.test.ts` holds a room-second | |
| 14 | 22 | * under 40 ms so the day the simulation gets several times dearer this file's |
| 15 | 23 | * arithmetic goes red rather than silently wrong. |
| 16 | 24 | * |
| 17 | 25 | * Nothing here is a socket either. The hub is handed a `Wire` — deliver these |
| 18 | 26 | * bytes to that member, close that member — and the server is the only file that |
| 19 | 27 | * knows what one really is. So `hub.test.ts` runs a hub with five members, an |
| 20 | * idle sweep and a room cap without opening a port. | |
| 28 | * idle sweep and a room cap without opening a port, and `src/engine/rooms.test.ts` | |
| 29 | * runs the same machinery under a game that is not this one. | |
| 21 | 30 | * |
| 22 | 31 | * Rooms live in memory and die with the process. Nothing is written under |
| 23 | 32 | * `FORGE_APP_DATA`: a half-finished round is not worth keeping, a deploy ends |
| @@ -25,14 +34,16 @@ | ||
| 25 | 34 | * survives one. |
| 26 | 35 | */ |
| 27 | 36 | |
| 37 | import { createHub as createEngineHub, type Hub as EngineHub, type RoomGame, type Wire } from "../engine/index.ts"; | |
| 28 | 38 | import { createLimiter, type Limiter } from "../limit.ts"; |
| 29 | import { newRoomCode } from "./code.ts"; | |
| 30 | 39 | import { |
| 31 | 40 | createRoom, describeRoom, drive, helloFor, isIdle, join, leave, logSince, rosterMessage, snapshotFor, |
| 32 | 41 | stepRoom, SNAPSHOT_HZ, TICK, type Member, type Room, |
| 33 | 42 | } from "./room.ts"; |
| 34 | 43 | import type { PlayedMap } from "../mapname.ts"; |
| 35 | 44 | |
| 45 | export type { Wire }; | |
| 46 | ||
| 36 | 47 | /** |
| 37 | 48 | * How many rooms one deployment holds at once. Twenty rooms is a measured |
| 38 | 49 | * 0.13–0.18 s of CPU a second — about a sixth of a core — against a box that |
| @@ -89,142 +100,54 @@ export const FRAME_LIMIT = { burst: 160, per: 8 }; | ||
| 89 | 100 | */ |
| 90 | 101 | export const MAX_CATCHUP = 16; |
| 91 | 102 | |
| 92 | /** What the hub needs a connection to be, which is two functions. */ | |
| 93 | export type Wire = { | |
| 94 | /** These bytes, or this line of JSON, to that member. */ | |
| 95 | deliver(id: string, frame: Uint8Array | string): void; | |
| 96 | /** That member, disconnected, with a code and a sentence. */ | |
| 97 | close(id: string, code: number, reason: string): void; | |
| 103 | /** What a room of this game is opened with. Opaque to the hub, which passes it on. */ | |
| 104 | export type RoomOptions = { map: PlayedMap; seed: number; length: "short" | "long"; now: number }; | |
| 105 | ||
| 106 | /** | |
| 107 | * The shooter, as the hub sees it: eleven functions of `room.ts` and the two | |
| 108 | * rates a room runs at. This is the whole of what the engine knows about this | |
| 109 | * game — no `World`, no `PlayedMap`, no map name — and the only thing that had | |
| 110 | * to be written down to get the hub out of here. | |
| 111 | */ | |
| 112 | export const SHOOTER: RoomGame<Room, Member, RoomOptions> = { | |
| 113 | tick: TICK, | |
| 114 | snapshotHz: SNAPSHOT_HZ, | |
| 115 | create: (code, options) => createRoom(code, options), | |
| 116 | join, | |
| 117 | leave, | |
| 118 | drive, | |
| 119 | step: stepRoom, | |
| 120 | snapshot: snapshotFor, | |
| 121 | hello: helloFor, | |
| 122 | roster: rosterMessage, | |
| 123 | words: logSince, | |
| 124 | idle: isIdle, | |
| 125 | describe: describeRoom, | |
| 98 | 126 | }; |
| 99 | 127 | |
| 100 | export type Hub = { | |
| 101 | rooms: Map<string, Room>; | |
| 128 | /** | |
| 129 | * Every room this process is holding. The engine's hub with this game's table | |
| 130 | * and this game's numbers in it, and one difference at the door: a room here | |
| 131 | * carries the clock it was opened at, so `open` takes the options alone where | |
| 132 | * the engine's takes a `now` beside them. | |
| 133 | */ | |
| 134 | export type Hub = Omit<EngineHub<Room, Member, RoomOptions>, "open"> & { | |
| 102 | 135 | /** A fresh room, or null when this process is already holding `ROOM_CAP`. */ |
| 103 | open(options: { map: PlayedMap; seed: number; length: "short" | "long"; now: number }): Room | null; | |
| 104 | find(code: string): Room | undefined; | |
| 105 | /** Somebody joins a room by code. Null is a full room or a room that is gone. */ | |
| 106 | enter(code: string, id: string, name: unknown, now: number): { room: Room; member: Member } | null; | |
| 107 | /** And leaves it, whichever room they were in. */ | |
| 108 | exit(id: string, now: number): void; | |
| 109 | /** One frame off the wire from a member. False when it was refused. */ | |
| 110 | frame(id: string, bytes: Uint8Array, now: number): boolean; | |
| 111 | /** | |
| 112 | * The clock: step every room by however many ticks are due at `now`, send the | |
| 113 | * snapshots that fall due, and drop what is idle or silent. Called from one | |
| 114 | * interval in `src/server.ts` and from a test with a clock it moves itself. | |
| 115 | */ | |
| 116 | advance(now: number): void; | |
| 117 | /** How many members this process is holding, over every room. */ | |
| 118 | members(): number; | |
| 119 | /** One line per room, for the terminal. */ | |
| 120 | lines(now: number): string[]; | |
| 136 | open(options: RoomOptions): Room | null; | |
| 121 | 137 | }; |
| 122 | 138 | |
| 123 | /** Where a member is: which room, and since when they were last heard from. */ | |
| 124 | type Seated = { code: string; heardAt: number }; | |
| 125 | ||
| 126 | 139 | export function createHub(wire: Wire, options: { now: number; limiter?: Limiter } = { now: Date.now() }): Hub { |
| 127 | const rooms = new Map<string, Room>(); | |
| 128 | const where = new Map<string, Seated>(); | |
| 129 | const limiter = options.limiter ?? createLimiter(FRAME_LIMIT); | |
| 130 | /** The wall clock of the last step, so a tick is never stepped twice. */ | |
| 131 | let steppedAt = options.now; | |
| 132 | /** When each room last sent its snapshots, on the wall clock. */ | |
| 133 | const sentAt = new Map<string, number>(); | |
| 134 | ||
| 135 | const send = (room: Room, member: Member): void => { | |
| 136 | wire.deliver(member.id, snapshotFor(room, member)); | |
| 137 | }; | |
| 138 | ||
| 139 | const hub: Hub = { | |
| 140 | rooms, | |
| 141 | open(o) { | |
| 142 | if (rooms.size >= ROOM_CAP) return null; | |
| 143 | // A collision is a room that exists, not an error: draw again. Thirty bits | |
| 144 | // against twenty rooms means this loop runs once, and it is bounded anyway. | |
| 145 | let code = newRoomCode(); | |
| 146 | for (let tries = 0; tries < 8 && rooms.has(code); tries++) code = newRoomCode(); | |
| 147 | if (rooms.has(code)) return null; | |
| 148 | const room = createRoom(code, o); | |
| 149 | rooms.set(code, room); | |
| 150 | sentAt.set(code, o.now); | |
| 151 | return room; | |
| 152 | }, | |
| 153 | find: (code) => rooms.get(code), | |
| 154 | enter(code, id, name, now) { | |
| 155 | const room = rooms.get(code); | |
| 156 | if (room === undefined) return null; | |
| 157 | const member = join(room, id, name, now); | |
| 158 | if (member === null) return null; | |
| 159 | where.set(id, { code, heardAt: now }); | |
| 160 | wire.deliver(id, JSON.stringify(helloFor(room, member))); | |
| 161 | // Everybody, the arrival included, gets the roster the arrival changed. | |
| 162 | const roster = JSON.stringify(rosterMessage(room)); | |
| 163 | for (const other of room.members.values()) wire.deliver(other.id, roster); | |
| 164 | return { room, member }; | |
| 165 | }, | |
| 166 | exit(id, now) { | |
| 167 | const seated = where.get(id); | |
| 168 | where.delete(id); | |
| 169 | if (seated === undefined) return; | |
| 170 | const room = rooms.get(seated.code); | |
| 171 | if (room === undefined) return; | |
| 172 | if (leave(room, id, now) === null) return; | |
| 173 | const roster = JSON.stringify(rosterMessage(room)); | |
| 174 | for (const other of room.members.values()) wire.deliver(other.id, roster); | |
| 175 | }, | |
| 176 | frame(id, bytes, now) { | |
| 177 | const seated = where.get(id); | |
| 178 | if (seated === undefined) return false; | |
| 179 | if (bytes.byteLength > MESSAGE_MAX) { | |
| 180 | wire.close(id, CLOSE_TOO_BIG, "frame too large"); | |
| 181 | return false; | |
| 182 | } | |
| 183 | if (!limiter.take(`ws:${id}`, now)) { | |
| 184 | wire.close(id, CLOSE_FLOOD, "too many frames"); | |
| 185 | return false; | |
| 186 | } | |
| 187 | seated.heardAt = now; | |
| 188 | const room = rooms.get(seated.code); | |
| 189 | return room !== undefined && drive(room, id, bytes); | |
| 190 | }, | |
| 191 | advance(now) { | |
| 192 | // Every 64 Hz step that has fallen due since the last call, capped: a | |
| 193 | // process that was descheduled for a second does not then burn a second of | |
| 194 | // CPU catching up on every room at once. Past the cap the rooms' clocks | |
| 195 | // simply run slow for that moment, which is a hitch and not a hole. | |
| 196 | const due = Math.min(Math.floor((now - steppedAt) / (TICK * 1000)), MAX_CATCHUP); | |
| 197 | if (due > 0) steppedAt += due * TICK * 1000; | |
| 198 | const interval = 1000 / SNAPSHOT_HZ; | |
| 199 | for (const [code, room] of rooms) { | |
| 200 | for (let i = 0; i < due; i++) stepRoom(room); | |
| 201 | if (room.members.size > 0) room.usedAt = now; | |
| 202 | const last = sentAt.get(code) ?? now; | |
| 203 | if (now - last >= interval) { | |
| 204 | sentAt.set(code, now); | |
| 205 | const log = logSince(room); | |
| 206 | const words = log === null ? null : JSON.stringify(log); | |
| 207 | for (const member of room.members.values()) { | |
| 208 | send(room, member); | |
| 209 | if (words !== null) wire.deliver(member.id, words); | |
| 210 | } | |
| 211 | } | |
| 212 | } | |
| 213 | // A member nobody has heard from is holding a seat somebody else could | |
| 214 | // have: closed here, and the close is what takes them out of the room. | |
| 215 | for (const [id, seated] of where) { | |
| 216 | if (now - seated.heardAt > SILENT_MS) wire.close(id, CLOSE_SILENT, "no input for thirty seconds"); | |
| 217 | } | |
| 218 | for (const [code, room] of rooms) { | |
| 219 | if (isIdle(room, now, IDLE_MS)) { | |
| 220 | rooms.delete(code); | |
| 221 | sentAt.delete(code); | |
| 222 | } | |
| 223 | } | |
| 224 | }, | |
| 225 | members: () => where.size, | |
| 226 | lines: (now) => [...rooms.values()].map((room) => describeRoom(room, now)), | |
| 227 | }; | |
| 228 | return hub; | |
| 140 | const hub = createEngineHub(wire, SHOOTER, { | |
| 141 | now: options.now, | |
| 142 | limiter: options.limiter ?? createLimiter(FRAME_LIMIT), | |
| 143 | rooms: ROOM_CAP, | |
| 144 | idleMs: IDLE_MS, | |
| 145 | silentMs: SILENT_MS, | |
| 146 | messageMax: MESSAGE_MAX, | |
| 147 | maxCatchup: MAX_CATCHUP, | |
| 148 | closeTooBig: CLOSE_TOO_BIG, | |
| 149 | closeFlood: CLOSE_FLOOD, | |
| 150 | closeSilent: CLOSE_SILENT, | |
| 151 | }); | |
| 152 | return { ...hub, open: (o) => hub.open(o, o.now) }; | |
| 229 | 153 | } |
| 230 |
src/rooms/stage.test-helper.ts +3 −3
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 2 | * The stage a room is played on in a test: the fixtures the four test files | |
| 2 | * The stage a room is played on in a test: the fixtures the three test files | |
| 3 | 3 | * beside this one would otherwise each keep a copy of. |
| 4 | 4 | * |
| 5 | 5 | * Both games do it this way — `src/client/stage.test-helper.ts` and |
| @@ -11,8 +11,8 @@ | ||
| 11 | 11 | * sends for one tick of holding W, and a `Wire` that remembers what it was |
| 12 | 12 | * handed instead of sending it. |
| 13 | 13 | * |
| 14 | * It ships in no bundle: only `code.test.ts`, `wire.test.ts`, `room.test.ts` | |
| 15 | * and `hub.test.ts` import it. | |
| 14 | * It ships in no bundle: only `wire.test.ts`, `room.test.ts` and `hub.test.ts` | |
| 15 | * import it. | |
| 16 | 16 | */ |
| 17 | 17 | |
| 18 | 18 | import { DE_FORGE } from "../client/map.ts"; |
src/server.ts +1 −1
| @@ -69,7 +69,7 @@ import { errorPage, notFoundPage, STYLE as MISS_STYLE } from "./pages/miss.ts"; | ||
| 69 | 69 | import { boardsHtml, rangePage } from "./pages/range.ts"; |
| 70 | 70 | import { STYLE as WEAPONS_STYLE, weaponsPage, WEAPONS_HREF } from "./pages/weapons.ts"; |
| 71 | 71 | import { policyFor } from "./policy.ts"; |
| 72 | import { roomCodeFrom } from "./rooms/code.ts"; | |
| 72 | import { roomCodeFrom } from "./engine/index.ts"; | |
| 73 | 73 | import { createHub, CLOSE_GONE, MESSAGE_MAX, ROOM_CAP } from "./rooms/hub.ts"; |
| 74 | 74 | import { playedOn } from "./mapname.ts"; |
| 75 | 75 | import { styleFor, type Linked } from "./pages/shell.ts"; |
new above
offline