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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/300-multiplayer-i-need-to-run-this-with-my-f → main 4 commits fork
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 | ||
| 1503 | 1503 | what PufferLib is known for rather than from its documentation: flat observation |
| 1504 | 1504 | and action spaces, environment throughput treated as the thing that decides |
| 1505 | 1505 | 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 | ||
| 49 | 49 | |
| 50 | 50 | test("the entry table is what the app ships today, each with the size it may not pass", () => { |
| 51 | 51 | 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 } }, | |
| 53 | 53 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 37_000, gzip: 15_500 } }, |
| 54 | 54 | { name: "example", entry: "src/example/main.ts", ceiling: { raw: 23_500, gzip: 10_200 } }, |
| 55 | 55 | ]); |
src/client/main.ts +159 −3
| @@ -100,6 +100,10 @@ import { drawBuy, HINT, labelOf, offers } from "./buy.ts"; | ||
| 100 | 100 | import { buy, canBuy, priceOf, type BuyItem } from "./economy.ts"; |
| 101 | 101 | import { atRound, createLoadout, noteBuy, rebuy, saidRebuy } from "./loadout.ts"; |
| 102 | 102 | 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"; | |
| 103 | 107 | import { unpack } from "../engine/index.ts"; |
| 104 | 108 | |
| 105 | 109 | const TICK = 1 / 64; |
| @@ -142,6 +146,12 @@ export const NO_POLICY = | ||
| 142 | 146 | "if it keeps happening, this deployment is missing the weights the page asked for."; |
| 143 | 147 | |
| 144 | 148 | /** 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 | ||
| 145 | 155 | export const COPIED = "Copied"; |
| 146 | 156 | export const NOT_COPIED = "Copy failed — the link still works"; |
| 147 | 157 | |
| @@ -285,6 +295,12 @@ export type Host = { | ||
| 285 | 295 | * into a message. `location.origin`; no path, no query, nothing of the id. |
| 286 | 296 | */ |
| 287 | 297 | 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; | |
| 288 | 304 | /** |
| 289 | 305 | * `navigator.clipboard.writeText`, when this browser has one and the page is |
| 290 | 306 | * 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 { | ||
| 372 | 388 | const copyLabel = copyMap.textContent; |
| 373 | 389 | const prep = by("prep"); |
| 374 | 390 | 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"); | |
| 375 | 395 | // Where this game's weights are: the shell put the link in the head with the |
| 376 | 396 | // content-hashed URL on it, so this is the only place the path is known. |
| 377 | 397 | const weightsLink = doc.getElementById("weights") as Link | null; |
| @@ -514,6 +534,14 @@ export function boot(host: Host): Frame { | ||
| 514 | 534 | // meshes were built from, and a match on another map swaps that one mesh. |
| 515 | 535 | let shown: Level = DE_FORGE; |
| 516 | 536 | 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; | |
| 517 | 545 | let lastSound = 0; |
| 518 | 546 | let frameAverage = 16.7; |
| 519 | 547 | let posted = -1; |
| @@ -567,6 +595,12 @@ export function boot(host: Host): Frame { | ||
| 567 | 595 | } |
| 568 | 596 | show(prep, false); |
| 569 | 597 | 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; | |
| 570 | 604 | } |
| 571 | 605 | |
| 572 | 606 | // --- the buy menu, rebuilt whenever it opens so the prices are honest |
| @@ -606,6 +640,13 @@ export function boot(host: Host): Frame { | ||
| 606 | 640 | function pressBuy(item: BuyItem): void { |
| 607 | 641 | if (world === null) return; |
| 608 | 642 | 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 | } | |
| 609 | 650 | if (buy(world, me, item)) { |
| 610 | 651 | noteBuy(loadout, item); |
| 611 | 652 | audio.beep(700, 0.05); |
| @@ -1027,6 +1068,96 @@ export function boot(host: Host): Frame { | ||
| 1027 | 1068 | host.storage.setItem("cs.name", name); |
| 1028 | 1069 | } |
| 1029 | 1070 | |
| 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 | ||
| 1030 | 1161 | play.addEventListener("click", start); |
| 1031 | 1162 | // Resume is the gesture that puts back whatever the pause cost: the screen |
| 1032 | 1163 | // first, if a browser took it, and then the pointer. |
| @@ -1043,6 +1174,8 @@ export function boot(host: Host): Frame { | ||
| 1043 | 1174 | */ |
| 1044 | 1175 | function toMenu(): void { |
| 1045 | 1176 | running = false; |
| 1177 | session?.close(); | |
| 1178 | session = null; | |
| 1046 | 1179 | world = null; |
| 1047 | 1180 | wantsFull = false; |
| 1048 | 1181 | full.exit(); |
| @@ -1208,11 +1341,18 @@ export function boot(host: Host): Frame { | ||
| 1208 | 1341 | planAt = null; |
| 1209 | 1342 | drawMapPlan(); |
| 1210 | 1343 | } |
| 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 | } | |
| 1211 | 1351 | if (world === null || !running) return; |
| 1212 | 1352 | |
| 1213 | 1353 | // A round nobody has planned yet — the first of a match, or the one that |
| 1214 | 1354 | // 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); | |
| 1216 | 1356 | // And the same frame rolls the wallet's memory over: what was bought in the |
| 1217 | 1357 | // round that just ended is what X buys back in this one. |
| 1218 | 1358 | atRound(loadout, world.match.round); |
| @@ -1237,12 +1377,28 @@ export function boot(host: Host): Frame { | ||
| 1237 | 1377 | slotWanted === 1 ? me.primary : null; |
| 1238 | 1378 | |
| 1239 | 1379 | 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; | |
| 1240 | 1384 | // Buying keeps the pointer, so the keys have to be kept off the player: with |
| 1241 | 1385 | // the panel open the trigger is dead and the number row buys instead of |
| 1242 | 1386 | // switching weapons. Movement is not suppressed, and never was — walking out |
| 1243 | 1387 | // 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 | } | |
| 1246 | 1402 | |
| 1247 | 1403 | // The camera, and with it whose eyes this frame is drawn and heard from. |
| 1248 | 1404 | // 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 = { | ||
| 207 | 207 | * contract between the page and this file, so `src/server.test.ts` checks the |
| 208 | 208 | * served page against this same list rather than a second copy of it. |
| 209 | 209 | */ |
| 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"]; | |
| 211 | 211 | |
| 212 | 212 | /** The ones the server renders with `hidden` on them: the page starts at the menu. */ |
| 213 | 213 | const HIDDEN = ["pause", "summary", "buy", "touch", "full"]; |
src/client/world.test.ts +45 −2
| @@ -8,14 +8,14 @@ import { WEAPONS } from "./weapons.ts"; | ||
| 8 | 8 | import { buy } from "./economy.ts"; |
| 9 | 9 | import { |
| 10 | 10 | 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, | |
| 12 | 12 | type Input, type Player, type Sound, type World, |
| 13 | 13 | } from "./world.ts"; |
| 14 | 14 | import { cloudRadius, type Cloud } from "./grenade.ts"; |
| 15 | 15 | import { HE, SMOKE } from "./weapons.ts"; |
| 16 | 16 | import { CS_HULL } from "./movement.ts"; |
| 17 | 17 | 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, | |
| 19 | 19 | } from "../engine/index.ts"; |
| 20 | 20 | |
| 21 | 21 | const TICK = 1 / 64; |
| @@ -655,3 +655,46 @@ test("a blast you are caught in came from the blast, and not from whoever threw | ||
| 655 | 655 | expect(Math.hypot(hit.from[0] - world.me.pos[0], hit.from[1] - world.me.pos[1])) |
| 656 | 656 | .toBeLessThan(HE.blast.radius); |
| 657 | 657 | }); |
| 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 = | ||
| 112 | 112 | | { kind: "tracer"; from: Vec3; to: Vec3; at: number } |
| 113 | 113 | | { kind: "impact"; at: number; pos: Vec3; normal: Vec3 } |
| 114 | 114 | | { 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 } | |
| 116 | 123 | | { kind: "hitmark"; at: number; headshot: boolean } |
| 117 | 124 | /** |
| 118 | 125 | * 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 { | ||
| 552 | 559 | const dir = angleVectors(yaw, pitch).forward; |
| 553 | 560 | const to: Vec3 = [from[0] + dir[0] * range, from[1] + dir[1] * range, from[2] + dir[2] * range]; |
| 554 | 561 | 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 }); | |
| 556 | 563 | if (w.kind !== "knife") record(world, { kind: "tracer", at: world.time, from, to: hit.kind === "none" ? to : hit.point }); |
| 557 | 564 | |
| 558 | 565 | if (hit.kind === "world") { |
| @@ -793,8 +800,27 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v | ||
| 793 | 800 | bombTick(world, p, input.use, dt); |
| 794 | 801 | } |
| 795 | 802 | |
| 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 { | |
| 798 | 824 | world.time += dt; |
| 799 | 825 | const before = world.match.phase; |
| 800 | 826 | for (const event of rules.tick(world.match, dt)) { |
| @@ -838,10 +864,10 @@ export function update(world: World, dt: number, input: Input): void { | ||
| 838 | 864 | // and it does so for everybody, standing or dead, before anyone acts on it. |
| 839 | 865 | for (const p of world.players) if (p.blind > 0) p.blind = Math.max(0, p.blind - dt); |
| 840 | 866 | |
| 841 | applyInput(world, world.me, world.me.alive ? input : NO_INPUT, dt); | |
| 842 | 867 | 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); | |
| 845 | 871 | } |
| 846 | 872 | // After everyone has moved, so a grenade is solved against where people |
| 847 | 873 | // ended the tick rather than where they started it. |
| @@ -859,6 +885,16 @@ export function update(world: World, dt: number, input: Input): void { | ||
| 859 | 885 | expireEffects(world.effects, world.time, EFFECT_LIFE); |
| 860 | 886 | } |
| 861 | 887 | |
| 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 | ||
| 862 | 898 | /** |
| 863 | 899 | * Is `target` visible to `from`, eye to eye? The one question the whole game |
| 864 | 900 | * 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[] = [ | ||
| 98 | 98 | label: "cs", |
| 99 | 99 | entry: "src/client/main.ts", |
| 100 | 100 | /** |
| 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. | |
| 106 | 106 | */ |
| 107 | ceiling: { raw: 110_000, gzip: 43_500 }, | |
| 107 | ceiling: { raw: 122_000, gzip: 47_500 }, | |
| 108 | 108 | assets: [ |
| 109 | 109 | { |
| 110 | 110 | name: "weights", |
src/pages/game.ts +10
| @@ -356,6 +356,16 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => ` | ||
| 356 | 356 | </div> |
| 357 | 357 | </div> |
| 358 | 358 | <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'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> | |
| 359 | 369 | <p class="hint" id="prep">Preparing textures…</p> |
| 360 | 370 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk · |
| 361 | 371 | 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"; | ||
| 60 | 60 | import { examplePage } from "./pages/example.ts"; |
| 61 | 61 | import { type Asked, gamePage, STYLE as GAME_STYLE, statsHtml } from "./pages/game.ts"; |
| 62 | 62 | import { mapFrom } from "./mapname.ts"; |
| 63 | import { seedFrom } from "./client/generate.ts"; | |
| 63 | 64 | import { localPlan } from "./plan/plan.ts"; |
| 64 | 65 | import { parseSituation } from "./plan/situation.ts"; |
| 65 | 66 | import { createPlanner, type Planner } from "./plan/typesafe.ts"; |
| @@ -68,6 +69,9 @@ import { errorPage, notFoundPage, STYLE as MISS_STYLE } from "./pages/miss.ts"; | ||
| 68 | 69 | import { boardsHtml, rangePage } from "./pages/range.ts"; |
| 69 | 70 | import { STYLE as WEAPONS_STYLE, weaponsPage, WEAPONS_HREF } from "./pages/weapons.ts"; |
| 70 | 71 | 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"; | |
| 71 | 75 | import { styleFor, type Linked } from "./pages/shell.ts"; |
| 72 | 76 | import { cleanName, parseResult } from "./result.ts"; |
| 73 | 77 | import { parseRun } from "./run.ts"; |
| @@ -277,6 +281,52 @@ const planLimit = createLimiter(PLAN_LIMIT); | ||
| 277 | 281 | export const PLAN_SPEND = { burst: 30, per: 2000 }; |
| 278 | 282 | const planSpend = createLimiter(PLAN_SPEND); |
| 279 | 283 | |
| 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 | ||
| 280 | 330 | /** |
| 281 | 331 | * Who a result is recorded as. A verified forge identity plays under its own |
| 282 | 332 | * 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 | ||
| 691 | 741 | |
| 692 | 742 | const listen = bind(process.env); |
| 693 | 743 | |
| 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>({ | |
| 695 | 754 | port: listen.port, |
| 696 | 755 | hostname: listen.hostname, |
| 697 | 756 | 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); | |
| 699 | 761 | }, |
| 700 | 762 | 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 | }, | |
| 701 | 791 | }); |
| 702 | 792 | |
| 703 | 793 | /** |
| @@ -705,7 +795,7 @@ export const server = Bun.serve({ | ||
| 705 | 795 | * body that was weighed at start-up. Nothing here sets `content-encoding` or |
| 706 | 796 | * drops a HEAD's body — `send` above does both, for all of it, in one place. |
| 707 | 797 | */ |
| 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> { | |
| 709 | 799 | const url = new URL(request.url); |
| 710 | 800 | if (request.method === "POST" && !sameOrigin(request)) |
| 711 | 801 | return text("forbidden", 403); |
| @@ -765,6 +855,70 @@ async function route(request: Request, listener: Bun.Server<undefined>): Promise | ||
| 765 | 855 | return json(mine ? await (planner as Planner).plan(situation) : localPlan(situation)); |
| 766 | 856 | } |
| 767 | 857 | |
| 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 | ||
| 768 | 922 | // The games, one table rather than one branch each. HEAD is GET without a |
| 769 | 923 | // body, so a health check on `/` sees the app up. |
| 770 | 924 | if (isRead(request)) { |
| @@ -793,7 +947,7 @@ if (listen.unusable !== undefined) | ||
| 793 | 947 | |
| 794 | 948 | console.log( |
| 795 | 949 | `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}` + | |
| 797 | 951 | `${identityKey ? "" : " — no FORGE_APP_IDENTITY_KEY: every visitor plays as a guest"}` + |
| 798 | 952 | `${planner ? "" : " — no TYPESAFE_API_KEY: the bots plan their rounds locally"}`, |
| 799 | 953 | ); |
new above
offline