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

forked from trevato/cs

multiplayer. i need to run this with my friends. surprised it’s not there yet… #312

Merged opened this in answer to

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

Merged

vesper/cs-engine:vesper/300-multiplayer-i-need-to-run-this-with-my-f → main 4 commits fork

  • Merged. vesper/300-multiplayer-i-need-to-run-this-with-my-f was merged into main as 93a7a20.

Review

Sign in to review this change.
15 files changed +2458 −24
README.md +23
@@ -1503,3 +1503,26 @@ network here is the forge and the package registry), so the influence is from
15031503 what PufferLib is known for rather than from its documentation: flat observation
15041504 and action spaces, environment throughput treated as the thing that decides
15051505 whether a run is possible, and plain PPO rather than a clever algorithm.
1506
1507 ## Playing with friends: rooms
1508
1509 The match at `/` runs in your own browser against bots. A **room** is the other
1510 thing: make one from the menu, read the six-character code out to your friends,
1511 and they type it in. A room's round is stepped **on this server** at the game's
1512 own 64 Hz — `src/rooms/room.ts` runs the shooter's own `createWorld` and
1513 `updateWith` — and every member is sent the same 544-byte packed snapshot sixteen
1514 times a second (`src/rooms/wire.ts`), so nobody in a room can disagree about what
1515 happened. Joining takes a bot's seat, so a round is always five against five, and
1516 leaving hands the body straight back to the bot with the money and guns it had.
1517
1518 What is real and what is not, today:
1519
1520 - Real: shared rounds, shots, damage, deaths, the bomb, the kill feed, the
1521 economy (the buy goes up with the input frame and is spent on the server), and
1522 the caps — twenty rooms a process, ten people a room, an empty room forgotten
1523 after ninety seconds (`src/rooms/hub.ts`).
1524 - Not yet: your own movement is **not** predicted, so it answers on the round
1525 trip rather than instantly; a room has no hit marker or damage arrow (both are
1526 effects recorded for one screen and a snapshot goes to everybody); and a room's
1527 result is not posted to the leaderboard. Rooms live in this process only — a
1528 deploy ends them.
scripts/build.test.ts +1 −1
@@ -49,7 +49,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri
4949
5050 test("the entry table is what the app ships today, each with the size it may not pass", () => {
5151 expect(ENTRIES).toEqual([
52 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 110_000, gzip: 43_500 } },
52 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 122_000, gzip: 47_500 } },
5353 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 37_000, gzip: 15_500 } },
5454 { name: "example", entry: "src/example/main.ts", ceiling: { raw: 23_500, gzip: 10_200 } },
5555 ]);
src/client/main.ts +159 −3
@@ -100,6 +100,10 @@ import { drawBuy, HINT, labelOf, offers } from "./buy.ts";
100100 import { buy, canBuy, priceOf, type BuyItem } from "./economy.ts";
101101 import { atRound, createLoadout, noteBuy, rebuy, saidRebuy } from "./loadout.ts";
102102 import { createWorld, eyePos, say as showMessage, update, type Player, type World } from "./world.ts";
103 import { createSession, type Session, type Socket } from "./session.ts";
104 import { roomCodeFrom } from "../rooms/code.ts";
105 import { SWITCH_KNIFE, SWITCH_NONE, SWITCH_PRIMARY, SWITCH_SECONDARY } from "../rooms/wire.ts";
106 import type { BuyItem as Item } from "./economy.ts";
103107 import { unpack } from "../engine/index.ts";
104108
105109 const TICK = 1 / 64;
@@ -142,6 +146,12 @@ export const NO_POLICY =
142146 "if it keeps happening, this deployment is missing the weights the page asked for.";
143147
144148 /** What the copy control says once the URL is on the clipboard, and if it is not. */
149 /** What the menu says when this browser cannot open a socket at all. */
150 export const NO_SOCKET = "This browser has no WebSocket, so it cannot join a room. The match above still plays.";
151 /** And what it says when a room could not be made or joined. */
152 export const NO_ROOM = "That room could not be joined — check the code, or make a new room.";
153 export const NO_MAKE = "A room could not be made right now. Try again in a moment.";
154
145155 export const COPIED = "Copied";
146156 export const NOT_COPIED = "Copy failed — the link still works";
147157
@@ -285,6 +295,12 @@ export type Host = {
285295 * into a message. `location.origin`; no path, no query, nothing of the id.
286296 */
287297 origin: string;
298 /**
299 * A socket to a room, when this browser has WebSocket: `(url) => new WebSocket(url)`.
300 * Absent is a browser that cannot play with other people, and the two room
301 * controls in the menu stay disabled and say so.
302 */
303 connect?(url: string): Socket;
288304 /**
289305 * `navigator.clipboard.writeText`, when this browser has one and the page is
290306 * allowed it. Absent is a browser where the copy control is only a link, and
@@ -372,6 +388,10 @@ export function boot(host: Host): Frame {
372388 const copyLabel = copyMap.textContent;
373389 const prep = by("prep");
374390 const play = by<Panel & { disabled: boolean }>("play");
391 const roomField = by<Field>("room");
392 const joinRoom = by<Panel & { disabled: boolean }>("joinroom");
393 const makeRoom = by<Panel & { disabled: boolean }>("makeroom");
394 const roomSaid = by("roomsaid");
375395 // Where this game's weights are: the shell put the link in the head with the
376396 // content-hashed URL on it, so this is the only place the path is known.
377397 const weightsLink = doc.getElementById("weights") as Link | null;
@@ -514,6 +534,14 @@ export function boot(host: Host): Frame {
514534 // meshes were built from, and a match on another map swaps that one mesh.
515535 let shown: Level = DE_FORGE;
516536 let running = false;
537 /**
538 * The room this page is in, or null for the match that runs in this browser.
539 * When it is set the world is the session's — filled from the server's
540 * snapshots — and the frame below sends keys upstream instead of stepping.
541 */
542 let session: Session | null = null;
543 /** One item asked for, sent on the next frame and then forgotten. */
544 let roomBuy: Item | null = null;
517545 let lastSound = 0;
518546 let frameAverage = 16.7;
519547 let posted = -1;
@@ -567,6 +595,12 @@ export function boot(host: Host): Frame {
567595 }
568596 show(prep, false);
569597 play.disabled = false;
598 // A browser with no WebSocket can still play the match in its own page; the
599 // two controls that need one say so rather than failing when pressed.
600 const canJoin = host.connect !== undefined;
601 joinRoom.disabled = !canJoin;
602 makeRoom.disabled = !canJoin;
603 if (!canJoin) roomSaid.textContent = NO_SOCKET;
570604 }
571605
572606 // --- the buy menu, rebuilt whenever it opens so the prices are honest
@@ -606,6 +640,13 @@ export function boot(host: Host): Frame {
606640 function pressBuy(item: BuyItem): void {
607641 if (world === null) return;
608642 const me = world.me;
643 // In a room the shelf is the server's: the ask goes up with the next input
644 // frame and the money is spent there, so nothing is spent here twice.
645 if (session !== null) {
646 roomBuy = item;
647 sayBuy(`Asked for ${labelOf(item, me.team)}.`);
648 return;
649 }
609650 if (buy(world, me, item)) {
610651 noteBuy(loadout, item);
611652 audio.beep(700, 0.05);
@@ -1027,6 +1068,96 @@ export function boot(host: Host): Frame {
10271068 host.storage.setItem("cs.name", name);
10281069 }
10291070
1071 /**
1072 * Into a room: the same match, with the round decided on the server. What
1073 * `start` above does with a world it makes, this does with a world the first
1074 * snapshot describes — so the level, the rules and the ten bodies come from
1075 * the room and not from the menu, and everything else about the screen is the
1076 * same frame as ever.
1077 */
1078 function startRoom(code: string): void {
1079 if (renderer === null || !policyIn || host.connect === undefined) return;
1080 const name = nameField.value.trim().slice(0, 24) || "you";
1081 host.storage.setItem("cs.name", name);
1082 session?.close();
1083 world = null;
1084 session = createSession({
1085 connect: host.connect,
1086 url: `/ws?room=${encodeURIComponent(code)}&name=${encodeURIComponent(name)}`,
1087 code,
1088 now: () => win.performance.now(),
1089 build: (hello) => {
1090 const level = hello.map === "de_forge" ? DE_FORGE : generateLevel(seedOf(hello.map) ?? 0);
1091 if (level !== shown && renderer !== null) {
1092 renderer.replace("level", meshFor("level", level));
1093 shown = level;
1094 }
1095 playingId.textContent = `room ${code}`;
1096 mapName.textContent = level.name;
1097 pauseId.textContent = `Room ${code} on ${level.name}.`;
1098 return createWorld(level, hello.rules === "long" ? COMPETITIVE : SHORT, name, "ct", 5, hello.seed);
1099 },
1100 });
1101 loadout = createLoadout();
1102 roomBuy = null;
1103 posted = -1;
1104 postedFinal = true; // a room's result is the server's to write, never this page's
1105 loop.reset();
1106 controls.sensitivity = clamp(Number(sensField.value) || 2.2, 0.2, 12);
1107 running = true;
1108 lastSound = 0;
1109 show(menu, false);
1110 show(pause, false);
1111 show(summaryPanel, false);
1112 show(touchPanel, win.matchMedia("(pointer: coarse)").matches);
1113 audio.resume();
1114 void sceneCanvas.requestPointerLock();
1115 }
1116
1117 joinRoom.addEventListener("click", () => {
1118 const code = roomCodeFrom(roomField.value);
1119 if (code === null) {
1120 roomSaid.textContent = NO_ROOM;
1121 return;
1122 }
1123 roomField.value = code;
1124 startRoom(code);
1125 });
1126
1127 /**
1128 * A room of one's own: the server makes it — a world it will step until the
1129 * room empties — and hands back the code a person reads out. The map and the
1130 * length are the menu's own two choices; everything else about the room is the
1131 * server's.
1132 */
1133 makeRoom.addEventListener("click", () => {
1134 const chosen = chosenMap();
1135 void host.fetch("/api/room", {
1136 method: "POST",
1137 headers: { "content-type": "application/json" },
1138 body: JSON.stringify({ map: chosen === "generated" ? "de_forge" : chosen, length: lengthField.value }),
1139 }).then(
1140 (reply) => void reply.text().then(
1141 (body) => {
1142 let code: string | null = null;
1143 try {
1144 code = roomCodeFrom((JSON.parse(body) as { code?: unknown }).code);
1145 } catch {
1146 code = null;
1147 }
1148 if (!reply.ok || code === null) {
1149 roomSaid.textContent = NO_MAKE;
1150 return;
1151 }
1152 roomField.value = code;
1153 startRoom(code);
1154 },
1155 () => void (roomSaid.textContent = NO_MAKE),
1156 ),
1157 () => void (roomSaid.textContent = NO_MAKE),
1158 );
1159 });
1160
10301161 play.addEventListener("click", start);
10311162 // Resume is the gesture that puts back whatever the pause cost: the screen
10321163 // first, if a browser took it, and then the pointer.
@@ -1043,6 +1174,8 @@ export function boot(host: Host): Frame {
10431174 */
10441175 function toMenu(): void {
10451176 running = false;
1177 session?.close();
1178 session = null;
10461179 world = null;
10471180 wantsFull = false;
10481181 full.exit();
@@ -1208,11 +1341,18 @@ export function boot(host: Host): Frame {
12081341 planAt = null;
12091342 drawMapPlan();
12101343 }
1344 // In a room the world is the session's, and there is nothing to draw until
1345 // the first snapshot has described it.
1346 if (session !== null) {
1347 world = session.world();
1348 const said = session.standing(now);
1349 roomSaid.textContent = `Room ${said.code} — ${said.line}. ${said.people} here.`;
1350 }
12111351 if (world === null || !running) return;
12121352
12131353 // A round nobody has planned yet — the first of a match, or the one that
12141354 // just started — is planned here, on the frame that noticed it.
1215 if (world.match.round !== plannedRound) planFor(world);
1355 if (session === null && world.match.round !== plannedRound) planFor(world);
12161356 // And the same frame rolls the wallet's memory over: what was bought in the
12171357 // round that just ended is what X buys back in this one.
12181358 atRound(loadout, world.match.round);
@@ -1237,12 +1377,28 @@ export function boot(host: Host): Frame {
12371377 slotWanted === 1 ? me.primary : null;
12381378
12391379 const steps = loop.advance(elapsed);
1380 const switchCode =
1381 slotWanted === 3 ? SWITCH_KNIFE :
1382 slotWanted === 2 ? SWITCH_SECONDARY :
1383 slotWanted === 1 ? SWITCH_PRIMARY : SWITCH_NONE;
12401384 // Buying keeps the pointer, so the keys have to be kept off the player: with
12411385 // the panel open the trigger is dead and the number row buys instead of
12421386 // switching weapons. Movement is not suppressed, and never was — walking out
12431387 // of spawn while you buy is the point.
1244 for (let step = 0; step < steps; step++)
1245 update(world, TICK, buyPanel.hidden ? input : { ...input, fire: false, switchTo: null });
1388 if (session !== null) {
1389 // A room's round is stepped on the server: what goes up is one frame of
1390 // keys for the tick the loop just counted — coalesced into one message
1391 // however many ticks this frame was worth, because a tick the page never
1392 // simulated has no separate keys to send. The buy is asked for once.
1393 const held = buyPanel.hidden ? input : { ...input, fire: false, switchTo: null };
1394 if (steps > 0) {
1395 session.press({ input: held, yaw: me.yaw, pitch: me.pitch, switchCode, buy: roomBuy });
1396 roomBuy = null;
1397 }
1398 } else {
1399 for (let step = 0; step < steps; step++)
1400 update(world, TICK, buyPanel.hidden ? input : { ...input, fire: false, switchTo: null });
1401 }
12461402
12471403 // The camera, and with it whose eyes this frame is drawn and heard from.
12481404 // It is moved after the steps and before anything reads it, and it is the
src/client/session.ts added +178
@@ -0,0 +1,178 @@
1 /**
2 * The page's end of a room: one socket, one world it writes into, and the two
3 * sentences it can say about the connection.
4 *
5 * A member of a room does not simulate. The round is stepped on the server
6 * (`src/rooms/room.ts`) and this fills a local world from the snapshots that
7 * arrive, so everything already built on a `World` — the scene, the HUD, the
8 * minimap, the sounds, the spectator camera — draws a room without knowing it
9 * is one. Two things are the browser's own even so: the view angles, which the
10 * frame writes on `world.me` and sends upstream, so aim answers the hand rather
11 * than the network; and nothing else. Your body's position is the server's
12 * word, arriving 16 times a second — which is honest and is also the lag a
13 * player feels in a room today. Predicting your own movement is #305's
14 * remaining half and is deliberately not faked here.
15 *
16 * Nothing in here is a browser either: `connect` is handed in, so `session.test.ts`
17 * plays a whole room through a socket of plain objects.
18 */
19
20 import type { Hello, Pressed } from "../rooms/wire.ts";
21 import { applySnapshot, forget, readDown, writeInput, SNAPSHOT_FRAME } from "../rooms/wire.ts";
22 import type { Player, World } from "./world.ts";
23
24 /** All this file asks a WebSocket to be. A browser's is one of these. */
25 export type Socket = {
26 binaryType: string;
27 send(data: Uint8Array): void;
28 close(): void;
29 onopen: (() => void) | null;
30 onmessage: ((event: { data: unknown }) => void) | null;
31 onclose: (() => void) | null;
32 onerror: (() => void) | null;
33 };
34
35 /** What the page shows about a room while it is up. */
36 export type Standing = {
37 /** The room's code, which is what a person reads out to their friends. */
38 code: string;
39 /** Whether the socket is open right now. */
40 live: boolean;
41 /** Milliseconds since the last snapshot landed, or −1 before the first one. */
42 behind: number;
43 /** One sentence: what the connection is doing. */
44 line: string;
45 /** How many people are in the room, bots not counted. */
46 people: number;
47 };
48
49 export type Session = {
50 /** The world the snapshots are filling, or null until the first `hello`. */
51 world(): World | null;
52 /** Which body is ours, or −1 before `hello`. */
53 seat(): number;
54 standing(now: number): Standing;
55 /** One tick of keys upstream. Dropped silently while the socket is not open. */
56 press(pressed: Omit<Pressed, "tick">): void;
57 close(): void;
58 };
59
60 /** The sentences a room says about itself. Exported because the tests read them. */
61 export const CONNECTING = "Joining the room…";
62 export const PLAYING = "In the room";
63 export const BEHIND = "The connection is behind — the round is still the server's";
64 export const DROPPED = "The room closed the connection. Leave the match and join again.";
65
66 /** How far behind the last snapshot has to be before the page says so, in ms. */
67 export const BEHIND_MS = 500;
68
69 export function createSession(options: {
70 connect(url: string): Socket;
71 /** Where to connect: the socket's path with the room and the name on it. */
72 url: string;
73 code: string;
74 /**
75 * The world a `hello` describes, built by the caller — which is the one place
76 * that knows how to carve a level and make a world of this game. Handed the
77 * message rather than the pieces, so a field added to `hello` needs no change
78 * here.
79 */
80 build(hello: Hello): World;
81 now(): number;
82 }): Session {
83 const socket = options.connect(options.url);
84 socket.binaryType = "arraybuffer";
85 let world: World | null = null;
86 let seat = -1;
87 let live = false;
88 let closed = false;
89 let landed = -1;
90 let people = 0;
91 /** What the next input frame is stamped with: our own count of ticks sent. */
92 let sent = 0;
93 const frame = new Uint8Array(16);
94
95 socket.onopen = (): void => void (live = true);
96 socket.onclose = (): void => {
97 live = false;
98 closed = true;
99 };
100 socket.onerror = (): void => void (live = false);
101 socket.onmessage = (event): void => {
102 const data = event.data;
103 if (typeof data === "string") {
104 const down = readDown(data);
105 if (down === null) return;
106 if (down.k === "hello") {
107 seat = down.seat;
108 const built = options.build(down);
109 // A body nobody else is holding is a bot to everything that reads the
110 // world; ours is not, and it is the one the camera and the HUD follow.
111 built.me = (built.players[seat] ?? built.me) as Player;
112 built.me.bot = false;
113 forget(built);
114 world = built;
115 apply(down.roster);
116 } else if (down.k === "roster") {
117 apply(down.roster);
118 } else if (world !== null) {
119 // The words of the round, which are written inside the server's own tick.
120 for (const entry of down.feed) world.feed.push({ ...entry, weapon: entry.weapon as never });
121 for (const line of down.say) world.messages.push(line);
122 if (world.feed.length > 8) world.feed.splice(0, world.feed.length - 8);
123 if (world.messages.length > 8) world.messages.splice(0, world.messages.length - 8);
124 }
125 return;
126 }
127 if (world === null || seat < 0) return;
128 const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data instanceof Uint8Array ? data : null;
129 if (bytes === null || bytes[0] !== SNAPSHOT_FRAME) return;
130 const got = applySnapshot(world, bytes, seat);
131 if (got === null) return;
132 landed = options.now();
133 // Our own body: the server's position, handed over rather than written in
134 // (`wire.ts`), so this is the one place a page decides what to do with it.
135 if (got.own !== null) {
136 world.me.pos = got.own.pos;
137 world.me.vel = got.own.vel;
138 }
139 };
140
141 /** Names and bots off the roster, onto the bodies they belong to. */
142 function apply(roster: Hello["roster"]): void {
143 if (world === null) return;
144 people = 0;
145 for (const seated of roster) {
146 const body = world.players[seated.seat];
147 if (body === undefined) continue;
148 body.name = seated.name;
149 body.bot = seated.bot;
150 if (!seated.bot) people += 1;
151 }
152 // Ours is ours whatever the roster says about the body: the camera follows it.
153 if (world.players[seat] !== undefined) world.me = world.players[seat] as Player;
154 }
155
156 return {
157 world: () => world,
158 seat: () => seat,
159 standing(now) {
160 const behind = landed < 0 ? -1 : now - landed;
161 const line =
162 closed ? DROPPED :
163 !live || landed < 0 ? CONNECTING :
164 behind > BEHIND_MS ? BEHIND : PLAYING;
165 return { code: options.code, live, behind, line, people };
166 },
167 press(pressed) {
168 if (!live) return;
169 sent += 1;
170 socket.send(writeInput({ ...pressed, tick: sent }, frame));
171 },
172 close() {
173 closed = true;
174 live = false;
175 socket.close();
176 },
177 };
178 }
src/client/stage.test-helper.ts +1 −1
@@ -207,7 +207,7 @@ export type FakeStage = {
207207 * contract between the page and this file, so `src/server.test.ts` checks the
208208 * served page against this same list rather than a second copy of it.
209209 */
210 export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "prep", "resume", "quit", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full"];
210 export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "room", "joinroom", "makeroom", "roomsaid", "prep", "resume", "quit", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full"];
211211
212212 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
213213 const HIDDEN = ["pause", "summary", "buy", "touch", "full"];
src/client/world.test.ts +45 −2
@@ -8,14 +8,14 @@ import { WEAPONS } from "./weapons.ts";
88 import { buy } from "./economy.ts";
99 import {
1010 aimAt, applyInput, bodyCentre, canSee, createWorld, currentWeapon, deployTicks, EFFECT_CAPACITY,
11 eyePos, insideSmoke, NO_INPUT, shoot, startRound, throwOne, update,
11 eyePos, insideSmoke, NO_INPUT, shoot, startRound, throwOne, update, updateWith,
1212 type Input, type Player, type Sound, type World,
1313 } from "./world.ts";
1414 import { cloudRadius, type Cloud } from "./grenade.ts";
1515 import { HE, SMOKE } from "./weapons.ts";
1616 import { CS_HULL } from "./movement.ts";
1717 import {
18 effectAt, eyeHeight, pushEffect, type Burst, type SoundDevice, type Tone, type Vec3,
18 angleVectors, effectAt, eyeHeight, pushEffect, type Burst, type SoundDevice, type Tone, type Vec3,
1919 } from "../engine/index.ts";
2020
2121 const TICK = 1 / 64;
@@ -655,3 +655,46 @@ test("a blast you are caught in came from the blast, and not from whoever threw
655655 expect(Math.hypot(hit.from[0] - world.me.pos[0], hit.from[1] - world.me.pos[1]))
656656 .toBeLessThan(HE.blast.radius);
657657 });
658
659 /**
660 * The tick's inputs are asked for body by body (`updateWith`), which is what
661 * lets a room drive five people through one world. Two claims, and the room in
662 * `src/rooms/` is built on both: a body somebody drives does what they pressed
663 * rather than what a bot would, and a body nobody drives and nobody thinks for
664 * stands still.
665 */
666 test("a tick asks for each body's input, and a driven bot does what it was told", () => {
667 const world = createWorld(DE_FORGE, SHORT, "you", "ct", 5, 41);
668 world.match.phase = "live";
669 world.match.timer = SHORT.roundTime;
670 const seat = world.players.find((p) => p.team === "ct" && p !== world.me) as Player;
671 const was: Vec3 = [...seat.pos] as Vec3;
672 // Driven straight forward for half a second, with every other body left to
673 // the bots — including `me`, who nobody is driving here.
674 const driving: Input = { ...NO_INPUT, forward: 1 };
675 for (let i = 0; i < 32; i++) updateWith(world, TICK, (p) => (p === seat ? driving : undefined));
676
677 // It moved, and it moved along its own yaw rather than wherever a bot wanted:
678 // nearly all of the ground it covered is in front of where it is looking.
679 const went = Math.hypot(seat.pos[0] - was[0], seat.pos[1] - was[1]);
680 expect(went).toBeGreaterThan(50);
681 const { forward } = angleVectors(seat.yaw, 0);
682 const along = (seat.pos[0] - was[0]) * forward[0] + (seat.pos[1] - was[1]) * forward[1];
683 expect(along).toBeGreaterThan(went * 0.9);
684 });
685
686 test("a body nobody drives and nobody thinks for stands still", () => {
687 const world = createWorld(DE_FORGE, SHORT, "you", "ct", 5, 41);
688 world.match.phase = "live";
689 world.match.timer = SHORT.roundTime;
690 // Every bot turned into a person nobody is holding the keys for: ten bodies,
691 // no input anywhere, so nothing in the round moves at all.
692 for (const p of world.players) p.bot = false;
693 const before = world.players.map((p) => [...p.pos] as Vec3);
694 for (let i = 0; i < 64; i++) updateWith(world, TICK, () => undefined);
695
696 world.players.forEach((p, i) => {
697 const was = before[i] as Vec3;
698 expect(Math.hypot(p.pos[0] - was[0], p.pos[1] - was[1])).toBeLessThan(0.001);
699 });
700 });
src/client/world.ts +43 −7
@@ -112,7 +112,14 @@ export type Effect =
112112 | { kind: "tracer"; from: Vec3; to: Vec3; at: number }
113113 | { kind: "impact"; at: number; pos: Vec3; normal: Vec3 }
114114 | { kind: "blood"; at: number; pos: Vec3 }
115 | { kind: "shot"; at: number; pos: Vec3; weapon: WeaponId; mine: boolean }
115 /**
116 * A gun going off, where and which. `mine` is whether it is the gun on *this*
117 * screen — the frame draws its own muzzle differently — and `by` is whose body
118 * fired it, which is the same question asked in a form that survives a wire: a
119 * room's snapshot goes to everybody at once, so `mine` cannot travel and the
120 * id it is derived from can (`src/rooms/wire.ts`).
121 */
122 | { kind: "shot"; at: number; pos: Vec3; weapon: WeaponId; mine: boolean; by: number }
116123 | { kind: "hitmark"; at: number; headshot: boolean }
117124 /**
118125 * Damage *you* took, and where it came from: the shooter's feet, or the
@@ -552,7 +559,7 @@ export function shoot(world: World, p: Player): boolean {
552559 const dir = angleVectors(yaw, pitch).forward;
553560 const to: Vec3 = [from[0] + dir[0] * range, from[1] + dir[1] * range, from[2] + dir[2] * range];
554561 const hit = traceBullet(from, to, world.level.boxes, world.players, p.id);
555 record(world, { kind: "shot", at: world.time, pos: from, weapon: w.id, mine: p === world.me });
562 record(world, { kind: "shot", at: world.time, pos: from, weapon: w.id, mine: p === world.me, by: p.id });
556563 if (w.kind !== "knife") record(world, { kind: "tracer", at: world.time, from, to: hit.kind === "none" ? to : hit.point });
557564
558565 if (hit.kind === "world") {
@@ -793,8 +800,27 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v
793800 bombTick(world, p, input.use, dt);
794801 }
795802
796 /** One fixed simulation tick. `input` is the person's; bots think for themselves. */
797 export function update(world: World, dt: number, input: Input): void {
803 /**
804 * Where a tick gets its inputs: one body in, the input whoever is driving it
805 * pressed this tick, or undefined for a body nobody is driving. A bot nobody
806 * drives thinks for itself; a person's body nobody drives stands still.
807 *
808 * It is a function rather than a map because the two callers ask different
809 * questions of it. The single-player game drives exactly one body — its own —
810 * and a room drives as many bodies as it has members in seats, the rest left to
811 * the bots. Neither needs the other's shape, and `update` below is the one
812 * place either answer is turned into a tick.
813 */
814 export type Inputs = (p: Player) => Input | undefined;
815
816 /**
817 * One fixed simulation tick, with the inputs asked for body by body. Bodies are
818 * stepped in the order they are in `world.players`, which is the order the
819 * single-player game always stepped them in — its own first, because it is
820 * `players[0]`, and then the bots — so a round played through here and a round
821 * played through `update` below are the same round, tick for tick.
822 */
823 export function updateWith(world: World, dt: number, inputs: Inputs): void {
798824 world.time += dt;
799825 const before = world.match.phase;
800826 for (const event of rules.tick(world.match, dt)) {
@@ -838,10 +864,10 @@ export function update(world: World, dt: number, input: Input): void {
838864 // and it does so for everybody, standing or dead, before anyone acts on it.
839865 for (const p of world.players) if (p.blind > 0) p.blind = Math.max(0, p.blind - dt);
840866
841 applyInput(world, world.me, world.me.alive ? input : NO_INPUT, dt);
842867 for (const p of world.players) {
843 if (!p.bot || !p.alive) continue;
844 applyInput(world, p, botThink(world, p, dt), dt);
868 const driven = inputs(p);
869 if (driven !== undefined) applyInput(world, p, p.alive ? driven : NO_INPUT, dt);
870 else if (p.bot && p.alive) applyInput(world, p, botThink(world, p, dt), dt);
845871 }
846872 // After everyone has moved, so a grenade is solved against where people
847873 // ended the tick rather than where they started it.
@@ -859,6 +885,16 @@ export function update(world: World, dt: number, input: Input): void {
859885 expireEffects(world.effects, world.time, EFFECT_LIFE);
860886 }
861887
888 /**
889 * One fixed simulation tick of the game one person plays in one browser:
890 * `input` is theirs, and every other body is a bot thinking for itself. It is
891 * `updateWith` with the one body this page is holding the keys for, and it is
892 * the whole of what the single-player game ever needed.
893 */
894 export function update(world: World, dt: number, input: Input): void {
895 updateWith(world, dt, (p) => (p === world.me ? input : undefined));
896 }
897
862898 /**
863899 * Is `target` visible to `from`, eye to eye? The one question the whole game
864900 * asks about sight: the bots pick their fights with it, a bot deciding whether
src/games.ts +6 −6
@@ -98,13 +98,13 @@ export const GAMES: readonly Game[] = [
9898 label: "cs",
9999 entry: "src/client/main.ts",
100100 /**
101 * Raised by #153, which made the buy panel keyboard-driven: the number row
102 * on the tiles, the rebuy and the loadout it remembers. 103,334 raw and
103 * 40,719 gzipped, measured by `bun run scripts/build.ts` — 334 bytes over
104 * the ceiling #165 set for the smoke grenade, and worth it for a round that
105 * is bought without the pointer leaving the game.
101 * Raised by #300, which put multiplayer in the page: `src/client/session.ts`
102 * is the room's end of the socket and `src/rooms/wire.ts` is the codec both
103 * ends read. 115,156 raw and 44,864 gzipped, measured by
104 * `bun run scripts/build.ts` — 1.8 kB of gzip for a round five people can
105 * play together, and the rooms' own server half ships in no bundle at all.
106106 */
107 ceiling: { raw: 110_000, gzip: 43_500 },
107 ceiling: { raw: 122_000, gzip: 47_500 },
108108 assets: [
109109 {
110110 name: "weights",
src/pages/game.ts +10
@@ -356,6 +356,16 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => `
356356 </div>
357357 </div>
358358 <div class="row"><button type="button" class="primary" id="play" disabled>Play</button></div>
359 <div class="row">
360 <label>Room code<input id="room" maxlength="6" placeholder="ABCDEF" autocapitalize="characters"
361 autocomplete="off" spellcheck="false"></label>
362 <button type="button" id="joinroom" disabled>Join room</button>
363 <button type="button" id="makeroom" disabled>Make a room</button>
364 </div>
365 <p class="hint" id="roomsaid">Play with friends: make a room, read the code out, and they join it. A room&#39;s
366 round is decided on this server, so everyone in it sees the same round — the match above runs in your
367 own browser against bots. Rooms live in this process: a deploy ends them, and an empty one is forgotten
368 after ninety seconds.</p>
359369 <p class="hint" id="prep">Preparing textures…</p>
360370 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk ·
361371 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>G</kbd> throw an HE · <kbd>T</kbd> throw a flashbang · <kbd>V</kbd> throw a smoke ·
src/rooms/code.ts added +59
@@ -0,0 +1,59 @@
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
19 /** The symbols a code is made of, and the only ones. */
20 export const ALPHABET = "ABCDEFGHJKMNPQRTUVWXYZ2346789";
21
22 /** How many characters a code is. */
23 export const LENGTH = 6;
24
25 const SHAPE = new RegExp(`^[${ALPHABET}]{${LENGTH}}$`);
26
27 /** Is this a room code at all? The shape above, and nothing else. */
28 export const isRoomCode = (value: unknown): value is string =>
29 typeof value === "string" && SHAPE.test(value);
30
31 /**
32 * The code a caller handed over, upper-cased first because a person typing one
33 * types it however their keyboard was, or null when it is not a code. This is
34 * the boundary: the server's upgrade goes through it and the page's field goes
35 * through it again, and neither trusts the other.
36 */
37 export function roomCodeFrom(value: unknown): string | null {
38 if (typeof value !== "string") return null;
39 const code = value.trim().toUpperCase();
40 return isRoomCode(code) ? code : null;
41 }
42
43 /**
44 * A fresh code. `bytes` is a source of random bytes — `crypto.getRandomValues`
45 * in both a browser and Bun — and is a parameter so a test can hand over a
46 * sequence it chose.
47 *
48 * The modulo is not quite uniform over 256: with 29 symbols the first 24 are
49 * drawn slightly more often than the last 5, by about four per cent. That is
50 * not a weakness worth code here — it costs a fraction of a bit out of the
51 * thirty a code carries — and rejection sampling would be a loop with a
52 * pathological case in the one place this app cannot afford one.
53 */
54 export function newRoomCode(bytes: (into: Uint8Array) => Uint8Array = (into) => crypto.getRandomValues(into)): string {
55 const drawn = bytes(new Uint8Array(LENGTH));
56 let code = "";
57 for (let i = 0; i < LENGTH; i++) code += ALPHABET[(drawn[i] as number) % ALPHABET.length];
58 return code;
59 }
src/rooms/hub.ts added +225
@@ -0,0 +1,225 @@
1 /**
2 * Every room this process is holding, and the one clock that steps them.
3 *
4 * A room is CPU on a shared box, so this file is mostly numbers with reasons.
5 * One room is one world stepped 64 times a second: measured by
6 * `src/budget.test.ts` a tick of ten bodies with nine of them thinking costs
7 * about 0.08 ms, so a room costs about **5 ms of CPU a second** — half a per
8 * cent of one core — and `ROOM_CAP` rooms at once is a tenth of a core plus the
9 * snapshots. That is the whole argument for the cap being where it is, and it is
10 * the number to revisit before raising it.
11 *
12 * Nothing here is a socket either. The hub is handed a `Wire` — deliver these
13 * bytes to that member, close that member — and the server is the only file that
14 * knows what one really is. So `hub.test.ts` runs a hub with five members, an
15 * idle sweep and a room cap without opening a port.
16 *
17 * Rooms live in memory and die with the process. Nothing is written under
18 * `FORGE_APP_DATA`: a half-finished round is not worth keeping, a deploy ends
19 * every room it was holding, and the page says so rather than implying a room
20 * survives one.
21 */
22
23 import { createLimiter, type Limiter } from "../limit.ts";
24 import { newRoomCode } from "./code.ts";
25 import {
26 createRoom, describeRoom, drive, helloFor, isIdle, join, leave, logSince, rosterMessage, snapshotFor,
27 stepRoom, SNAPSHOT_HZ, TICK, type Member, type Room,
28 } from "./room.ts";
29 import type { PlayedMap } from "../mapname.ts";
30
31 /**
32 * How many rooms one deployment holds at once. Twenty rooms is about 0.1 s of
33 * CPU a second — a tenth of a core — against a box that also serves pages, and
34 * two hundred people in them at ten a room. Past it a create is refused with a
35 * sentence, which is the honest answer: a twenty-first room would make the
36 * other twenty judder.
37 */
38 export const ROOM_CAP = 20;
39
40 /**
41 * How long a room with nobody in it is kept, in milliseconds. Long enough that
42 * a person who created a room, read the code out loud and reloaded the page
43 * comes back to the same round; short enough that a forgotten room is a minute
44 * of a core and not an hour of one.
45 */
46 export const IDLE_MS = 90_000;
47
48 /**
49 * How long a connection that says nothing at all is kept, in milliseconds. A
50 * member's browser sends an input frame every tick while the page is open, so
51 * thirty seconds of silence is a tab that is gone, a laptop that slept or a
52 * network that will not come back — and the seat it is holding belongs to
53 * somebody who is here.
54 */
55 export const SILENT_MS = 30_000;
56
57 /**
58 * The most bytes one frame from a member may be. An input frame is fourteen; a
59 * ceiling of sixty-four leaves room for the wire to grow a field without
60 * becoming somewhere to push a megabyte. Past it the connection is closed
61 * rather than the frame dropped: a member sending frames of the wrong size is
62 * not a member playing this game.
63 */
64 export const MESSAGE_MAX = 64;
65
66 /** WebSocket close codes this app sends, from the private range 4000–4999. */
67 export const CLOSE_TOO_BIG = 4001;
68 export const CLOSE_FLOOD = 4002;
69 export const CLOSE_SILENT = 4003;
70 export const CLOSE_GONE = 4004;
71
72 /**
73 * How many frames a second one connection may send. The page sends one a tick,
74 * 64 a second; a burst of 160 and one more every 8 ms is two and a half times
75 * that, which absorbs a tab that was descheduled and caught up, and is a wall
76 * to anything trying to make this process do more work than a player can.
77 */
78 export const FRAME_LIMIT = { burst: 160, per: 8 };
79
80 /**
81 * The most ticks one call of `advance` will step a room. A quarter of a second:
82 * past that the room is behind and stays behind rather than stealing the whole
83 * process to catch up.
84 */
85 export const MAX_CATCHUP = 16;
86
87 /** What the hub needs a connection to be, which is two functions. */
88 export type Wire = {
89 /** These bytes, or this line of JSON, to that member. */
90 deliver(id: string, frame: Uint8Array | string): void;
91 /** That member, disconnected, with a code and a sentence. */
92 close(id: string, code: number, reason: string): void;
93 };
94
95 export type Hub = {
96 rooms: Map<string, Room>;
97 /** A fresh room, or null when this process is already holding `ROOM_CAP`. */
98 open(options: { map: PlayedMap; seed: number; length: "short" | "long"; now: number }): Room | null;
99 find(code: string): Room | undefined;
100 /** Somebody joins a room by code. Null is a full room or a room that is gone. */
101 enter(code: string, id: string, name: unknown, now: number): { room: Room; member: Member } | null;
102 /** And leaves it, whichever room they were in. */
103 exit(id: string, now: number): void;
104 /** One frame off the wire from a member. False when it was refused. */
105 frame(id: string, bytes: Uint8Array, now: number): boolean;
106 /**
107 * The clock: step every room by however many ticks are due at `now`, send the
108 * snapshots that fall due, and drop what is idle or silent. Called from one
109 * interval in `src/server.ts` and from a test with a clock it moves itself.
110 */
111 advance(now: number): void;
112 /** How many members this process is holding, over every room. */
113 members(): number;
114 /** One line per room, for the terminal. */
115 lines(now: number): string[];
116 };
117
118 /** Where a member is: which room, and since when they were last heard from. */
119 type Seated = { code: string; heardAt: number };
120
121 export function createHub(wire: Wire, options: { now: number; limiter?: Limiter } = { now: Date.now() }): Hub {
122 const rooms = new Map<string, Room>();
123 const where = new Map<string, Seated>();
124 const limiter = options.limiter ?? createLimiter(FRAME_LIMIT);
125 /** The wall clock of the last step, so a tick is never stepped twice. */
126 let steppedAt = options.now;
127 /** When each room last sent its snapshots, on the wall clock. */
128 const sentAt = new Map<string, number>();
129
130 const send = (room: Room, member: Member): void => {
131 wire.deliver(member.id, snapshotFor(room, member));
132 };
133
134 const hub: Hub = {
135 rooms,
136 open(o) {
137 if (rooms.size >= ROOM_CAP) return null;
138 // A collision is a room that exists, not an error: draw again. Thirty bits
139 // against twenty rooms means this loop runs once, and it is bounded anyway.
140 let code = newRoomCode();
141 for (let tries = 0; tries < 8 && rooms.has(code); tries++) code = newRoomCode();
142 if (rooms.has(code)) return null;
143 const room = createRoom(code, o);
144 rooms.set(code, room);
145 sentAt.set(code, o.now);
146 return room;
147 },
148 find: (code) => rooms.get(code),
149 enter(code, id, name, now) {
150 const room = rooms.get(code);
151 if (room === undefined) return null;
152 const member = join(room, id, name, now);
153 if (member === null) return null;
154 where.set(id, { code, heardAt: now });
155 wire.deliver(id, JSON.stringify(helloFor(room, member)));
156 // Everybody, the arrival included, gets the roster the arrival changed.
157 const roster = JSON.stringify(rosterMessage(room));
158 for (const other of room.members.values()) wire.deliver(other.id, roster);
159 return { room, member };
160 },
161 exit(id, now) {
162 const seated = where.get(id);
163 where.delete(id);
164 if (seated === undefined) return;
165 const room = rooms.get(seated.code);
166 if (room === undefined) return;
167 if (leave(room, id, now) === null) return;
168 const roster = JSON.stringify(rosterMessage(room));
169 for (const other of room.members.values()) wire.deliver(other.id, roster);
170 },
171 frame(id, bytes, now) {
172 const seated = where.get(id);
173 if (seated === undefined) return false;
174 if (bytes.byteLength > MESSAGE_MAX) {
175 wire.close(id, CLOSE_TOO_BIG, "frame too large");
176 return false;
177 }
178 if (!limiter.take(`ws:${id}`, now)) {
179 wire.close(id, CLOSE_FLOOD, "too many frames");
180 return false;
181 }
182 seated.heardAt = now;
183 const room = rooms.get(seated.code);
184 return room !== undefined && drive(room, id, bytes);
185 },
186 advance(now) {
187 // Every 64 Hz step that has fallen due since the last call, capped: a
188 // process that was descheduled for a second does not then burn a second of
189 // CPU catching up on every room at once. Past the cap the rooms' clocks
190 // simply run slow for that moment, which is a hitch and not a hole.
191 const due = Math.min(Math.floor((now - steppedAt) / (TICK * 1000)), MAX_CATCHUP);
192 if (due > 0) steppedAt += due * TICK * 1000;
193 const interval = 1000 / SNAPSHOT_HZ;
194 for (const [code, room] of rooms) {
195 for (let i = 0; i < due; i++) stepRoom(room);
196 if (room.members.size > 0) room.usedAt = now;
197 const last = sentAt.get(code) ?? now;
198 if (now - last >= interval) {
199 sentAt.set(code, now);
200 const log = logSince(room);
201 const words = log === null ? null : JSON.stringify(log);
202 for (const member of room.members.values()) {
203 send(room, member);
204 if (words !== null) wire.deliver(member.id, words);
205 }
206 }
207 }
208 // A member nobody has heard from is holding a seat somebody else could
209 // have: closed here, and the close is what takes them out of the room.
210 for (const [id, seated] of where) {
211 if (now - seated.heardAt > SILENT_MS) wire.close(id, CLOSE_SILENT, "no input for thirty seconds");
212 }
213 for (const [code, room] of rooms) {
214 if (isIdle(room, now, IDLE_MS)) {
215 rooms.delete(code);
216 sentAt.delete(code);
217 }
218 }
219 },
220 members: () => where.size,
221 lines: (now) => [...rooms.values()].map((room) => describeRoom(room, now)),
222 };
223 return hub;
224 }
225
src/rooms/room.ts added +366
@@ -0,0 +1,366 @@
1 /**
2 * One room: a code, a world stepped in *this* process, and up to ten bodies —
3 * bots until somebody sits in one.
4 *
5 * This is where multiplayer stops being a relay and becomes a game. The round
6 * is not simulated in anybody's browser: `createWorld` and `updateWith` from
7 * `src/client/world.ts` run here at the game's own 64 Hz, every member's keys
8 * are one body's input, and what each of them sees is a snapshot of the same
9 * numbers. So two people in a room cannot disagree about who shot first, and
10 * nothing a page says about the round is taken on trust.
11 *
12 * What a seat is, and the rule that makes it safe: **a member holds exactly one
13 * body, and a body is held by at most one member.** Joining takes a bot's seat
14 * — so the round is always five against five — and leaving hands it straight
15 * back to the bot with the money, the guns and the position it had, under the
16 * bot's own name. An input for a seat nobody holds, or for somebody else's,
17 * never reaches the world: `drive` looks the member up and writes to their own
18 * seat or to nothing.
19 *
20 * Nothing here is a socket. A room is plain data with a world in it: `join`,
21 * `leave`, `drive`, `stepRoom` and `snapshotFor` are functions of their
22 * arguments, `src/rooms/hub.ts` holds the rooms and `src/server.ts` is the only
23 * file that knows what a WebSocket is. That is what lets `room.test.ts` play a
24 * whole round with five members in it and no network at all.
25 */
26
27 import { DE_FORGE } from "../client/map.ts";
28 import { generateLevel } from "../client/generate.ts";
29 import { buy as spend, canBuy } from "../client/economy.ts";
30 import type { Level } from "../client/level.ts";
31 import { COMPETITIVE, SHORT, type Rules, type Team } from "../client/match.ts";
32 import { seedOf, type PlayedMap } from "../mapname.ts";
33 import {
34 INPUT_BYTES, readInput, resolveSwitch, SNAPSHOT_MAX, writeSnapshot,
35 type Hello, type Log, type Pressed, type Roster, type Seated,
36 } from "./wire.ts";
37 import { createWorld, NO_INPUT, updateWith, type Input, type Player, type World } from "../client/world.ts";
38
39 /** The fixed step a room's world takes, which is the game's own. */
40 export const TICK = 1 / 64;
41
42 /**
43 * How many snapshots a member is sent a second. Sixteen is the number #302's
44 * arithmetic lands on and the number this ships at: a snapshot of a ten-body
45 * round is 544 bytes, so sixteen of them is 8.7 kB a second down to each member
46 * — half a megabyte a minute, which a phone on a mobile connection can hold —
47 * and the frame between two of them is 62 ms of interpolation, which is what
48 * `src/client/session.ts` hides. Thirty-two would double the bytes to buy a
49 * smoothness the interpolation already has.
50 */
51 export const SNAPSHOT_HZ = 16;
52
53 /**
54 * How long a member's last input is held before their body drops the keys, in
55 * ticks. An input frame is one tick, and the rule for a late one is that it is
56 * the member's last one — otherwise a packet lost at 250 u/s would stutter a
57 * body every time the network hiccups. But *held forever* is a body that runs
58 * into a wall for the rest of the match because a laptop closed, so a third of
59 * a second of silence is a body that stands still and keeps looking where it
60 * was looking.
61 */
62 export const INPUT_HOLD = 21;
63
64 /** How many bodies a round has, per side. Five, like the game. */
65 export const TEAM_SIZE = 5;
66
67 /** One person in a room, and the body they are holding. */
68 export type Member = {
69 /** The session that joined. Opaque here: the server makes it, this never reads it. */
70 id: string;
71 /** What the scoreboard calls them. Cleaned and made unique on the way in. */
72 name: string;
73 /** Which body is theirs — an index into `world.players`. */
74 seat: number;
75 /** When they arrived, on the wall clock. */
76 joined: number;
77 /** The tick of the newest input frame that has arrived from them. */
78 lastTick: number;
79 /** The world clock of the last snapshot they were sent, so an effect goes once. */
80 sentAt: number;
81 /** How many frames of theirs were refused for being the wrong shape. */
82 dropped: number;
83 };
84
85 export type Room = {
86 code: string;
87 created: number;
88 map: PlayedMap;
89 seed: number;
90 length: "short" | "long";
91 rules: Rules;
92 level: Level;
93 world: World;
94 /** How many 64 Hz steps this room's world has taken. The tick an input names. */
95 tick: number;
96 members: Map<string, Member>;
97 /** The latest input for each seat, by seat, and the tick it was pressed on. */
98 pressed: Map<number, { pressed: Pressed; tick: number }>;
99 /** What each bot is called, by seat, so a leave gives the name back. */
100 botNames: string[];
101 /** The buy each seat has already been charged for, so one ask is one buy. */
102 bought: Map<number, string>;
103 /** The world clock of the newest feed entry and line already sent out. */
104 loggedFeed: number;
105 loggedSay: number;
106 /** When the room last had a member in it, on the wall clock. */
107 usedAt: number;
108 /** One buffer per room, written through by every snapshot. */
109 frame: Uint8Array;
110 };
111
112 /** The level a room stands on: de_forge, or the one that map name carves. */
113 export const levelOf = (map: PlayedMap): Level =>
114 map === "de_forge" ? DE_FORGE : generateLevel(seedOf(map) ?? 0);
115
116 /**
117 * A room on a map, with ten bots in it. The world is the shooter's own and so is
118 * every rule in it; the only thing done to it that the single-player game does
119 * not is that `world.me` is a bot like the other nine — a room has no "the
120 * person", it has seats, and the body that happens to be `players[0]` is not
121 * special until somebody sits in it.
122 */
123 export function createRoom(
124 code: string,
125 options: { map: PlayedMap; seed: number; length: "short" | "long"; now: number },
126 ): Room {
127 const rules = options.length === "long" ? COMPETITIVE : SHORT;
128 const level = levelOf(options.map);
129 const world = createWorld(level, rules, "Sentinel", "ct", TEAM_SIZE, options.seed);
130 // Every body a bot, `players[0]` included: see above.
131 world.me.bot = true;
132 return {
133 code,
134 created: options.now,
135 map: options.map,
136 seed: options.seed,
137 length: options.length,
138 rules,
139 level,
140 world,
141 tick: 0,
142 members: new Map(),
143 pressed: new Map(),
144 botNames: world.players.map((p) => p.name),
145 bought: new Map(),
146 loggedFeed: 0,
147 loggedSay: 0,
148 usedAt: options.now,
149 frame: new Uint8Array(SNAPSHOT_MAX),
150 };
151 }
152
153 /** Who is in this room, in seat order: everyone, bots included. */
154 export const rosterOf = (room: Room): Seated[] =>
155 room.world.players.map((p, seat) => ({ seat, name: p.name, bot: p.bot, team: p.team as "t" | "ct" }));
156
157 export const rosterMessage = (room: Room): Roster => ({ k: "roster", roster: rosterOf(room) });
158
159 /**
160 * A name nobody else in this room already has: trimmed, capped, stripped of
161 * anything that is not printable, and suffixed with a number if it collides.
162 * Two members called "you" would make the kill feed a lie — the feed is names —
163 * so the second one is "you (2)".
164 */
165 export function uniqueName(room: Room, wanted: unknown): string {
166 const base = (typeof wanted === "string" ? wanted : "")
167 .replace(/[^\x20-\x7e]/g, "")
168 .trim()
169 .slice(0, 20) || "player";
170 const taken = new Set(room.world.players.map((p) => p.name));
171 if (!taken.has(base)) return base;
172 for (let n = 2; n < 100; n++) {
173 const tried = `${base} (${n})`;
174 if (!taken.has(tried)) return tried;
175 }
176 return base;
177 }
178
179 /** How many people are in a room, bots not counted. */
180 export const memberCount = (room: Room): number => room.members.size;
181
182 /**
183 * Which body a person arriving takes: a bot's, on whichever side has fewer
184 * people on it, so two friends joining land opposite each other only once the
185 * teams are even. Null when every body is already held, which is the member cap.
186 */
187 export function freeSeat(room: Room): number | null {
188 const held = { t: 0, ct: 0 };
189 for (const member of room.members.values()) {
190 const body = room.world.players[member.seat];
191 if (body !== undefined) held[body.team] += 1;
192 }
193 const want: Team = held.t <= held.ct ? "t" : "ct";
194 const on = (team: Team): number => room.world.players.findIndex((p) => p.bot && p.team === team);
195 const first = on(want);
196 if (first >= 0) return first;
197 const other = on(want === "t" ? "ct" : "t");
198 return other >= 0 ? other : null;
199 }
200
201 /**
202 * Somebody sits down. The bot in that seat stands aside — it keeps its money,
203 * its guns and where it is standing, because a round in progress does not stop
204 * for an arrival — and the seat is theirs until they leave.
205 *
206 * Null is a full room, which is the member cap and is answered as a sentence by
207 * the caller rather than as a dropped connection.
208 */
209 export function join(room: Room, id: string, wanted: unknown, now: number): Member | null {
210 const existing = room.members.get(id);
211 if (existing !== undefined) return existing;
212 const seat = freeSeat(room);
213 if (seat === null) return null;
214 const body = room.world.players[seat] as Player;
215 const name = uniqueName(room, wanted);
216 body.bot = false;
217 body.name = name;
218 // A bot's brain is left where it is: if this member leaves, the bot picks the
219 // body up with everything else it had.
220 const member: Member = { id, name, seat, joined: now, lastTick: 0, sentAt: 0, dropped: 0 };
221 room.members.set(id, member);
222 room.usedAt = now;
223 return member;
224 }
225
226 /** And stands up again: the bot takes the body back, under its own name. */
227 export function leave(room: Room, id: string, now: number): Member | null {
228 const member = room.members.get(id);
229 if (member === undefined) return null;
230 const body = room.world.players[member.seat];
231 if (body !== undefined) {
232 body.bot = true;
233 body.name = room.botNames[member.seat] ?? body.name;
234 }
235 room.members.delete(id);
236 room.pressed.delete(member.seat);
237 room.bought.delete(member.seat);
238 room.usedAt = now;
239 return member;
240 }
241
242 /**
243 * One frame of input off the wire, for the member it came from and for nobody
244 * else. The bytes are validated in `wire.ts` — every field in range or the
245 * frame is refused — and the seat is resolved here from the member, never from
246 * anything in the frame, which is what makes "an input for a slot you do not
247 * hold" a message that cannot be written rather than one that is checked for.
248 *
249 * An older tick than the one already in hand is dropped: a frame that overtook
250 * another on the way here must not undo it.
251 */
252 export function drive(room: Room, id: string, bytes: Uint8Array): boolean {
253 const member = room.members.get(id);
254 if (member === undefined) return false;
255 if (bytes.byteLength !== INPUT_BYTES) {
256 member.dropped += 1;
257 return false;
258 }
259 const pressed = readInput(bytes);
260 if (pressed === null) {
261 member.dropped += 1;
262 return false;
263 }
264 if (pressed.tick !== 0 && pressed.tick < member.lastTick) return false;
265 member.lastTick = pressed.tick;
266 room.pressed.set(member.seat, { pressed, tick: room.tick });
267 return true;
268 }
269
270 /**
271 * What one seat is doing this tick: the member's own keys, their own view
272 * angles, whatever they asked to hold, and whatever they asked to buy. Applied
273 * to the body before the world steps it, because a view angle is a property of
274 * the body the mover reads and not part of the `Input` it takes.
275 */
276 function driveSeat(room: Room, seat: number, p: Player): Input | undefined {
277 const held = room.pressed.get(seat);
278 if (held === undefined) return undefined;
279 // Silence for a third of a second is a body that drops the keys, not one that
280 // keeps running: see `INPUT_HOLD`.
281 if (room.tick - held.tick > INPUT_HOLD) return { ...NO_INPUT };
282 const { pressed } = held;
283 p.yaw = pressed.yaw;
284 p.pitch = pressed.pitch;
285 // One ask is one buy: the frame goes up every tick while the key is held, and
286 // the shelf would empty a wallet sixty-four times a second without this.
287 if (pressed.buy !== null) {
288 const key = `${pressed.tick}:${pressed.buy}`;
289 if (room.bought.get(seat) !== key) {
290 room.bought.set(seat, key);
291 if (canBuy(room.world, p)) spend(room.world, p, pressed.buy);
292 }
293 }
294 return { ...pressed.input, switchTo: resolveSwitch(pressed.switchCode, p) };
295 }
296
297 /** One 64 Hz step of this room's round, with every seat's own input in it. */
298 export function stepRoom(room: Room): void {
299 room.tick += 1;
300 const seats = new Map<Player, number>();
301 room.world.players.forEach((p, seat) => seats.set(p, seat));
302 updateWith(room.world, TICK, (p) => {
303 const seat = seats.get(p);
304 return seat === undefined || p.bot ? undefined : driveSeat(room, seat, p);
305 });
306 }
307
308 /**
309 * The bytes one member is sent now: the round, and everything that happened
310 * since *their* last snapshot. It is per member rather than per room because
311 * that last clock is per member — somebody who joined half a second ago has a
312 * different idea of "since" than somebody who has been here all match — and
313 * because the buffer is one room's, written through each time.
314 */
315 export function snapshotFor(room: Room, member: Member): Uint8Array {
316 const bytes = writeSnapshot(room.world, room.tick, member.sentAt, room.frame);
317 member.sentAt = room.world.time;
318 return bytes;
319 }
320
321 /**
322 * The words of the round since the last time it was asked for: the kill feed and
323 * the lines in the middle of the screen. Both are written inside the world's own
324 * tick and a member's page never runs one, so they travel as themselves.
325 *
326 * Null when nothing was said, which is almost every beat — a room sends this
327 * only when there is something in it.
328 */
329 export function logSince(room: Room): Log | null {
330 const feed = room.world.feed.filter((entry) => entry.at > room.loggedFeed);
331 const say = room.world.messages.filter((line) => line.at > room.loggedSay);
332 if (feed.length === 0 && say.length === 0) return null;
333 for (const entry of feed) room.loggedFeed = Math.max(room.loggedFeed, entry.at);
334 for (const line of say) room.loggedSay = Math.max(room.loggedSay, line.at);
335 return {
336 k: "log",
337 feed: feed.map((e) => ({
338 at: e.at, killer: e.killer, victim: e.victim, weapon: e.weapon,
339 headshot: e.headshot, killerTeam: e.killerTeam as "t" | "ct",
340 })),
341 say: say.map((line) => ({ at: line.at, text: line.text })),
342 };
343 }
344
345 /** What a member is told the moment they are in: their seat, and the ground. */
346 export const helloFor = (room: Room, member: Member): Hello => ({
347 k: "hello",
348 code: room.code,
349 seat: member.seat,
350 map: room.map,
351 seed: room.seed,
352 rules: room.length,
353 tick: room.tick,
354 snapshotHz: SNAPSHOT_HZ,
355 roster: rosterOf(room),
356 });
357
358 /** A room with nobody in it, for longer than `idle` milliseconds. */
359 export const isIdle = (room: Room, now: number, idle: number): boolean =>
360 room.members.size === 0 && now - room.usedAt > idle;
361
362 /** For the terminal and the tests: one line about a room. */
363 export const describeRoom = (room: Room, now: number): string =>
364 `${room.code} ${String(room.members.size).padStart(2)}/${TEAM_SIZE * 2} on ${room.map}` +
365 ` round ${room.world.match.round} ${room.world.match.score.t}-${room.world.match.score.ct}` +
366 ` ${Math.round((now - room.created) / 1000)}s old`;
src/rooms/rooms.test.ts added +507
@@ -0,0 +1,507 @@
1 /**
2 * A room, played without a network. Everything in `src/rooms/` is plain data
3 * with a world in it, so every claim multiplayer rests on is made here: the
4 * code is a code, an input frame is validated at the boundary, a snapshot round
5 * trips, a seat is one person's, a leaving member hands the bot its body back,
6 * and the caps refuse politely.
7 *
8 * The socket's own half — the upgrade, the same-origin check, the 404 on a plain
9 * GET — is in `src/server.test.ts`, where the server is.
10 */
11
12 import { expect, test } from "bun:test";
13 import { ALPHABET, isRoomCode, LENGTH, newRoomCode, roomCodeFrom } from "./code.ts";
14 import {
15 createRoom, drive, freeSeat, isIdle, join, leave, logSince, rosterOf, snapshotFor, stepRoom,
16 uniqueName, INPUT_HOLD, TICK, type Room,
17 } from "./room.ts";
18 import {
19 applySnapshot, readInput, writeInput, writeSnapshot, BUY_ITEMS, INPUT_BYTES, SNAPSHOT_MAX,
20 SWITCH_PRIMARY, SWITCH_SECONDARY, readDown, forget,
21 } from "./wire.ts";
22 import { createHub, CLOSE_FLOOD, CLOSE_TOO_BIG, IDLE_MS, MESSAGE_MAX, ROOM_CAP, type Wire } from "./hub.ts";
23 import { createWorld, NO_INPUT, type Input, type Player } from "../client/world.ts";
24 import { DE_FORGE } from "../client/map.ts";
25 import { SHORT } from "../client/match.ts";
26 import { useShipped } from "../client/shipped.ts";
27 import { pushEffect } from "../engine/index.ts";
28
29 useShipped(); // a room's bots think with the shipped policy, like the game's
30
31 const room = (now = 1000): Room => createRoom("ABCDEF", { map: "de_forge", seed: 9, length: "short", now });
32
33 // --- the code -------------------------------------------------------------
34
35 test("a room code is six readable characters, and nothing else is one", () => {
36 expect(isRoomCode("ABCDEF")).toBe(true);
37 expect(isRoomCode("abcdef")).toBe(false);
38 expect(isRoomCode("ABCDE")).toBe(false);
39 // The characters a person reads wrong are not in the alphabet at all.
40 for (const wrong of ["O", "0", "I", "1", "L", "S", "5"]) expect(ALPHABET.includes(wrong)).toBe(false);
41 expect(isRoomCode("ABCDE0")).toBe(false);
42 expect(isRoomCode({ code: "ABCDEF" })).toBe(false);
43 // The boundary upper-cases what a person typed and refuses what is not a code.
44 expect(roomCodeFrom(" abcdef ")).toBe("ABCDEF");
45 expect(roomCodeFrom("../../etc/passwd")).toBe(null);
46 expect(roomCodeFrom(null)).toBe(null);
47 });
48
49 test("a fresh code is drawn from the alphabet and is the stated length", () => {
50 for (let i = 0; i < 50; i++) {
51 const code = newRoomCode();
52 expect(code.length).toBe(LENGTH);
53 expect(isRoomCode(code)).toBe(true);
54 }
55 // And it is drawn from the bytes it is handed, so a test can choose one.
56 expect(newRoomCode((into) => into.fill(0))).toBe((ALPHABET[0] as string).repeat(LENGTH));
57 });
58
59 // --- the input frame ------------------------------------------------------
60
61 test("an input frame round trips, and every field comes back in range", () => {
62 const input: Input = {
63 ...NO_INPUT, forward: 1, side: -1, jump: true, fire: true, walk: true, throwing: "smoke",
64 };
65 const bytes = writeInput({ tick: 4242, input, yaw: -91.25, pitch: 37.5, switchCode: SWITCH_PRIMARY, buy: "ak47" });
66 expect(bytes.byteLength).toBe(INPUT_BYTES);
67 const read = readInput(bytes);
68 expect(read).not.toBe(null);
69 const got = read as NonNullable<typeof read>;
70 expect(got.tick).toBe(4242);
71 expect(got.input.forward).toBeCloseTo(1, 2);
72 expect(got.input.side).toBeCloseTo(-1, 2);
73 expect(got.input.jump).toBe(true);
74 expect(got.input.fire).toBe(true);
75 expect(got.input.walk).toBe(true);
76 expect(got.input.duck).toBe(false);
77 expect(got.input.throwing).toBe("smoke");
78 expect(got.yaw).toBeCloseTo(-91.25, 1);
79 expect(got.pitch).toBeCloseTo(37.5, 1);
80 expect(got.switchCode).toBe(SWITCH_PRIMARY);
81 expect(got.buy).toBe("ak47");
82 // A weapon id never crosses this wire: a switch is one of four things the
83 // server resolves against the body it is holding.
84 expect(BUY_ITEMS.includes("ak47")).toBe(true);
85 });
86
87 test("a frame off the wire cannot ask for more than a keyboard can", () => {
88 // Every byte set: the widest values this encoding can carry.
89 const wild = new Uint8Array(INPUT_BYTES).fill(0xff);
90 wild[0] = 1; // still an input frame
91 const read = readInput(wild);
92 const got = read as NonNullable<typeof read>;
93 expect(got.input.forward).toBeGreaterThanOrEqual(-1);
94 expect(got.input.side).toBeGreaterThanOrEqual(-1);
95 expect(Math.abs(got.pitch)).toBeLessThanOrEqual(89);
96 expect(Number.isFinite(got.yaw)).toBe(true);
97 // And a frame of the wrong size or the wrong kind is not a frame at all.
98 expect(readInput(new Uint8Array(INPUT_BYTES - 1))).toBe(null);
99 expect(readInput(new Uint8Array(INPUT_BYTES + 1))).toBe(null);
100 expect(readInput(new Uint8Array(INPUT_BYTES))).toBe(null); // first byte is not INPUT_FRAME
101 });
102
103 // --- the snapshot ---------------------------------------------------------
104
105 /** A round with something in it: a body hurt, a grenade in the air, a plant. */
106 function busy(): Room {
107 const r = room();
108 const world = r.world;
109 world.match.phase = "live";
110 world.match.timer = SHORT.roundTime;
111 for (let i = 0; i < 64 * 3; i++) stepRoom(r);
112 const thrower = world.players[2] as Player;
113 world.grenades.push({
114 pos: [thrower.pos[0], thrower.pos[1], thrower.pos[2] + 60], vel: [300, 20, 150],
115 onGround: false, ducking: false, duckAmount: 0,
116 weapon: "he", owner: thrower.id, team: thrower.team, fuse: 1.25,
117 });
118 world.clouds.push({ pos: [500, 500, 40], born: world.time - 1, owner: 3 });
119 world.bomb.state = "planted";
120 world.bomb.pos = [DE_FORGE.sites.A[0], DE_FORGE.sites.A[1], 4];
121 world.bomb.site = "A";
122 world.bomb.plantProgress = 1;
123 world.bomb.defuseProgress = 0.5;
124 return r;
125 }
126
127 test("a snapshot round trips: every body, the grenades, the smoke, the bomb and the clock", () => {
128 const r = busy();
129 const bytes = writeSnapshot(r.world, r.tick, 0, new Uint8Array(SNAPSHOT_MAX));
130 // The other end has the same ten bodies on the same map from the same seed,
131 // and has never stepped the world once.
132 const mine = createWorld(r.level, SHORT, "watching", "ct", 5, r.seed);
133 const got = applySnapshot(mine, bytes, 0);
134 expect(got).not.toBe(null);
135 const snap = got as NonNullable<typeof got>;
136 expect(snap.tick).toBe(r.tick);
137 expect(snap.time).toBeCloseTo(r.world.time, 2);
138 expect(snap.own).not.toBe(null);
139
140 r.world.players.forEach((p, i) => {
141 const q = mine.players[i] as Player;
142 // Seat 0 is the reader's own body, whose position is handed back rather
143 // than written in: everybody else's is the server's, to a hundredth.
144 if (i > 0) {
145 expect(q.pos[0]).toBeCloseTo(p.pos[0], 2);
146 expect(q.pos[1]).toBeCloseTo(p.pos[1], 2);
147 expect(q.pos[2]).toBeCloseTo(p.pos[2], 2);
148 expect(q.vel[0]).toBeCloseTo(p.vel[0], 2);
149 // An angle is 16 bits of a turn, so it is right to a hundredth of a degree.
150 expect(Math.abs(((q.yaw - p.yaw + 540) % 360) - 180)).toBeCloseTo(0, 1);
151 }
152 expect(q.health).toBe(Math.round(p.health));
153 expect(q.armor).toBe(Math.round(p.armor));
154 expect(q.alive).toBe(p.alive);
155 expect(q.team).toBe(p.team);
156 expect(q.weapon).toBe(p.weapon);
157 expect(q.primary).toBe(p.primary);
158 expect(q.money).toBe(p.money);
159 expect(q.kills).toBe(p.kills);
160 expect(q.deaths).toBe(p.deaths);
161 expect(q.ammo[q.weapon]?.mag).toBe(p.ammo[p.weapon]?.mag ?? 0);
162 expect(q.duckAmount).toBeCloseTo(p.duckAmount, 2);
163 });
164 expect(mine.match.phase).toBe(r.world.match.phase);
165 expect(mine.match.round).toBe(r.world.match.round);
166 expect(mine.match.timer).toBeCloseTo(r.world.match.timer, 2);
167 expect(mine.bomb.state).toBe("planted");
168 expect(mine.bomb.site).toBe("A");
169 expect(mine.bomb.pos[0]).toBeCloseTo(r.world.bomb.pos[0], 2);
170 expect(mine.bomb.defuseProgress).toBeCloseTo(0.5, 3);
171 expect(mine.grenades.length).toBe(1);
172 expect(mine.grenades[0]?.weapon).toBe("he");
173 expect(mine.grenades[0]?.pos[0]).toBeCloseTo(r.world.grenades[0]?.pos[0] as number, 2);
174 expect(mine.grenades[0]?.fuse).toBeCloseTo(r.world.grenades[0]?.fuse as number, 1);
175 expect(mine.clouds.length).toBe(1);
176 expect(mine.clouds[0]?.pos[1]).toBeCloseTo(500, 2);
177 // The cloud's age survives, which is what its radius is read off.
178 expect(mine.time - (mine.clouds[0]?.born as number)).toBeCloseTo(1, 1);
179 });
180
181 test("a snapshot carries what happened, and a member's own shot is their own", () => {
182 const r = room();
183 const world = r.world;
184 world.match.phase = "live";
185 const at = world.time;
186 pushEffect(world.effects, { kind: "shot", at, pos: [1, 2, 3], weapon: "ak47", mine: false, by: 3 }, at);
187 pushEffect(world.effects, { kind: "tracer", at, from: [0, 0, 0], to: [10, 0, 0] }, at);
188 pushEffect(world.effects, { kind: "sound", at, pos: [4, 5, 6], name: "step", by: 7 }, at);
189 const bytes = writeSnapshot(world, r.tick, at - 0.001, new Uint8Array(SNAPSHOT_MAX));
190 const mine = createWorld(r.level, SHORT, "watching", "ct", 5, r.seed);
191 forget(mine);
192 expect(applySnapshot(mine, bytes, 3)?.events).toBe(3);
193 const shot = mine.effects.events.find((e) => e?.kind === "shot");
194 expect(shot?.kind).toBe("shot");
195 if (shot?.kind === "shot") {
196 // Seat 3 read this snapshot, and seat 3 fired: it is their own gun.
197 expect(shot.mine).toBe(true);
198 expect(shot.weapon).toBe("ak47");
199 expect(shot.pos[0]).toBeCloseTo(1, 2);
200 }
201 const heard = mine.effects.events.find((e) => e?.kind === "sound");
202 expect(heard?.kind === "sound" && heard.by).toBe(7);
203 // And the same snapshot read by somebody else is somebody else's gun.
204 const theirs = createWorld(r.level, SHORT, "watching", "ct", 5, r.seed);
205 forget(theirs);
206 applySnapshot(theirs, bytes, 8);
207 const other = theirs.effects.events.find((e) => e?.kind === "shot");
208 expect(other?.kind === "shot" && other.mine).toBe(false);
209 });
210
211 test("a snapshot of a ten-body round stays inside the bytes the rate was chosen for", () => {
212 const r = busy();
213 const bytes = writeSnapshot(r.world, r.tick, r.world.time - 0.0625, new Uint8Array(SNAPSHOT_MAX));
214 // The number the snapshot rate was picked against: 16 a second of this is
215 // under 10 kB a second down to each member. A change that doubles it should
216 // be read here first, not discovered on somebody's phone.
217 expect(bytes.byteLength).toBeLessThan(700);
218 console.log(` a snapshot of a busy ten-body round: ${bytes.byteLength} bytes` +
219 ` — ${(bytes.byteLength * 16 / 1000).toFixed(1)} kB/s a member at 16 Hz`);
220 });
221
222 test("a snapshot that is not one changes nothing", () => {
223 const r = room();
224 const mine = createWorld(r.level, SHORT, "watching", "ct", 5, r.seed);
225 const was: number[] = [...(mine.players[1] as Player).pos];
226 expect(applySnapshot(mine, new Uint8Array(0), 0)).toBe(null);
227 expect(applySnapshot(mine, new Uint8Array([9, 9, 9, 9]), 0)).toBe(null);
228 // A frame that is the right kind and then stops: refused, and nothing written.
229 const good = writeSnapshot(r.world, r.tick, 0, new Uint8Array(SNAPSHOT_MAX));
230 expect(applySnapshot(mine, good.slice(0, 30), 0)).toBe(null);
231 expect([...(mine.players[1] as Player).pos]).toEqual(was);
232 });
233
234 test("a control message off the wire is validated field by field", () => {
235 const hello = readDown(JSON.stringify({
236 k: "hello", code: "ABCDEF", seat: 3, map: "de_forge", seed: 9, rules: "short", tick: 10,
237 snapshotHz: 16, roster: [{ seat: 0, name: "you", bot: false, team: "ct" }],
238 }));
239 expect(hello?.k).toBe("hello");
240 expect(readDown("not json")).toBe(null);
241 expect(readDown(JSON.stringify({ k: "hello" }))).toBe(null);
242 expect(readDown(JSON.stringify({ k: "hello", code: "A", seat: 0, map: "x", seed: Infinity, rules: "short", tick: 0, snapshotHz: 16, roster: [] }))).toBe(null);
243 expect(readDown(JSON.stringify({ k: "roster", roster: [{ seat: 0, name: "a", bot: true, team: "zz" }] }))).toBe(null);
244 expect(readDown(JSON.stringify({ k: "log", feed: [], say: [{ at: 1, text: "hi" }] }))?.k).toBe("log");
245 expect(readDown(JSON.stringify({ k: "nonsense" }))).toBe(null);
246 });
247
248 // --- seats ---------------------------------------------------------------
249
250 test("a member takes a bot's seat and gives it back on the way out", () => {
251 const r = room();
252 const seat = freeSeat(r) as number;
253 const body = r.world.players[seat] as Player;
254 const botName = body.name;
255 body.money = 4321;
256 const member = join(r, "s1", " Vesper ", 2000);
257 expect(member?.seat).toBe(seat);
258 expect(body.bot).toBe(false);
259 expect(body.name).toBe("Vesper");
260 // The bot's money, guns and position are not reset by an arrival.
261 expect(body.money).toBe(4321);
262 expect(rosterOf(r).filter((s) => !s.bot).length).toBe(1);
263
264 leave(r, "s1", 3000);
265 expect(body.bot).toBe(true);
266 expect(body.name).toBe(botName);
267 expect(body.money).toBe(4321);
268 expect(r.members.size).toBe(0);
269 });
270
271 test("two friends land on opposite sides, and a name is never two people's", () => {
272 const r = room();
273 const first = join(r, "s1", "sam", 1) as NonNullable<ReturnType<typeof join>>;
274 const second = join(r, "s2", "sam", 2) as NonNullable<ReturnType<typeof join>>;
275 const a = r.world.players[first.seat] as Player;
276 const b = r.world.players[second.seat] as Player;
277 expect(a.team).not.toBe(b.team);
278 expect(second.name).toBe("sam (2)");
279 expect(uniqueName(r, "")).toBe("player");
280 // A member who reconnects with the same session keeps the seat they had.
281 expect(join(r, "s1", "sam", 3)?.seat).toBe(first.seat);
282 expect(r.members.size).toBe(2);
283 });
284
285 test("a room fills up, and the eleventh person is refused rather than dropped", () => {
286 const r = room();
287 for (let i = 0; i < 10; i++) expect(join(r, `s${i}`, `p${i}`, i)).not.toBe(null);
288 expect(freeSeat(r)).toBe(null);
289 expect(join(r, "s10", "late", 11)).toBe(null);
290 expect(r.members.size).toBe(10);
291 });
292
293 // --- input into the world -------------------------------------------------
294
295 /** The bytes a member would send for one tick of holding W. */
296 const forward = (tick: number, yaw: number): Uint8Array =>
297 writeInput({ tick, input: { ...NO_INPUT, forward: 1 }, yaw, pitch: 0, switchCode: 0, buy: null });
298
299 test("a member's keys move their own body, and nobody else's", () => {
300 const r = room();
301 r.world.match.phase = "live";
302 r.world.match.timer = SHORT.roundTime;
303 const member = join(r, "s1", "sam", 1) as NonNullable<ReturnType<typeof join>>;
304 const body = r.world.players[member.seat] as Player;
305 const was: [number, number] = [body.pos[0], body.pos[1]];
306 for (let i = 0; i < 32; i++) {
307 expect(drive(r, "s1", forward(i, 90))).toBe(true);
308 stepRoom(r);
309 }
310 // It walked, and it walked where the frame said it was looking.
311 expect(Math.hypot(body.pos[0] - was[0], body.pos[1] - was[1])).toBeGreaterThan(40);
312 expect(body.yaw).toBeCloseTo(90, 2);
313 // An input from somebody who is not in this room reaches nothing at all.
314 expect(drive(r, "stranger", forward(40, 0))).toBe(false);
315 // And a frame of the wrong size is refused and counted.
316 expect(drive(r, "s1", new Uint8Array(MESSAGE_MAX))).toBe(false);
317 expect(member.dropped).toBe(1);
318 });
319
320 test("a late frame is the member's last one, and silence drops the keys", () => {
321 const r = room();
322 r.world.match.phase = "live";
323 r.world.match.timer = SHORT.roundTime;
324 join(r, "s1", "sam", 1);
325 const body = r.world.players[freeSeatOf(r)] as Player;
326 drive(r, "s1", forward(1, 0));
327 // Nothing else arrives: the last input is held for INPUT_HOLD ticks and the
328 // body keeps walking, then drops the keys and comes to a stop.
329 for (let i = 0; i < INPUT_HOLD; i++) stepRoom(r);
330 const moving = Math.hypot(body.vel[0], body.vel[1]);
331 expect(moving).toBeGreaterThan(50);
332 for (let i = 0; i < 64; i++) stepRoom(r);
333 expect(Math.hypot(body.vel[0], body.vel[1])).toBeLessThan(1);
334 // A frame that overtook another on the way here does not undo it.
335 drive(r, "s1", forward(100, 10));
336 expect(drive(r, "s1", forward(50, 200))).toBe(false);
337 stepRoom(r);
338 expect(body.yaw).toBeCloseTo(10, 2);
339 });
340
341 /** The seat the one member of this room is holding. */
342 const freeSeatOf = (r: Room): number => [...r.members.values()][0]?.seat as number;
343
344 test("a member buys once for one ask, and only what the money covers", () => {
345 const r = room();
346 const member = join(r, "s1", "sam", 1) as NonNullable<ReturnType<typeof join>>;
347 const body = r.world.players[member.seat] as Player;
348 body.money = 2700;
349 const ask = (tick: number, item: "ak47"): void => {
350 drive(r, "s1", writeInput({ tick, input: NO_INPUT, yaw: 0, pitch: 0, switchCode: 0, buy: item }));
351 };
352 // The key is held for a quarter of a second, which is sixteen frames of the
353 // same ask: one rifle, not sixteen.
354 for (let i = 0; i < 16; i++) { ask(1, "ak47"); stepRoom(r); }
355 expect(body.primary).toBe("ak47");
356 expect(body.money).toBe(2700 - 2700);
357 // And with the wallet empty, the next ask changes nothing.
358 ask(2, "ak47");
359 stepRoom(r);
360 expect(body.money).toBe(0);
361 // Holding the rifle is a switch the server resolves against the body.
362 drive(r, "s1", writeInput({ tick: 3, input: NO_INPUT, yaw: 0, pitch: 0, switchCode: SWITCH_PRIMARY, buy: null }));
363 stepRoom(r);
364 expect(body.weapon).toBe("ak47");
365 drive(r, "s1", writeInput({ tick: 4, input: NO_INPUT, yaw: 0, pitch: 0, switchCode: SWITCH_SECONDARY, buy: null }));
366 for (let i = 0; i < 64; i++) stepRoom(r);
367 expect(body.weapon).toBe(body.secondary);
368 });
369
370 test("a room's round is played by its own bots and says what happened", () => {
371 const r = room();
372 // Straight into a live round, and then a few seconds of it.
373 r.world.match.phase = "live";
374 r.world.match.timer = SHORT.roundTime;
375 for (let i = 0; i < 64 * 20; i++) stepRoom(r);
376 expect(r.tick).toBe(64 * 20);
377 expect(r.world.time).toBeCloseTo(20, 0);
378 // Bots left their spawns and somebody shot somebody.
379 expect(r.world.players.some((p) => p.kills > 0 || p.damage > 0)).toBe(true);
380 const log = logSince(r);
381 // The words of the round travel as themselves: the feed and the lines.
382 if (log !== null) expect(log.feed.length + log.say.length).toBeGreaterThan(0);
383 // Asked twice, nothing is said twice.
384 const again = logSince(r);
385 expect(again === null || again.feed.length + again.say.length === 0).toBe(true);
386 });
387
388 // --- the hub -------------------------------------------------------------
389
390 /** A wire that remembers instead of sending. */
391 function spy(): Wire & { sent: Map<string, (Uint8Array | string)[]>; closed: { id: string; code: number }[] } {
392 const sent = new Map<string, (Uint8Array | string)[]>();
393 const closed: { id: string; code: number }[] = [];
394 return {
395 sent, closed,
396 deliver(id, frame) {
397 const list = sent.get(id) ?? [];
398 list.push(frame);
399 sent.set(id, list);
400 },
401 close(id, code) { closed.push({ id, code }); },
402 };
403 }
404
405 const opened = { map: "de_forge" as const, seed: 5, length: "short" as const, now: 0 };
406
407 test("a hub holds its rooms, hears one member and never the other room", () => {
408 const wire = spy();
409 const hub = createHub(wire, { now: 0 });
410 const first = hub.open({ ...opened, now: 0 });
411 const second = hub.open({ ...opened, seed: 6, now: 0 });
412 expect(first).not.toBe(null);
413 expect(second).not.toBe(null);
414 const a = hub.enter((first as Room).code, "s1", "sam", 0);
415 const b = hub.enter((second as Room).code, "s2", "kim", 0);
416 expect(a).not.toBe(null);
417 expect(b).not.toBe(null);
418 // Each was told which body is theirs, on which map, with who else is here.
419 const hello = wire.sent.get("s1")?.[0] as string;
420 expect(JSON.parse(hello).k).toBe("hello");
421 expect(JSON.parse(hello).code).toBe((first as Room).code);
422
423 // A quarter of a second: sixteen ticks a room, four snapshots a member.
424 for (let ms = 10; ms <= 250; ms += 10) hub.advance(ms);
425 const snapshots = (id: string): number =>
426 (wire.sent.get(id) ?? []).filter((f) => f instanceof Uint8Array).length;
427 expect(snapshots("s1")).toBeGreaterThanOrEqual(3);
428 expect(snapshots("s1")).toBeLessThanOrEqual(5);
429 // The two rooms stepped their own worlds and nobody else's.
430 expect((first as Room).tick).toBeGreaterThan(8);
431 expect((first as Room).world.players[0]?.pos).not.toEqual((second as Room).world.players[0]?.pos);
432 expect(hub.members()).toBe(2);
433 expect(hub.lines(250).length).toBe(2);
434
435 // One member's input reaches their own room's world, and the other room's
436 // member is in no way touched by it.
437 const before = [...((second as Room).world.players[(b as { member: { seat: number } }).member.seat] as Player).pos];
438 expect(hub.frame("s1", forward(1, 0), 260)).toBe(true);
439 hub.advance(300);
440 expect((second as Room).world.players[(b as { member: { seat: number } }).member.seat]?.pos).not.toBe(before);
441 });
442
443 test("a frame over the cap or over the limiter closes the connection", () => {
444 const wire = spy();
445 const hub = createHub(wire, { now: 0 });
446 const r = hub.open(opened) as Room;
447 hub.enter(r.code, "s1", "sam", 0);
448 expect(hub.frame("s1", new Uint8Array(MESSAGE_MAX + 1), 1)).toBe(false);
449 expect(wire.closed[0]).toEqual({ id: "s1", code: CLOSE_TOO_BIG });
450 // And a caller that sends far more than a page can: closed, and the process
451 // is still here to say so.
452 const wire2 = spy();
453 const hub2 = createHub(wire2, { now: 0 });
454 const r2 = hub2.open(opened) as Room;
455 hub2.enter(r2.code, "s2", "kim", 0);
456 let refused = 0;
457 for (let i = 0; i < 400; i++) if (!hub2.frame("s2", forward(i, 0), 1)) refused += 1;
458 expect(refused).toBeGreaterThan(200);
459 expect(wire2.closed.some((c) => c.code === CLOSE_FLOOD)).toBe(true);
460 });
461
462 test("the room cap refuses politely, and an empty room is collected", () => {
463 const wire = spy();
464 const hub = createHub(wire, { now: 0 });
465 for (let i = 0; i < ROOM_CAP; i++) expect(hub.open({ ...opened, seed: i })).not.toBe(null);
466 expect(hub.open(opened)).toBe(null);
467 expect(hub.rooms.size).toBe(ROOM_CAP);
468 // Nobody ever joined any of them, so they all go on the first sweep past the
469 // idle time — and the cap is a cap again.
470 hub.advance(IDLE_MS + 1000);
471 expect(hub.rooms.size).toBe(0);
472 const r = hub.open({ ...opened, now: IDLE_MS + 1000 }) as Room;
473 hub.enter(r.code, "s1", "sam", IDLE_MS + 1000);
474 // A room with somebody in it is never idle, however long the match runs.
475 hub.advance(IDLE_MS * 3);
476 expect(hub.rooms.size).toBe(1);
477 expect(isIdle(r, IDLE_MS * 3, IDLE_MS)).toBe(false);
478 // They leave, and it is collected on the sweep after the idle time.
479 hub.exit("s1", IDLE_MS * 3);
480 hub.advance(IDLE_MS * 3 + IDLE_MS + 1);
481 expect(hub.rooms.size).toBe(0);
482 });
483
484 test("a member nobody has heard from loses the seat they are holding", () => {
485 const wire = spy();
486 const hub = createHub(wire, { now: 0 });
487 const r = hub.open(opened) as Room;
488 hub.enter(r.code, "s1", "sam", 0);
489 hub.advance(1000);
490 expect(wire.closed.length).toBe(0);
491 hub.advance(40_000);
492 expect(wire.closed.some((c) => c.id === "s1")).toBe(true);
493 // The close is what takes them out, the way a browser closing does.
494 hub.exit("s1", 40_000);
495 expect(r.members.size).toBe(0);
496 expect(r.world.players.every((p) => p.bot)).toBe(true);
497 });
498
499 test("one member's snapshot is a snapshot of the room they are in", () => {
500 const r = room();
501 const member = join(r, "s1", "sam", 0) as NonNullable<ReturnType<typeof join>>;
502 for (let i = 0; i < 64; i++) stepRoom(r);
503 const bytes = snapshotFor(r, member);
504 expect(bytes.byteLength).toBeGreaterThan(100);
505 // Asking again sends only what happened since: the clock moved on.
506 expect(member.sentAt).toBeCloseTo(r.world.time, 5);
507 });
src/rooms/wire.ts added +677
@@ -0,0 +1,677 @@
1 /**
2 * What crosses the socket between a room and a member, and nothing about how it
3 * gets there. Two frames, in two directions, and they are different shapes on
4 * purpose.
5 *
6 * **Up, from a browser: one input a tick, fourteen bytes.** A member's keys, the
7 * tick they were pressed on, and where they are looking. The view angles are the
8 * one thing a client is believed about — a person's aim has to answer their hand
9 * and not a round trip — and everything else is a request the world may refuse.
10 * `readInput` is the boundary: it takes bytes off a wire and hands back an
11 * `Input` whose every field is in range, or null, and a null is a frame that is
12 * dropped rather than a room that is confused.
13 *
14 * **Down, from the room: one snapshot a beat.** The whole round as numbers —
15 * every body, the grenades in the air, the smoke standing, the bomb and the
16 * clock — plus what *happened* since the last one, so a shot is a tracer and a
17 * noise on the member's screen and not a silently smaller number. It is packed
18 * binary and quantised rather than JSON because it is the only thing on this
19 * wire that is sent sixteen times a second to everybody: 544 bytes against
20 * about 3.4 kB of JSON for the same round, which is 8.7 kB a second a member
21 * instead of 54.
22 *
23 * Positions are `f32` and not quantised integers. A generated map is a few
24 * thousand Source units across and the honest 16-bit encoding of that is a
25 * quarter-unit grid, which is visible on a body standing still; two bytes a
26 * coordinate is the cheapest thing in this frame to buy correctness with.
27 * Angles are 16-bit — 0.0055° a step, far under what a screen can show — and
28 * everything else is a byte or two of its own natural range.
29 *
30 * Nothing here knows about a socket, a request or a page: bytes in, plain data
31 * out, so every claim in `wire.test.ts` is made without either end running.
32 */
33
34 import type { Bomb, BombState } from "../client/bomb.ts";
35 import type { Cloud, Grenade } from "../client/grenade.ts";
36 import type { Phase } from "../client/match.ts";
37 import { emptyAmmo, type BuyItem } from "../client/economy.ts";
38 import { pushEffect, resetEffects, type Vec3 } from "../engine/index.ts";
39 import { WEAPONS, type ThrownId, type WeaponId } from "../client/weapons.ts";
40 import { NO_INPUT, type Effect, type Input, type Player, type Sound, type World } from "../client/world.ts";
41
42 /** The first byte of every frame: which of the two shapes it is. */
43 export const INPUT_FRAME = 1;
44 export const SNAPSHOT_FRAME = 2;
45
46 /**
47 * The tables both ends index into. Each is sorted rather than written out in
48 * declaration order, so a weapon added to `weapons.ts` cannot silently renumber
49 * the wire for a page that is still open on a previous deploy — it lands in
50 * alphabetical order and the numbers around it stay put.
51 */
52 export const WEAPON_IDS: readonly WeaponId[] = (Object.keys(WEAPONS) as WeaponId[]).sort();
53 export const THROWN_IDS: readonly ThrownId[] = ["flash", "he", "smoke"];
54 export const PHASES: readonly Phase[] = ["freeze", "live", "planted", "over", "match_over"];
55 export const BOMB_STATES: readonly BombState[] = ["carried", "dropped", "planted", "defused", "detonated"];
56 export const SOUNDS: readonly Sound[] = [
57 "beep", "bounce", "blast", "death", "defuse", "flash", "plant", "smoke", "step", "throw",
58 ];
59
60 /** The widest frame this module will ever write, so the buffer is made once. */
61 export const SNAPSHOT_MAX = 8192;
62 /** How many bodies, grenades, clouds and events one snapshot may carry. */
63 export const MAX_PLAYERS = 16;
64 export const MAX_GRENADES = 32;
65 export const MAX_CLOUDS = 16;
66 export const MAX_EVENTS = 64;
67
68 // --- reading and writing bytes --------------------------------------------
69
70 /**
71 * A cursor over bytes. Both directions use one, because a frame is written and
72 * read field by field in one order and the two halves of that have to be read
73 * side by side to stay the same order.
74 */
75 class Cursor {
76 view: DataView;
77 at = 0;
78 constructor(public bytes: Uint8Array) {
79 this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
80 }
81 u8(v: number): void { this.view.setUint8(this.at++, v & 0xff); }
82 i8(v: number): void { this.view.setInt8(this.at++, Math.max(-128, Math.min(127, v | 0))); }
83 u16(v: number): void { this.view.setUint16(this.at, v & 0xffff, true); this.at += 2; }
84 i16(v: number): void { this.view.setInt16(this.at, Math.max(-32768, Math.min(32767, v | 0)), true); this.at += 2; }
85 u32(v: number): void { this.view.setUint32(this.at, v >>> 0, true); this.at += 4; }
86 f32(v: number): void { this.view.setFloat32(this.at, Number.isFinite(v) ? v : 0, true); this.at += 4; }
87 ru8(): number { return this.view.getUint8(this.at++); }
88 ri8(): number { return this.view.getInt8(this.at++); }
89 ru16(): number { const v = this.view.getUint16(this.at, true); this.at += 2; return v; }
90 ri16(): number { const v = this.view.getInt16(this.at, true); this.at += 2; return v; }
91 ru32(): number { const v = this.view.getUint32(this.at, true); this.at += 4; return v; }
92 rf32(): number { const v = this.view.getFloat32(this.at, true); this.at += 4; return v; }
93 /** Room for `n` more bytes. A frame that claims more than it carries is refused. */
94 has(n: number): boolean { return this.at + n <= this.bytes.byteLength; }
95 }
96
97 /** An angle as 16 bits of a turn: 0.0055° a step, which no screen can show. */
98 const packAngle = (degrees: number): number => Math.round(((degrees % 360) + 360) % 360 / 360 * 65536) & 0xffff;
99 const unpackAngle = (bits: number): number => (bits / 65536) * 360;
100 /** The same, read back into the −180…180 half the view uses. */
101 const unpackSigned = (bits: number): number => {
102 const a = unpackAngle(bits);
103 return a > 180 ? a - 360 : a;
104 };
105
106 const clamp = (v: number, lo: number, hi: number): number => (v < lo ? lo : v > hi ? hi : v);
107 /** A number off a wire, or the fallback: NaN and Infinity are not numbers here. */
108 const finite = (v: number, fallback = 0): number => (Number.isFinite(v) ? v : fallback);
109
110 // --- up: one member's input ----------------------------------------------
111
112 /**
113 * Which weapon a member asked to hold, as one of four things the *server*
114 * resolves against the body: nothing, the knife, the pistol, the rifle. A
115 * client cannot name a weapon id on this wire at all, so "switch to the AWP I
116 * do not own" is not a message that exists rather than a message that is
117 * refused — and `applyInput` in the world refuses it a second time anyway.
118 */
119 export const SWITCH_NONE = 0;
120 export const SWITCH_KNIFE = 1;
121 export const SWITCH_SECONDARY = 2;
122 export const SWITCH_PRIMARY = 3;
123
124 /**
125 * Everything a member may buy, sorted for the reason the weapon table is: this
126 * is an index on a wire, and a page open on yesterday's deploy must not be able
127 * to buy a rifle by asking for armour. Zero is "nothing asked for".
128 */
129 export const BUY_ITEMS: readonly BuyItem[] = ([...WEAPON_IDS, "armor", "helmet", "kit"] as BuyItem[]).sort();
130
131 /** What one member pressed, on the tick they pressed it. */
132 export type Pressed = {
133 tick: number;
134 input: Input;
135 yaw: number;
136 pitch: number;
137 switchCode: number;
138 /**
139 * The one thing a member asks for that is not a key: an item off the shelf,
140 * by index into `BUY_ITEMS`, or null. It rides on the input frame because it
141 * happens during freeze time when the frame is already going up every tick,
142 * and because a buy that needs its own message needs its own limiter; the
143 * room spends it once — `economy.ts` refuses it if the money is not there.
144 */
145 buy: BuyItem | null;
146 };
147
148 /** The fourteen bytes a browser sends once a tick. */
149 export function writeInput(pressed: Pressed, into = new Uint8Array(INPUT_BYTES)): Uint8Array {
150 const c = new Cursor(into);
151 const { input } = pressed;
152 c.u8(INPUT_FRAME);
153 c.u32(pressed.tick);
154 c.u8(
155 (input.jump ? 1 : 0) | (input.duck ? 2 : 0) | (input.walk ? 4 : 0) | (input.fire ? 8 : 0) |
156 (input.reload ? 16 : 0) | (input.use ? 32 : 0) | (input.scope ? 64 : 0),
157 );
158 c.i8(Math.round(clamp(input.forward, -1, 1) * 127));
159 c.i8(Math.round(clamp(input.side, -1, 1) * 127));
160 c.u16(packAngle(pressed.yaw));
161 c.u16(packAngle(pressed.pitch));
162 const throwing = input.throwing === null ? 0 : THROWN_IDS.indexOf(input.throwing) + 1;
163 c.u8(throwing | (clamp(pressed.switchCode, 0, 3) << 2));
164 c.u8(pressed.buy === null ? 0 : BUY_ITEMS.indexOf(pressed.buy) + 1);
165 return into.subarray(0, c.at);
166 }
167
168 export const INPUT_BYTES = 14;
169
170 /**
171 * The inverse, and the boundary. Every field comes back inside its own range —
172 * a wish is at most one in each direction, a pitch is at most 89° from level, a
173 * grenade is one of three or none, a switch is one of four — or this is null,
174 * which is a frame the room drops. Nothing a caller writes can make the world
175 * do something the keyboard cannot.
176 */
177 export function readInput(bytes: Uint8Array): Pressed | null {
178 if (bytes.byteLength !== INPUT_BYTES) return null;
179 const c = new Cursor(bytes);
180 if (c.ru8() !== INPUT_FRAME) return null;
181 const tick = c.ru32();
182 const flags = c.ru8();
183 const forward = clamp(c.ri8() / 127, -1, 1);
184 const side = clamp(c.ri8() / 127, -1, 1);
185 const yaw = unpackSigned(c.ru16());
186 const pitch = clamp(unpackSigned(c.ru16()), -89, 89);
187 const extra = c.ru8();
188 const thrown = extra & 3;
189 const switchCode = (extra >> 2) & 3;
190 const wanted = c.ru8();
191 // An index nothing is filed under is nothing asked for, not a refusal: a
192 // frame is dropped for being the wrong shape and never for one bad field.
193 const buy = wanted === 0 ? null : (BUY_ITEMS[wanted - 1] ?? null);
194 const input: Input = {
195 ...NO_INPUT,
196 forward, side,
197 jump: (flags & 1) !== 0,
198 duck: (flags & 2) !== 0,
199 walk: (flags & 4) !== 0,
200 fire: (flags & 8) !== 0,
201 reload: (flags & 16) !== 0,
202 use: (flags & 32) !== 0,
203 scope: (flags & 64) !== 0,
204 throwing: thrown === 0 ? null : (THROWN_IDS[thrown - 1] as ThrownId),
205 switchTo: null,
206 };
207 return { tick, input, yaw, pitch, switchCode, buy };
208 }
209
210 /**
211 * The weapon a switch code means for this body: the knife, whatever is in the
212 * holster, whatever is on the back, or nothing. A code for a rifle nobody owns
213 * is nothing, because `primary` is null on a body that never bought one.
214 */
215 export const resolveSwitch = (code: number, p: Player): WeaponId | null =>
216 code === SWITCH_KNIFE ? "knife" :
217 code === SWITCH_SECONDARY ? p.secondary :
218 code === SWITCH_PRIMARY ? p.primary : null;
219
220 // --- down: the round, and what just happened in it ------------------------
221
222 /** Which kinds of effect travel. The viewer-relative ones do not: see below. */
223 const EFFECT_KINDS = ["tracer", "impact", "blood", "shot", "blast", "sound"] as const;
224
225 /**
226 * What arrived: the tick it is the truth of, the clock it was taken on, how many
227 * effects came with it, and — separately from the world it was written into —
228 * where the server has the member's *own* body. That last one is handed back
229 * rather than written in because it is the one number the page does not simply
230 * accept: `src/client/session.ts` predicted a position for this tick already and
231 * reconciles against this one.
232 */
233 export type Snapshot = { tick: number; time: number; events: number; own: { pos: Vec3; vel: Vec3 } | null };
234
235 /**
236 * One room's round, whole, into `into`. Everything in `world` that a screen or
237 * a HUD reads, and every effect younger than `since` — which is the clock of
238 * the last snapshot this member was sent, so a shot is delivered exactly once.
239 *
240 * Two kinds of effect are left out and cannot be sent from here: `hitmark` and
241 * `hurt` are recorded for one body's screen only (`world.me`'s), and a room's
242 * world has no such body — every snapshot goes to everybody. A room therefore
243 * has no hit marker and no damage arrow yet, which is stated on the page and in
244 * the README rather than faked.
245 */
246 export function writeSnapshot(world: World, tick: number, since: number, into: Uint8Array): Uint8Array {
247 const c = new Cursor(into);
248 c.u8(SNAPSHOT_FRAME);
249 c.u32(tick);
250 c.f32(world.time);
251 const m = world.match;
252 c.u8(Math.max(0, PHASES.indexOf(m.phase)));
253 c.u8(clamp(m.round, 0, 255));
254 c.u8(clamp(m.score.t, 0, 255));
255 c.u8(clamp(m.score.ct, 0, 255));
256 c.f32(m.timer);
257 c.u8(m.matchWinner === null ? 0 : m.matchWinner === "t" ? 1 : 2);
258 // The bomb: its state, where it is, and how far through a plant or a defuse
259 // somebody is — both of which are bars on a screen and so both of which go.
260 c.u8(Math.max(0, BOMB_STATES.indexOf(world.bomb.state)));
261 c.f32(world.bomb.pos[0]); c.f32(world.bomb.pos[1]); c.f32(world.bomb.pos[2]);
262 c.f32(world.bomb.plantProgress);
263 c.f32(world.bomb.defuseProgress);
264 c.u8(world.bomb.site === "A" ? 1 : world.bomb.site === "B" ? 2 : 0);
265
266 c.u8(Math.min(world.players.length, MAX_PLAYERS));
267 for (const p of world.players.slice(0, MAX_PLAYERS)) writePlayer(c, p);
268
269 const grenades = world.grenades.slice(0, MAX_GRENADES);
270 c.u8(grenades.length);
271 for (const g of grenades) {
272 c.f32(g.pos[0]); c.f32(g.pos[1]); c.f32(g.pos[2]);
273 c.u8(Math.max(0, THROWN_IDS.indexOf(g.weapon)));
274 c.u8(clamp(g.owner, 0, 255));
275 c.u8(g.team === "t" ? 1 : 0);
276 // A fuse is at most a handful of seconds, in thirty-seconds of one.
277 c.u8(clamp(Math.round(g.fuse * 32), 0, 255));
278 }
279
280 const clouds = world.clouds.slice(0, MAX_CLOUDS);
281 c.u8(clouds.length);
282 for (const s of clouds) {
283 c.f32(s.pos[0]); c.f32(s.pos[1]); c.f32(s.pos[2]);
284 // Seconds ago it opened, in sixteenths: a cloud's radius is read off this.
285 c.u8(clamp(Math.round((world.time - s.born) * 16), 0, 255));
286 c.u8(clamp(s.owner, 0, 255));
287 }
288
289 const events = eventsSince(world, since);
290 c.u8(events.length);
291 for (const e of events) writeEvent(c, e);
292 return into.subarray(0, c.at);
293 }
294
295 /** Everything in the ring younger than `since`, newest last, capped. */
296 function eventsSince(world: World, since: number): Effect[] {
297 const out: Effect[] = [];
298 const ring = world.effects;
299 for (let i = 0; i < ring.count; i++) {
300 const e = ring.events[i];
301 if (e === undefined || e.at <= since) continue;
302 if (!(EFFECT_KINDS as readonly string[]).includes(e.kind)) continue;
303 out.push(e);
304 }
305 // A busier two hundredths of a second than this game can have: the newest are
306 // what a frame is about to draw, so it is the oldest that are dropped.
307 return out.length <= MAX_EVENTS ? out : out.slice(out.length - MAX_EVENTS);
308 }
309
310 const writeEvent = (c: Cursor, e: Effect): void => {
311 c.u8(Math.max(0, (EFFECT_KINDS as readonly string[]).indexOf(e.kind)));
312 c.f32(e.at);
313 switch (e.kind) {
314 case "tracer":
315 c.f32(e.from[0]); c.f32(e.from[1]); c.f32(e.from[2]);
316 c.f32(e.to[0]); c.f32(e.to[1]); c.f32(e.to[2]);
317 break;
318 case "impact":
319 c.f32(e.pos[0]); c.f32(e.pos[1]); c.f32(e.pos[2]);
320 // A normal is a unit vector: a byte a component is a fiftieth of a degree
321 // of tilt, and nothing but a spark's direction is read off it.
322 c.i8(Math.round(e.normal[0] * 127)); c.i8(Math.round(e.normal[1] * 127)); c.i8(Math.round(e.normal[2] * 127));
323 break;
324 case "blood":
325 c.f32(e.pos[0]); c.f32(e.pos[1]); c.f32(e.pos[2]);
326 break;
327 case "shot":
328 c.f32(e.pos[0]); c.f32(e.pos[1]); c.f32(e.pos[2]);
329 c.u8(Math.max(0, WEAPON_IDS.indexOf(e.weapon)));
330 // Whose gun it was, so the member reading this decides whether it is
331 // theirs. `mine` cannot cross a wire one snapshot serves everybody on.
332 c.u8(clamp(e.by ?? 255, 0, 255));
333 break;
334 case "blast":
335 c.f32(e.pos[0]); c.f32(e.pos[1]); c.f32(e.pos[2]);
336 c.u8(Math.max(0, THROWN_IDS.indexOf(e.weapon)));
337 break;
338 case "sound":
339 c.f32(e.pos[0]); c.f32(e.pos[1]); c.f32(e.pos[2]);
340 c.u8(Math.max(0, SOUNDS.indexOf(e.name)));
341 c.u8(e.by < 0 ? 255 : clamp(e.by, 0, 255));
342 break;
343 default:
344 break;
345 }
346 };
347
348 function writePlayer(c: Cursor, p: Player): void {
349 c.f32(p.pos[0]); c.f32(p.pos[1]); c.f32(p.pos[2]);
350 c.f32(p.vel[0]); c.f32(p.vel[1]); c.f32(p.vel[2]);
351 c.u16(packAngle(p.yaw));
352 c.u16(packAngle(p.pitch));
353 c.u8(clamp(Math.round(p.health), 0, 255));
354 c.u8(clamp(Math.round(p.armor), 0, 255));
355 c.u8(
356 (p.alive ? 1 : 0) | (p.team === "t" ? 2 : 0) | (p.onGround ? 4 : 0) | (p.ducking ? 8 : 0) |
357 (p.hasBomb ? 16 : 0) | (p.helmet ? 32 : 0) | (p.defuseKit ? 64 : 0) | (p.scoped ? 128 : 0),
358 );
359 c.u8(clamp(Math.round(p.duckAmount * 255), 0, 255));
360 c.u8(Math.max(0, WEAPON_IDS.indexOf(p.weapon)));
361 c.u8(p.primary === null ? 255 : Math.max(0, WEAPON_IDS.indexOf(p.primary)));
362 c.u8(Math.max(0, WEAPON_IDS.indexOf(p.secondary)));
363 const ammo = p.ammo[p.weapon];
364 c.u16(clamp(ammo?.mag ?? 0, 0, 65535));
365 c.u16(clamp(ammo?.reserve ?? 0, 0, 65535));
366 c.u16(clamp(Math.round(p.money), 0, 65535));
367 // Blindness in thirty-seconds of a second: a flash is at most a few of them.
368 c.u8(clamp(Math.round(p.blind * 32), 0, 255));
369 c.u8((p.pocket.he & 3) | ((p.pocket.flash & 3) << 2) | ((p.pocket.smoke & 3) << 4));
370 c.u8(clamp(p.kills, 0, 255));
371 c.u8(clamp(p.deaths, 0, 255));
372 c.u16(clamp(Math.round(p.damage), 0, 65535));
373 }
374
375 /** How many bytes one body is, which is what the size test holds. */
376 export const PLAYER_BYTES = 44;
377
378 /**
379 * A snapshot into a world the member already has: the level, the rules and the
380 * ten bodies were built from the room's own seed when they joined, so what
381 * arrives is the numbers on them and never their shape. Nothing is allocated
382 * per body — the arrays are written through.
383 *
384 * `seat` is the member's own body, and two things are *not* overwritten on it:
385 * the view angles, which the browser owns and is sending upstream, and the
386 * position, which `src/client/session.ts` reconciles against rather than
387 * replaces. Everything else about it is the server's word, health included.
388 *
389 * Returns what arrived, or null for a frame that is not one: a wrong first
390 * byte, a count past the cap, a frame that ends early. A refused snapshot
391 * changes nothing at all, which is what makes a truncated read safe.
392 */
393 export function applySnapshot(world: World, bytes: Uint8Array, seat: number): Snapshot | null {
394 if (bytes.byteLength < 2) return null;
395 const c = new Cursor(bytes);
396 if (c.ru8() !== SNAPSHOT_FRAME) return null;
397 if (!c.has(4 + 4 + 4 + 4 + 1 + 1 + 13 + 9 + 1)) return null;
398 const tick = c.ru32();
399 const time = finite(c.rf32());
400 const phase = PHASES[c.ru8()];
401 const round = c.ru8();
402 const scoreT = c.ru8();
403 const scoreCt = c.ru8();
404 const timer = finite(c.rf32());
405 const matchWinner = c.ru8();
406 const bombState = BOMB_STATES[c.ru8()];
407 const bombPos: Vec3 = [finite(c.rf32()), finite(c.rf32()), finite(c.rf32())];
408 const plantProgress = finite(c.rf32());
409 const defuseProgress = finite(c.rf32());
410 const site = c.ru8();
411 const count = c.ru8();
412 if (phase === undefined || bombState === undefined) return null;
413 if (count !== world.players.length || count > MAX_PLAYERS) return null;
414 if (!c.has(count * PLAYER_BYTES)) return null;
415
416 world.time = time;
417 world.match.phase = phase;
418 world.match.round = round;
419 world.match.score.t = scoreT;
420 world.match.score.ct = scoreCt;
421 world.match.timer = timer;
422 world.match.matchWinner = matchWinner === 1 ? "t" : matchWinner === 2 ? "ct" : null;
423 const bomb: Bomb = world.bomb;
424 bomb.state = bombState;
425 bomb.pos = bombPos;
426 bomb.plantProgress = plantProgress;
427 bomb.defuseProgress = defuseProgress;
428 bomb.site = site === 1 ? "A" : site === 2 ? "B" : null;
429 world.bombPlanted = world.bombPlanted || bombState === "planted" || bombState === "defused";
430
431 mine = null;
432 for (let i = 0; i < count; i++) readPlayer(c, world.players[i] as Player, i === seat);
433 const own = mine;
434
435 if (!c.has(1)) return null;
436 const grenades = c.ru8();
437 if (grenades > MAX_GRENADES || !c.has(grenades * 16)) return null;
438 world.grenades.length = 0;
439 for (let i = 0; i < grenades; i++) {
440 const pos: Vec3 = [finite(c.rf32()), finite(c.rf32()), finite(c.rf32())];
441 const weapon = THROWN_IDS[c.ru8()] ?? "he";
442 const owner = c.ru8();
443 const team = c.ru8() === 1 ? "t" : "ct";
444 const fuse = c.ru8() / 32;
445 const grenade: Grenade = {
446 pos, vel: [0, 0, 0], onGround: false, ducking: false, duckAmount: 0,
447 weapon, owner, team, fuse,
448 };
449 world.grenades.push(grenade);
450 }
451 if (!c.has(1)) return null;
452 const clouds = c.ru8();
453 if (clouds > MAX_CLOUDS || !c.has(clouds * 14)) return null;
454 world.clouds.length = 0;
455 for (let i = 0; i < clouds; i++) {
456 const pos: Vec3 = [finite(c.rf32()), finite(c.rf32()), finite(c.rf32())];
457 const born = world.time - c.ru8() / 16;
458 const owner = c.ru8();
459 const cloud: Cloud = { pos, born, owner };
460 world.clouds.push(cloud);
461 }
462 if (!c.has(1)) return null;
463 const events = c.ru8();
464 let read = 0;
465 for (let i = 0; i < events; i++) {
466 const effect = readEvent(c, seat);
467 if (effect === null) break;
468 pushEffect(world.effects, effect, effect.at);
469 read += 1;
470 }
471 return { tick, time, events: read, own };
472 }
473
474 let mine: { pos: Vec3; vel: Vec3 } | null = null;
475
476 function readPlayer(c: Cursor, p: Player, own: boolean): void {
477 const pos: Vec3 = [finite(c.rf32()), finite(c.rf32()), finite(c.rf32())];
478 const vel: Vec3 = [finite(c.rf32()), finite(c.rf32()), finite(c.rf32())];
479 const yaw = unpackSigned(c.ru16());
480 const pitch = clamp(unpackSigned(c.ru16()), -89, 89);
481 // Your own body's position and view are yours: the session reconciles the one
482 // and the mouse owns the other, so the server's copy is handed back rather
483 // than written in. Everybody else's is simply the truth.
484 if (own) {
485 mine = { pos, vel };
486 } else {
487 p.pos = pos;
488 p.vel = vel;
489 p.yaw = yaw;
490 p.pitch = pitch;
491 }
492 p.health = c.ru8();
493 p.armor = c.ru8();
494 const flags = c.ru8();
495 p.alive = (flags & 1) !== 0;
496 p.team = (flags & 2) !== 0 ? "t" : "ct";
497 p.onGround = (flags & 4) !== 0;
498 p.ducking = (flags & 8) !== 0;
499 p.hasBomb = (flags & 16) !== 0;
500 p.helmet = (flags & 32) !== 0;
501 p.defuseKit = (flags & 64) !== 0;
502 p.scoped = (flags & 128) !== 0;
503 p.duckAmount = c.ru8() / 255;
504 const weapon = WEAPON_IDS[c.ru8()];
505 const primaryAt = c.ru8();
506 const secondary = WEAPON_IDS[c.ru8()];
507 if (weapon !== undefined) p.weapon = weapon;
508 p.primary = primaryAt === 255 ? null : (WEAPON_IDS[primaryAt] ?? null);
509 if (secondary !== undefined) p.secondary = secondary;
510 const mag = c.ru16();
511 const reserve = c.ru16();
512 // The HUD reads the magazine off `ammo[weapon]`, so the held weapon always
513 // has one here even on a body that arrived holding something new.
514 p.ammo[p.weapon] = { mag, reserve };
515 if (p.primary !== null && p.ammo[p.primary] === undefined) p.ammo[p.primary] = emptyAmmo(p.primary);
516 p.money = c.ru16();
517 p.blind = c.ru8() / 32;
518 const pocket = c.ru8();
519 p.pocket = { he: pocket & 3, flash: (pocket >> 2) & 3, smoke: (pocket >> 4) & 3 };
520 p.kills = c.ru8();
521 p.deaths = c.ru8();
522 p.damage = c.ru16();
523 }
524
525 function readEvent(c: Cursor, seat: number): Effect | null {
526 if (!c.has(5)) return null;
527 const kind = EFFECT_KINDS[c.ru8()];
528 const at = finite(c.rf32());
529 if (kind === undefined) return null;
530 const need = kind === "tracer" ? 24 : kind === "impact" ? 15 : kind === "blood" ? 12 : kind === "blast" ? 13 : kind === "shot" ? 14 : 14;
531 if (!c.has(need)) return null;
532 if (kind === "tracer") {
533 const from: Vec3 = [finite(c.rf32()), finite(c.rf32()), finite(c.rf32())];
534 const to: Vec3 = [finite(c.rf32()), finite(c.rf32()), finite(c.rf32())];
535 return { kind, at, from, to };
536 }
537 const pos: Vec3 = [finite(c.rf32()), finite(c.rf32()), finite(c.rf32())];
538 if (kind === "impact") {
539 const normal: Vec3 = [c.ri8() / 127, c.ri8() / 127, c.ri8() / 127];
540 return { kind, at, pos, normal };
541 }
542 if (kind === "blood") return { kind, at, pos };
543 if (kind === "shot") {
544 const weapon = WEAPON_IDS[c.ru8()] ?? "knife";
545 const by = c.ru8();
546 return { kind, at, pos, weapon, mine: by === seat, by };
547 }
548 if (kind === "blast") {
549 const weapon = THROWN_IDS[c.ru8()] ?? "he";
550 return { kind, at, pos, weapon };
551 }
552 const name = SOUNDS[c.ru8()] ?? "step";
553 const by = c.ru8();
554 return { kind: "sound", at, pos, name, by: by === 255 ? -1 : by };
555 }
556
557 /**
558 * What a member is told once, when they join: which body is theirs, what the
559 * round is played on, and who else is here. It is JSON and not packed, because
560 * it is sent once and read by a person in a log when something is wrong.
561 *
562 * The map and the seed are what the page carves its own copy of the level from,
563 * so nothing about the geometry crosses the wire: the same two numbers that
564 * make a match reproducible make a room's world reproducible.
565 */
566 export type Hello = {
567 k: "hello";
568 code: string;
569 seat: number;
570 map: string;
571 seed: number;
572 rules: "short" | "long";
573 tick: number;
574 snapshotHz: number;
575 roster: Seated[];
576 };
577
578 /** One body in the room, and whether a person or a bot is holding it. */
579 export type Seated = { seat: number; name: string; bot: boolean; team: "t" | "ct" };
580
581 /** The roster again, whenever somebody joins or leaves. */
582 export type Roster = { k: "roster"; roster: Seated[] };
583
584 /**
585 * The words of the round: the kill feed and the lines in the middle of the
586 * screen. Both are written inside the world's own tick — a room's client never
587 * runs one — so they come down as themselves rather than being re-derived from
588 * numbers that could not say them.
589 */
590 export type Log = {
591 k: "log";
592 feed: { at: number; killer: string; victim: string; weapon: string; headshot: boolean; killerTeam: "t" | "ct" }[];
593 say: { at: number; text: string }[];
594 };
595
596 export type Down = Hello | Roster | Log;
597
598 /**
599 * A control message off the wire, validated field by field, or null. It comes
600 * from this app's own server, and it is checked anyway: a page that trusts
601 * whatever JSON a socket hands it is a page one proxy away from a surprise, and
602 * every other input in this repository is checked at the boundary.
603 */
604 export function readDown(text: string): Down | null {
605 let value: unknown;
606 try {
607 value = JSON.parse(text);
608 } catch {
609 return null;
610 }
611 if (typeof value !== "object" || value === null) return null;
612 const v = value as Record<string, unknown>;
613 if (v.k === "hello") {
614 const roster = readRoster(v.roster);
615 if (
616 typeof v.code !== "string" || typeof v.seat !== "number" || typeof v.map !== "string" ||
617 typeof v.seed !== "number" || !Number.isFinite(v.seed) ||
618 (v.rules !== "short" && v.rules !== "long") || typeof v.tick !== "number" ||
619 typeof v.snapshotHz !== "number" || roster === null
620 ) return null;
621 return {
622 k: "hello", code: v.code, seat: Math.trunc(v.seat), map: v.map, seed: v.seed,
623 rules: v.rules, tick: Math.trunc(v.tick), snapshotHz: v.snapshotHz, roster,
624 };
625 }
626 if (v.k === "roster") {
627 const roster = readRoster(v.roster);
628 return roster === null ? null : { k: "roster", roster };
629 }
630 if (v.k === "log") {
631 if (!Array.isArray(v.feed) || !Array.isArray(v.say)) return null;
632 const feed: Log["feed"] = [];
633 for (const entry of v.feed as Record<string, unknown>[]) {
634 if (
635 typeof entry?.at !== "number" || typeof entry.killer !== "string" || typeof entry.victim !== "string" ||
636 typeof entry.weapon !== "string" || typeof entry.headshot !== "boolean" ||
637 (entry.killerTeam !== "t" && entry.killerTeam !== "ct")
638 ) return null;
639 feed.push({
640 at: entry.at, killer: entry.killer.slice(0, 32), victim: entry.victim.slice(0, 32),
641 weapon: entry.weapon.slice(0, 16), headshot: entry.headshot, killerTeam: entry.killerTeam,
642 });
643 }
644 const say: Log["say"] = [];
645 for (const line of v.say as Record<string, unknown>[]) {
646 if (typeof line?.at !== "number" || typeof line.text !== "string") return null;
647 say.push({ at: line.at, text: line.text.slice(0, 160) });
648 }
649 return { k: "log", feed, say };
650 }
651 return null;
652 }
653
654 function readRoster(value: unknown): Seated[] | null {
655 if (!Array.isArray(value) || value.length > MAX_PLAYERS) return null;
656 const out: Seated[] = [];
657 for (const entry of value as Record<string, unknown>[]) {
658 if (
659 typeof entry?.seat !== "number" || typeof entry.name !== "string" || typeof entry.bot !== "boolean" ||
660 (entry.team !== "t" && entry.team !== "ct")
661 ) return null;
662 out.push({ seat: Math.trunc(entry.seat), name: entry.name.slice(0, 24), bot: entry.bot, team: entry.team });
663 }
664 return out;
665 }
666
667 /**
668 * The round's effects, dropped: what a member's world gets on the frame they
669 * join, so the first snapshot does not arrive on top of two seconds of a
670 * freeze-time round nobody watched. Effects are remembered for `EFFECT_LIFE`
671 * seconds, which is why this is worth doing rather than waiting it out.
672 */
673 export const forget = (world: World): void => {
674 resetEffects(world.effects);
675 world.grenades.length = 0;
676 world.clouds.length = 0;
677 };
src/server.ts +158 −4
@@ -60,6 +60,7 @@ import { createLimiter } from "./limit.ts";
6060 import { examplePage } from "./pages/example.ts";
6161 import { type Asked, gamePage, STYLE as GAME_STYLE, statsHtml } from "./pages/game.ts";
6262 import { mapFrom } from "./mapname.ts";
63 import { seedFrom } from "./client/generate.ts";
6364 import { localPlan } from "./plan/plan.ts";
6465 import { parseSituation } from "./plan/situation.ts";
6566 import { createPlanner, type Planner } from "./plan/typesafe.ts";
@@ -68,6 +69,9 @@ import { errorPage, notFoundPage, STYLE as MISS_STYLE } from "./pages/miss.ts";
6869 import { boardsHtml, rangePage } from "./pages/range.ts";
6970 import { STYLE as WEAPONS_STYLE, weaponsPage, WEAPONS_HREF } from "./pages/weapons.ts";
7071 import { policyFor } from "./policy.ts";
72 import { roomCodeFrom } from "./rooms/code.ts";
73 import { createHub, CLOSE_GONE, MESSAGE_MAX, ROOM_CAP } from "./rooms/hub.ts";
74 import { playedOn } from "./mapname.ts";
7175 import { styleFor, type Linked } from "./pages/shell.ts";
7276 import { cleanName, parseResult } from "./result.ts";
7377 import { parseRun } from "./run.ts";
@@ -277,6 +281,52 @@ const planLimit = createLimiter(PLAN_LIMIT);
277281 export const PLAN_SPEND = { burst: 30, per: 2000 };
278282 const planSpend = createLimiter(PLAN_SPEND);
279283
284 /**
285 * How often one caller may open a room. A room is a world stepped 64 times a
286 * second for as long as somebody is in it, so opening one is the most expensive
287 * thing a visitor can ask this process for: three back to back and one more
288 * every twenty seconds is a person making a room for their friends, and a wall
289 * to a script filling `ROOM_CAP` in a second. Past it the answer is a sentence
290 * and a 429, never a room.
291 */
292 export const ROOM_LIMIT = { burst: 3, per: 20_000 };
293 const roomLimit = createLimiter(ROOM_LIMIT);
294
295 /**
296 * Every room this process is holding (`src/rooms/hub.ts`), and the sockets they
297 * are seen through. The hub knows nothing about a socket: it is handed two
298 * functions — deliver these bytes to that member, close that member — and this
299 * is the only file that knows what a WebSocket is.
300 *
301 * A member id is one connection and not one browser: two tabs of the same
302 * session are two members, and a reload is a new one whose predecessor is taken
303 * out of the room by its own close. So a seat is never held by a tab that is
304 * gone, and a session cookie is not a claim on a seat.
305 */
306 const sockets = new Map<string, Bun.ServerWebSocket<Joined>>();
307 const hub = createHub({
308 deliver(id, frame) {
309 sockets.get(id)?.send(frame);
310 },
311 close(id, code, reason) {
312 sockets.get(id)?.close(code, reason);
313 },
314 }, { now: Date.now() });
315
316 /** What one connection is: which member, in which room, under what name. */
317 type Joined = { id: string; code: string; name: string };
318
319 /**
320 * The clock every room steps on. One interval for the whole process rather than
321 * one per room: `hub.advance` takes the wall clock, steps each room by the ticks
322 * that have fallen due and sends the snapshots that are due with them, so ten
323 * rooms are one timer and not ten. Ten milliseconds is below the 15.6 ms a tick
324 * takes, so no tick waits for the next wake-up, and the whole call is nothing at
325 * all when no room is open.
326 */
327 const roomClock = setInterval(() => hub.advance(Date.now()), 10);
328 roomClock.unref();
329
280330 /**
281331 * Who a result is recorded as. A verified forge identity plays under its own
282332 * name and the browser's claim is ignored; everyone else is a guest, whose
@@ -691,13 +741,53 @@ const isRead = (request: Request): boolean => request.method === "GET" || reques
691741
692742 const listen = bind(process.env);
693743
694 export const server = Bun.serve({
744 /**
745 * The one answer that is not an answer: an upgraded request has already been
746 * taken over by the socket, and `send` must not write a body to it.
747 */
748 const UPGRADED = new Response(null, { status: 101 });
749
750 /** Where a room's socket answers. One path, in one place, for both ends. */
751 export const SOCKET_PATH = "/ws";
752
753 export const server = Bun.serve<Joined, never>({
695754 port: listen.port,
696755 hostname: listen.hostname,
697756 async fetch(request, listener) {
698 return send(request, await route(request, listener));
757 const made = await route(request, listener);
758 // A 101 is the socket's, already written: nothing more goes on that wire.
759 if (made === UPGRADED) return undefined as unknown as Response;
760 return send(request, made);
699761 },
700762 error: onError,
763 websocket: {
764 // The frame cap is the socket's own as well as the hub's: a frame past it
765 // is refused by Bun before this process ever holds the bytes.
766 maxPayloadLength: MESSAGE_MAX,
767 // Bun closes a connection it has heard nothing on for this long, in
768 // seconds. The page sends an input frame every tick, so ten seconds of
769 // silence is already a connection that is gone; the hub's own sweep is the
770 // one that frees the seat.
771 idleTimeout: 30,
772 open(ws) {
773 sockets.set(ws.data.id, ws);
774 const joined = hub.enter(ws.data.code, ws.data.id, ws.data.name, Date.now());
775 if (joined === null) {
776 ws.close(CLOSE_GONE, "that room is full");
777 sockets.delete(ws.data.id);
778 }
779 },
780 message(ws, message) {
781 // Only binary frames are input. A text frame is not part of this protocol
782 // in that direction at all, so it is dropped rather than parsed.
783 if (typeof message === "string") return;
784 hub.frame(ws.data.id, new Uint8Array(message.buffer, message.byteOffset, message.byteLength), Date.now());
785 },
786 close(ws) {
787 sockets.delete(ws.data.id);
788 hub.exit(ws.data.id, Date.now());
789 },
790 },
701791 });
702792
703793 /**
@@ -705,7 +795,7 @@ export const server = Bun.serve({
705795 * body that was weighed at start-up. Nothing here sets `content-encoding` or
706796 * drops a HEAD's body — `send` above does both, for all of it, in one place.
707797 */
708 async function route(request: Request, listener: Bun.Server<undefined>): Promise<Response | Ready> {
798 async function route(request: Request, listener: Bun.Server<Joined>): Promise<Response | Ready> {
709799 const url = new URL(request.url);
710800 if (request.method === "POST" && !sameOrigin(request))
711801 return text("forbidden", 403);
@@ -765,6 +855,70 @@ async function route(request: Request, listener: Bun.Server<undefined>): Promise
765855 return json(mine ? await (planner as Planner).plan(situation) : localPlan(situation));
766856 }
767857
858 /**
859 * A room to play in with other people. It is a POST because it makes one: a
860 * world this process will step 64 times a second until it empties, which is
861 * the most expensive thing on this server a visitor can ask for. So it is
862 * same-origin (refused above), rate-limited per caller, capped in number by
863 * the hub, and it answers with a code and nothing else.
864 *
865 * The map and the length are the two things a room is opened with, and both
866 * are settled here through the doors the game's own query goes through: an
867 * unknown map is de_forge and an unknown length is the short match.
868 */
869 if (request.method === "POST" && url.pathname === "/api/room") {
870 const body = await readBody(request, PLAN_BODY_MAX);
871 if (body === undefined) return text("too large", 413);
872 let parsed: unknown;
873 try {
874 parsed = JSON.parse(new TextDecoder().decode(body));
875 } catch {
876 return text("bad json", 400);
877 }
878 const asked = (typeof parsed === "object" && parsed !== null ? parsed : {}) as Record<string, unknown>;
879 const held = readSession(request);
880 const caller = `room:${listener.requestIP(request)?.address ?? "?"}|${held ?? "-"}`;
881 if (!roomLimit.take(caller))
882 return text("slow down", 429, { "retry-after": String(Math.ceil(ROOM_LIMIT.per / 1000)) });
883 const map = playedOn(asked.map) ?? "de_forge";
884 const length = asked.length === "long" ? "long" : "short";
885 const room = hub.open({ map, seed: seedFrom(crypto.randomUUID()), length, now: Date.now() });
886 if (room === null) return text(`every room is taken — this deployment holds ${ROOM_CAP}`, 503);
887 const session = held ?? newSession();
888 return new Response(JSON.stringify({ code: room.code, map: room.map, length }), {
889 headers: {
890 "content-type": "application/json; charset=utf-8", "cache-control": "no-store",
891 ...SECURITY, "set-cookie": sessionCookie(session),
892 },
893 });
894 }
895
896 /**
897 * And the socket into one. It is refused rather than dropped: a browser that
898 * cannot upgrade gets a status and a sentence, so the page can say which of
899 * the four things went wrong. A plain GET of this path — no upgrade header —
900 * falls through to the 404 every other wrong path gets.
901 *
902 * What it takes: same-origin, a room that exists, and a session. The name is
903 * whatever the page sent, cleaned and made unique inside the room.
904 */
905 if (isRead(request) && url.pathname === SOCKET_PATH) {
906 if (!sameOrigin(request)) return text("forbidden", 403);
907 const code = roomCodeFrom(url.searchParams.get("room"));
908 if (code === null) return text("not a room code", 400);
909 if (hub.find(code) === undefined) return text("no room with that code — it may have been idle too long", 404);
910 const session = readSession(request) ?? newSession();
911 const id = `${session}.${crypto.randomUUID().slice(0, 8)}`;
912 const name = (url.searchParams.get("name") ?? "").slice(0, 24);
913 const upgraded = listener.upgrade(request, {
914 data: { id, code, name },
915 headers: { ...SECURITY, "set-cookie": sessionCookie(session) },
916 });
917 // A plain GET of this path — a person following a link, a crawler — is not
918 // an error, it is a path with no page: the app's own 404, like any other.
919 return upgraded ? UPGRADED : MISS;
920 }
921
768922 // The games, one table rather than one branch each. HEAD is GET without a
769923 // body, so a health check on `/` sees the app up.
770924 if (isRead(request)) {
@@ -793,7 +947,7 @@ if (listen.unusable !== undefined)
793947
794948 console.log(
795949 `cs listening on http://${server.hostname}:${server.port} (${env}) serving ${[...scripts.values()].join(", ")}` +
796 ` — ${buildSummary(built, builtIn, BUNDLES)} — ${staticSummary()}` +
950 ` — ${buildSummary(built, builtIn, BUNDLES)} — ${staticSummary()} — up to ${ROOM_CAP} rooms on ${SOCKET_PATH}` +
797951 `${identityKey ? "" : " — no FORGE_APP_IDENTITY_KEY: every visitor plays as a guest"}` +
798952 `${planner ? "" : " — no TYPESAFE_API_KEY: the bots plan their rounds locally"}`,
799953 );

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs