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

forked from trevato/cs

A room's result counts: the server writes the match it stepped, under the identity that joined #374

Closed opened this
Closed without merging

torque/cs-engine:torque/318-a-room-s-result-counts-the-server-writes → main 1 commit fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
13 files changed +636 −40
README.md +35 −3
@@ -1668,9 +1668,41 @@ What is real and what is not, today:
16681668 after ninety seconds (`src/rooms/hub.ts`).
16691669 - Not yet: your own movement is **not** predicted, so it answers on the round
16701670 trip rather than instantly; a room has no hit marker or damage arrow (both are
1671 effects recorded for one screen and a snapshot goes to everybody); and a room's
1672 result is not posted to the leaderboard. Rooms live in this process only — a
1673 deploy ends them.
1671 effects recorded for one screen and a snapshot goes to everybody). Rooms live
1672 in this process only — a deploy ends them.
1673
1674 ### Whose result a room's match is
1675
1676 A room's result is the one on the leaderboard that is **not** a browser's word:
1677 the round was stepped here, so when the world reaches `match_over` the server
1678 writes the rows itself (`resultsOf` in `src/rooms/room.ts`, written through
1679 `src/seated.ts`), and a page in a room posts nothing to `/api/match` at all. The
1680 rule, which the menu also says before anyone joins:
1681
1682 - A seat held by a member when the match ends is a row for that member. A member
1683 who left before the end — closed the tab, lost the connection, went silent for
1684 thirty seconds — has no row; the bot finished that match, and a bot's match is
1685 nobody's.
1686 - The row counts what the body did **while that member held it**. A seat is a
1687 bot's until somebody sits in it, so kills, deaths, headshots, damage and
1688 seconds are counted from the join; the score is the team's whole match. A
1689 person who joins for the last round has a match played with one round of their
1690 own numbers in it. There is no minimum: the row already says how little it was.
1691 - It is written as the person who joined: the forge identity is verified once at
1692 the socket's upgrade, exactly as `/api/match` verifies it per post, and carried
1693 on the connection. A signed-in member's row is theirs under their forge name,
1694 whatever they typed; a guest's belongs to that browser's session and is shown
1695 as a guest's.
1696 - It is written once. A room is one match; its row ids are the room's match and
1697 the seat, and the hub marks the room recorded on the step that first sees
1698 `match_over`, so stepping on past it writes nothing more.
1699
1700 The numbers still go through `parseResult`, the same bounds a posted match is
1701 held to, and `saveMatch`'s ownership rule is under both doors. `src/rooms/`
1702 itself never touches the database: the hub is handed a writer the way it is
1703 handed a wire, and `src/rooms/hub.test.ts` holds that no file there imports
1704 `bun:sqlite` or `src/db.ts`. The single-player match is unchanged — its result
1705 is still the browser's report (#168).
16741706
16751707 ### What a room costs, measured
16761708
src/client/main.test.ts +38 −1
@@ -31,7 +31,8 @@
3131 import { expect, test } from "bun:test";
3232 import { generateLevel, nameOf, seedFrom, seedOf } from "./generate.ts";
3333 import type { Level } from "./level.ts";
34 import { boot, COPIED, gameMeshes, NOT_AN_ID, NOT_A_MAP, NOT_COPIED, type Frame } from "./main.ts";
34 import { boot, COPIED, gameMeshes, NOT_AN_ID, NOT_A_MAP, NOT_COPIED, ROOM_SAVED, type Frame } from "./main.ts";
35 import type { Socket } from "./session.ts";
3536 import { NO_PICTURE } from "./support.ts";
3637 import { HINT } from "./buy.ts";
3738 import { DE_FORGE } from "./map.ts";
@@ -1729,3 +1730,39 @@ test("a browser that crosses midnight picks up the new day's match, not yesterda
17291730 expect(stage.by("ontoday").textContent).toBe(saidToday(after));
17301731 expect(stage.by("willplay").textContent).toBe(after.map);
17311732 });
1733
1734 // --- a room posts nothing ------------------------------------------------------
1735
1736 test("a page in a room sends nothing to /api/match, through every round and the end of the match", () => {
1737 const stage = fakeStage();
1738 const socket: Socket = {
1739 binaryType: "", send: () => {}, close: () => {},
1740 onopen: null, onmessage: null, onclose: null, onerror: null,
1741 };
1742 stage.host.connect = () => socket;
1743 painted(start(stage));
1744 stage.by("room").value = "ABCDEF";
1745 stage.by("joinroom").fire("click");
1746 socket.onopen?.();
1747 socket.onmessage?.({
1748 data: JSON.stringify({
1749 k: "hello", code: "ABCDEF", seat: 3, map: "de_forge", seed: 9, rules: "short", tick: 64, snapshotHz: 16,
1750 roster: [{ seat: 3, name: "you", bot: false, team: "ct" }],
1751 }),
1752 });
1753 stage.step(FRAME);
1754 const world = worldOf(stage);
1755 if (world === null) throw new Error("the hello built no world");
1756 // The rounds a single-player page would post, one by one, and then the end.
1757 for (let ct = 1; ct <= SHORT.winScore; ct++) {
1758 world.match.score.ct = ct;
1759 stage.step(FRAME);
1760 }
1761 world.match.phase = "match_over";
1762 stage.step(FRAME);
1763 stage.step(FRAME);
1764 expect(stage.posts.filter((post) => post.url === "/api/match")).toEqual([]);
1765 // And the summary says whose word the leaderboard has instead.
1766 expect(stage.by("summary").hidden).toBe(false);
1767 expect(stage.by("sumsaved").textContent).toBe(ROOM_SAVED);
1768 });
src/client/main.ts +11 −3
@@ -153,6 +153,12 @@ export const NO_SOCKET = "This browser has no WebSocket, so it cannot join a roo
153153 /** And what it says when a room could not be made or joined. */
154154 export const NO_ROOM = "That room could not be joined — check the code, or make a new room.";
155155 export const NO_MAKE = "A room could not be made right now. Try again in a moment.";
156 /**
157 * What the summary says about the leaderboard after a room's match. The page
158 * posted nothing — the server stepped the round and writes it — so this is the
159 * rule, not a report on a post: `resultsOf` in `src/rooms/room.ts`.
160 */
161 export const ROOM_SAVED = "This room's match is on the leaderboard for everyone who was seated when it ended — written by the server, not this page.";
156162
157163 export const COPIED = "Copied";
158164 export const NOT_COPIED = "Copy failed — the link still works";
@@ -911,7 +917,7 @@ export function boot(host: Host): Frame {
911917 * on the board while bytes are still in the page's hand.
912918 */
913919 function sayMatch(): void {
914 sumSaved.textContent = reporter.standing();
920 sumSaved.textContent = session !== null ? ROOM_SAVED : reporter.standing();
915921 }
916922
917923 /**
@@ -1236,7 +1242,6 @@ export function boot(host: Host): Frame {
12361242 loadout = createLoadout();
12371243 roomBuy = null;
12381244 posted = -1;
1239 postedFinal = true; // a room's result is the server's to write, never this page's
12401245 loop.reset();
12411246 controls.sensitivity = clamp(Number(sensField.value) || 2.2, 0.2, 12);
12421247 motion = parseMotion(motionField.value);
@@ -1567,7 +1572,10 @@ export function boot(host: Host): Frame {
15671572 // again although no round was scored with it: the last round of a match is
15681573 // won five seconds before the match is over, so a post keyed on the score
15691574 // alone would leave every finished match on the board saying "in progress".
1570 if (played !== posted || (over && !postedFinal)) {
1575 // A room posts nothing, ever: its round was stepped on the server, which
1576 // writes the result itself, and a page that also posted would be a member
1577 // offering a second, better version of the same match.
1578 if (session === null && (played !== posted || (over && !postedFinal))) {
15711579 posted = played;
15721580 postedFinal = over;
15731581 reporter.post(world, playId, nameField.value, over);
src/db.test.ts +84 −2
@@ -10,9 +10,15 @@ import { mkdtempSync } from "node:fs";
1010 import { tmpdir } from "node:os";
1111 import { join } from "node:path";
1212 import {
13 dayBoard, deleteMatch, displayName, IDLE_SECONDS, liveness, openDatabase, type Player, recentMatches, recentRuns,
14 runBoard, saveMatch, saveRun, standings,
13 dayBoard, deleteMatch, displayName, IDLE_SECONDS, liveness, type MatchRow, openDatabase, type Player, recentMatches,
14 recentRuns, runBoard, saveMatch, saveRun, standings,
1515 } from "./db.ts";
16 import { statsHtml } from "./pages/game.ts";
17 import { createHub } from "./rooms/hub.ts";
18 import type { Room } from "./rooms/room.ts";
19 import { ENDING_TICKS, lastRound, opened, spy } from "./rooms/stage.test-helper.ts";
20 import { roomWriter, type Seated } from "./seated.ts";
21 import { ANON, type Who } from "./who.ts";
1622 import { playKey } from "./matchid.ts";
1723 import type { Result } from "./result.ts";
1824 import type { RangeRun } from "./run.ts";
@@ -445,3 +451,79 @@ test("a run belongs to whoever posted it: nobody else writes over that id", () =
445451 expect(saveRun(db, guest("finch", "s10"), range({ runId: mine, score: 9000 }))).toBe(false);
446452 expect(recentRuns(db).find((r) => r.id === mine)!.score).toBe(800);
447453 });
454
455 // --- a room's result, written by the server ----------------------------------
456
457 /**
458 * A room played to the end through the writer `src/server.ts` hands its hub,
459 * with this file's database under it: one member signed in, one a guest who
460 * typed the signed-in member's name, and one who left before the end.
461 */
462 function playedRoom(): { db: Database; logged: string[]; seats: Map<string, Seated> } {
463 const db = openDatabase(dir());
464 const seats = new Map<string, Seated>();
465 const logged: string[] = [];
466 const hub = createHub(spy(), { now: 0, writer: roomWriter(db, (id) => seats.get(id), (l) => void logged.push(l)) });
467 const room = hub.open(opened) as Room;
468 const sit = (id: string, name: string, who: Who, session: string): void => {
469 seats.set(id, { id, code: room.code, name, who, session });
470 hub.enter(room.code, id, name, 0);
471 };
472 // The member's own name field says "impostor": a verified identity wins.
473 sit("m1", "impostor", { role: "user", id: "u-7", name: "kestrel" }, "sess-a");
474 sit("m2", "kestrel", ANON, "sess-b");
475 sit("m3", "quitter", ANON, "sess-c");
476 const body = (id: string) => room.world.players[room.members.get(id)?.seat ?? -1];
477 (body("m1") as { kills: number }).kills += 4;
478 (body("m2") as { kills: number }).kills += 2;
479 hub.exit("m3", 0);
480 seats.delete("m3");
481 lastRound(room, 1);
482 let ms = 0;
483 for (let i = 0; i < ENDING_TICKS * 2; i++) hub.advance((ms += 1000 / 64));
484 return { db, logged, seats };
485 }
486
487 test("a room played to match_over writes a row per seated member, under who they are, and not for a leaver", () => {
488 const { db, logged } = playedRoom();
489 expect(logged).toEqual([]);
490 const rows = recentMatches(db);
491 expect(rows.length).toBe(2);
492 const signed = rows.find((r) => r.user_id === "u-7") as MatchRow;
493 const guestRow = rows.find((r) => r.user_id === "") as MatchRow;
494 // The forge's name, not the one typed; and the guest shown as a guest.
495 expect(signed.name).toBe("kestrel");
496 expect(displayName(signed)).toBe("kestrel");
497 expect(guestRow.session).toBe("sess-b");
498 expect(displayName(guestRow)).toBe("kestrel (guest)");
499 expect(rows.some((r) => r.name === "quitter")).toBe(false);
500 // The numbers the world ended with: de_forge, first to five, 5–1, finished.
501 for (const row of rows) {
502 expect(row.map).toBe("de_forge");
503 expect(row.rounds).toBe(6);
504 expect(row.finished).toBe(1);
505 expect([row.score_mine, row.score_theirs].sort()).toEqual([1, 5]);
506 }
507 expect(signed.kills).toBe(4);
508 expect(guestRow.kills).toBe(2);
509 // And the leaderboard page has it: two players, one of them a guest.
510 const html = statsHtml(db);
511 expect(html).toContain("kestrel (guest)");
512 expect(standings(db).map((s) => [s.user_id, s.matches])).toEqual(expect.arrayContaining([["u-7", 1], ["", 1]]));
513 });
514
515 test("a room's writer refuses what a post would be refused: another owner's row, and numbers no match makes", () => {
516 const db = openDatabase(dir());
517 const logged: string[] = [];
518 const seated: Seated = { id: "m1", code: "ABCDEF", name: "x", who: ANON, session: "s9" };
519 const writer = roomWriter(db, (id) => (id === "m1" ? seated : undefined), (l) => void logged.push(l));
520 const taken = result();
521 saveMatch(db, player("u-1", "kestrel"), taken);
522 writer.write({ id: "m1", result: { ...taken, kills: 40 } });
523 expect(recentMatches(db).find((r) => r.id === taken.matchId)?.kills).toBe(9);
524 writer.write({ id: "m1", result: result({ headshots: 99 }) });
525 expect(logged.length).toBe(2);
526 // A member whose connection is gone is written for nobody.
527 writer.write({ id: "gone", result: result() });
528 expect(recentMatches(db).length).toBe(1);
529 });
src/pages/game.ts +3 −2
@@ -387,8 +387,9 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => `
387387 </div>
388388 <p class="hint" id="roomsaid">Play with friends: make a room, read the code out, and they join it. A room&#39;s
389389 round is decided on this server, so everyone in it sees the same round — the match above runs in your
390 own browser against bots. Rooms live in this process: a deploy ends them, and an empty one is forgotten
391 after ninety seconds.</p>
390 own browser against bots. When a room&#39;s match ends, the server puts it on the leaderboard for everyone
391 still seated — leave before the end and it is not counted for you. Rooms live in this process: a deploy
392 ends them, and an empty one is forgotten after ninety seconds.</p>
392393 <p class="hint" id="prep">Preparing textures…</p>
393394 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk ·
394395 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/hub.test.ts +72 −3
@@ -26,9 +26,9 @@
2626 import { expect, test } from "bun:test";
2727 import { existsSync, readFileSync, readdirSync } from "node:fs";
2828 import { join, resolve } from "node:path";
29 import { createHub, CLOSE_FLOOD, CLOSE_TOO_BIG, IDLE_MS, MESSAGE_MAX, ROOM_CAP } from "./hub.ts";
30 import { isIdle, type Room } from "./room.ts";
31 import { forward, opened, spy } from "./stage.test-helper.ts";
29 import { createHub, finish, CLOSE_FLOOD, CLOSE_TOO_BIG, IDLE_MS, MESSAGE_MAX, ROOM_CAP, type Writer } from "./hub.ts";
30 import { createRoom, isIdle, isOver, join as sit, type Finished, type Room } from "./room.ts";
31 import { ENDING_TICKS, forward, lastRound, opened, spy } from "./stage.test-helper.ts";
3232 import { type Player } from "../client/world.ts";
3333
3434 test("a hub holds its rooms, hears one member and never the other room", () => {
@@ -123,6 +123,75 @@ test("a member nobody has heard from loses the seat they are holding", () => {
123123 expect(r.world.players.every((p) => p.bot)).toBe(true);
124124 });
125125
126 // --- a finished room, written once ------------------------------------------
127
128 /** A writer made of one array: what a hub hands over, and nothing opened. */
129 const keeping = (): Writer & { kept: Finished[] } => {
130 const kept: Finished[] = [];
131 return { kept, write: (finished) => void kept.push(finished) };
132 };
133
134 test("a room played past match_over writes one result per seated member, once, with no database open", () => {
135 const writer = keeping();
136 const hub = createHub(spy(), { now: 0, writer });
137 const r = hub.open(opened) as Room;
138 hub.enter(r.code, "s1", "sam", 0);
139 hub.enter(r.code, "s2", "kim", 0);
140 hub.enter(r.code, "s3", "lee", 0);
141 hub.exit("s3", 0);
142 lastRound(r);
143 // The whole end delay, a tick at a time on the hub's own clock, and then as
144 // long again with the world standing at match_over.
145 let ms = 0;
146 const run = (ticks: number): void => {
147 for (let i = 0; i < ticks; i++) hub.advance((ms += 1000 / 64));
148 };
149 run(ENDING_TICKS);
150 expect(isOver(r)).toBe(true);
151 expect(writer.kept.map((f) => f.id).sort()).toEqual(["s1", "s2"]);
152 run(ENDING_TICKS * 2);
153 // Somebody arriving after the end is too late for a match that is written.
154 hub.enter(r.code, "s4", "max", ms);
155 run(64);
156 expect(writer.kept.length).toBe(2);
157 expect(r.recorded).toBe(true);
158 // One row a member: two different ids for the two members, both this match.
159 expect(new Set(writer.kept.map((f) => f.result.matchId)).size).toBe(2);
160 });
161
162 /** One member in a room, directly: `finish` is about the room and not the hub. */
163 const seatOne = (r: Room): void => void sit(r, "s1", "sam", 0);
164
165 test("the once-only rule holds by itself: `finish` on a finished room writes nothing the second time", () => {
166 const writer = keeping();
167 const r = createRoom("ABCDEF", opened);
168 seatOne(r);
169 finish(r, writer);
170 expect(writer.kept).toEqual([]); // not over yet: nothing
171 r.world.match.phase = "match_over";
172 finish(r, writer);
173 finish(r, writer);
174 expect(writer.kept.length).toBe(1);
175 // A writer that throws is still a room that is recorded: the next step does
176 // not try the same match again.
177 const r2 = createRoom("ABCDEG", opened);
178 seatOne(r2);
179 r2.world.match.phase = "match_over";
180 expect(() => finish(r2, { write: () => { throw new Error("disk full"); } })).toThrow("disk full");
181 finish(r2, writer);
182 expect(writer.kept.length).toBe(1);
183 });
184
185 test("nothing under src/rooms reaches a database: no `bun:sqlite`, no `src/db.ts`", () => {
186 const offences: string[] = [];
187 for (const file of roomSources()) {
188 const source = readFileSync(join(ROOMS, file), "utf8");
189 for (const line of source.split("\n"))
190 if (/^\s*(import|export)\b.*from\s+["'](bun:sqlite|(\.\.?\/)+db\.ts)["']/.test(line)) offences.push(`${file}: ${line.trim()}`);
191 }
192 expect(offences).toEqual([]);
193 });
194
126195 // --- the directory's own documentation ------------------------------------
127196
128197 const ROOMS = import.meta.dir;
src/rooms/hub.ts +54 −9
@@ -28,22 +28,55 @@
2828 * idle sweep and a room cap without opening a port, and `src/engine/rooms.test.ts`
2929 * runs the same machinery under a game that is not this one.
3030 *
31 * Rooms live in memory and die with the process. Nothing is written under
32 * `FORGE_APP_DATA`: a half-finished round is not worth keeping, a deploy ends
33 * every room it was holding, and the page says so rather than implying a room
34 * survives one.
31 * Rooms live in memory and die with the process. A half-finished round is not
32 * worth keeping, a deploy ends every room it was holding, and the page says so
33 * rather than implying a room survives one.
34 *
35 * What *is* kept is a finished match, and it is kept the way the wire is kept
36 * out of here: the hub is handed a `Writer` — write this result for that
37 * member — and `src/server.ts` is the only file that knows it is a database.
38 * Nothing under `src/rooms/` imports `bun:sqlite` or `src/db.ts`, and
39 * `hub.test.ts` holds that and plays a room to the end with a writer made of
40 * one array. The rule for who gets a row is `room.ts`'s; the rule that a room
41 * writes **once** is here, in `finish`: the step that first finds the world at
42 * `match_over` marks the room recorded and hands over `resultsOf`, and every
43 * step after it — the world stays at `match_over` until the room is swept —
44 * finds it recorded and writes nothing.
3545 */
3646
3747 import { createHub as createEngineHub, type Hub as EngineHub, type RoomGame, type Wire } from "../engine/index.ts";
3848 import { createLimiter, type Limiter } from "../limit.ts";
3949 import {
40 createRoom, describeRoom, drive, helloFor, isIdle, join, leave, logSince, rosterMessage, snapshotFor,
41 stepRoom, SNAPSHOT_HZ, TICK, type Member, type Room,
50 createRoom, describeRoom, drive, helloFor, isIdle, isOver, join, leave, logSince, resultsOf, rosterMessage,
51 snapshotFor, stepRoom, SNAPSHOT_HZ, TICK, type Finished, type Member, type Room,
4252 } from "./room.ts";
4353 import type { PlayedMap } from "../mapname.ts";
4454
4555 export type { Wire };
4656
57 /**
58 * Where a finished match goes: one call per member who held a seat at
59 * `match_over`, with the member id the server joined them under and the
60 * numbers the world ended with. The server turns the id into a person — the
61 * identity it verified at the upgrade — and the result into a row.
62 */
63 export type Writer = { write(finished: Finished): void };
64
65 /** A hub handed no writer keeps nothing, which is what a test of the wire wants. */
66 export const NO_WRITER: Writer = { write: () => {} };
67
68 /**
69 * The once-only rule: a room that has just reached `match_over` and has not
70 * been recorded is recorded now, and never again. It is marked *before* the
71 * writer is called, so a writer that throws cannot make the next step try the
72 * same match a second time.
73 */
74 export function finish(room: Room, writer: Writer): void {
75 if (room.recorded || !isOver(room)) return;
76 room.recorded = true;
77 for (const finished of resultsOf(room)) writer.write(finished);
78 }
79
4780 /**
4881 * How many rooms one deployment holds at once. Twenty rooms is a measured
4982 * 0.13–0.18 s of CPU a second — about a sixth of a core — against a box that
@@ -107,7 +140,8 @@ export type RoomOptions = { map: PlayedMap; seed: number; length: "short" | "lon
107140 * The shooter, as the hub sees it: eleven functions of `room.ts` and the two
108141 * rates a room runs at. This is the whole of what the engine knows about this
109142 * game — no `World`, no `PlayedMap`, no map name — and the only thing that had
110 * to be written down to get the hub out of here.
143 * to be written down to get the hub out of here. `shooter` is the same table
144 * with a writer behind its step: a step that ends the match writes it.
111145 */
112146 export const SHOOTER: RoomGame<Room, Member, RoomOptions> = {
113147 tick: TICK,
@@ -125,6 +159,14 @@ export const SHOOTER: RoomGame<Room, Member, RoomOptions> = {
125159 describe: describeRoom,
126160 };
127161
162 export const shooter = (writer: Writer): RoomGame<Room, Member, RoomOptions> => ({
163 ...SHOOTER,
164 step(room) {
165 stepRoom(room);
166 finish(room, writer);
167 },
168 });
169
128170 /**
129171 * Every room this process is holding. The engine's hub with this game's table
130172 * and this game's numbers in it, and one difference at the door: a room here
@@ -136,8 +178,11 @@ export type Hub = Omit<EngineHub<Room, Member, RoomOptions>, "open"> & {
136178 open(options: RoomOptions): Room | null;
137179 };
138180
139 export function createHub(wire: Wire, options: { now: number; limiter?: Limiter } = { now: Date.now() }): Hub {
140 const hub = createEngineHub(wire, SHOOTER, {
181 export function createHub(
182 wire: Wire,
183 options: { now: number; limiter?: Limiter; writer?: Writer } = { now: Date.now() },
184 ): Hub {
185 const hub = createEngineHub(wire, shooter(options.writer ?? NO_WRITER), {
141186 now: options.now,
142187 limiter: options.limiter ?? createLimiter(FRAME_LIMIT),
143188 rooms: ROOM_CAP,
src/rooms/room.test.ts +87 −3
@@ -13,14 +13,16 @@
1313
1414 import { expect, test } from "bun:test";
1515 import {
16 drive, freeSeat, join, leave, logSince, rosterOf, snapshotFor, stepRoom, uniqueName,
17 INPUT_HOLD, type Room,
16 drive, freeSeat, isOver, join, leave, logSince, resultsOf, rosterOf, snapshotFor, stepRoom, uniqueName,
17 INPUT_HOLD, type Finished, type Member, type Room,
1818 } from "./room.ts";
1919 import { writeInput, SWITCH_PRIMARY, SWITCH_SECONDARY } from "./wire.ts";
2020 // The cap is the hub's, because the hub is what enforces it on a socket; a
2121 // frame of that size is refused here too, and this is where that is read.
2222 import { MESSAGE_MAX } from "./hub.ts";
23 import { forward, room } from "./stage.test-helper.ts";
23 import { ENDING_TICKS, forward, lastRound, room } from "./stage.test-helper.ts";
24 import { matchOf } from "../matchid.ts";
25 import { parseResult, type Result } from "../result.ts";
2426 import { NO_INPUT, type Player } from "../client/world.ts";
2527 import { SHORT } from "../client/match.ts";
2628
@@ -169,3 +171,85 @@ test("one member's snapshot is a snapshot of the room they are in", () => {
169171 // Asking again sends only what happened since: the clock moved on.
170172 expect(member.sentAt).toBeCloseTo(r.world.time, 5);
171173 });
174
175 // --- the result a finished room writes --------------------------------------
176
177 test("a room has no result until its world is at match_over", () => {
178 const r = room();
179 join(r, "s1", "sam", 1000);
180 for (let i = 0; i < 64; i++) stepRoom(r);
181 expect(isOver(r)).toBe(false);
182 expect(resultsOf(r)).toEqual([]);
183 });
184
185 test("two members at match_over are two results: the map, the score the world ended with, their own numbers", () => {
186 const r = room();
187 const sam = join(r, "s1", "sam", 1000) as Member;
188 const kim = join(r, "s2", "kim", 1000) as Member;
189 // Two people land on opposite sides, which is what makes the score a pair.
190 const samBody = r.world.players[sam.seat] as Player;
191 const kimBody = r.world.players[kim.seat] as Player;
192 expect(samBody.team).not.toBe(kimBody.team);
193 samBody.kills += 3;
194 samBody.headshots += 1;
195 samBody.damage += 312.4;
196 kimBody.deaths += 2;
197 lastRound(r, 2);
198 for (let i = 0; i < ENDING_TICKS; i++) stepRoom(r);
199 expect(isOver(r)).toBe(true);
200
201 const results = resultsOf(r);
202 expect(results.map((f) => f.id).sort()).toEqual(["s1", "s2"]);
203 const of = (id: string): Result => (results.find((f) => f.id === id) as Finished).result;
204 const ct = samBody.team === "ct" ? of("s1") : of("s2");
205 const t = samBody.team === "ct" ? of("s2") : of("s1");
206 // The short match's rules, played to their end: first to five, 5–2.
207 expect([ct.scoreMine, ct.scoreTheirs, ct.rounds]).toEqual([SHORT.winScore, 2, SHORT.winScore + 2]);
208 expect([t.scoreMine, t.scoreTheirs, t.rounds]).toEqual([2, SHORT.winScore, SHORT.winScore + 2]);
209 for (const result of [ct, t]) {
210 expect(result.map).toBe("de_forge");
211 expect(result.finished).toBe(true);
212 // The same shape a page posts, inside every bound a posted one is held to.
213 expect(parseResult(result)).toEqual(result);
214 }
215 expect([of("s1").kills, of("s1").headshots, of("s1").damage]).toEqual([samBody.kills, 1, Math.round(samBody.damage)]);
216 expect(of("s2").deaths).toBe(kimBody.deaths);
217 // One match, one row a seat: the room's match id in both, and not the same row.
218 expect(matchOf(of("s1").matchId)).toBe(r.matchId);
219 expect(matchOf(of("s2").matchId)).toBe(r.matchId);
220 expect(of("s1").matchId).not.toBe(of("s2").matchId);
221 });
222
223 test("a member counts from when they sat down: the bot's kills before the join stay the bot's", () => {
224 const r = room();
225 const seat = freeSeat(r) as number;
226 const body = r.world.players[seat] as Player;
227 // Four rounds of a bot's match, then somebody takes the seat for the last one.
228 body.kills = 11;
229 body.deaths = 3;
230 body.damage = 1500;
231 r.world.time = 400;
232 const late = join(r, "late", "late", 1000) as Member;
233 expect(late.seat).toBe(seat);
234 body.kills += 1;
235 lastRound(r, 0);
236 for (let i = 0; i < ENDING_TICKS; i++) stepRoom(r);
237 const [only] = resultsOf(r);
238 expect(only?.result.kills).toBe(1);
239 expect(only?.result.deaths).toBe(0);
240 expect(only?.result.damage).toBe(0);
241 // The seconds are theirs too: the ending they sat through, not the match.
242 expect(only?.result.seconds).toBeLessThan(10);
243 // And it is still a match played to the end, with the whole match's score.
244 expect(only?.result.rounds).toBe(SHORT.winScore);
245 });
246
247 test("a member who left before match_over has no result, and the bot that finished it has none either", () => {
248 const r = room();
249 join(r, "stays", "stays", 1000);
250 join(r, "goes", "goes", 1000);
251 leave(r, "goes", 2000);
252 lastRound(r);
253 for (let i = 0; i < ENDING_TICKS; i++) stepRoom(r);
254 expect(resultsOf(r).map((f) => f.id)).toEqual(["stays"]);
255 });
src/rooms/room.ts +89 −2
@@ -17,6 +17,29 @@
1717 * never reaches the world: `drive` looks the member up and writes to their own
1818 * seat or to nothing.
1919 *
20 * **Whose result a finished match is.** A room's round is stepped here, so a
21 * room's result is the one on the leaderboard that is not a browser's word: when
22 * the world reaches `match_over`, `resultsOf` reads one result per member off
23 * the world it stepped, and `src/rooms/hub.ts` hands them to the writer it was
24 * given. The rule for who gets one, all of it:
25 *
26 * - A seat held by a member at `match_over` is a row for that member. A member
27 * who left before it — closed the tab, lost the network, went quiet for
28 * `SILENT_MS` — has no row: the bot finished their match, and a bot's match is
29 * nobody's. The page says so under the room controls before anyone joins.
30 * - The row counts what the body did **while the member held it**. A seat is a
31 * bot's until somebody sits in it, and the kills that bot made before the
32 * join are the bot's, so `join` writes down the body's counters as it sits
33 * and `resultsOf` subtracts them. The score is the team's — that is the match
34 * they finished — and so a person who joined for the last round is a match
35 * played, with one round of their own numbers in it. There is no minimum: a
36 * minimum would be a number with no argument behind it, and the numbers in
37 * the row already say how little of the match it was.
38 * - One room is one match, and one match is at most one row per seat: the id is
39 * the room's match and the seat (`playKey` in `src/matchid.ts`), and a seat is
40 * held by one member at a time, so a second write of the same match is the
41 * same row. The hub writes a room once anyway.
42 *
2043 * Nothing here is a socket. A room is plain data with a world in it: `join`,
2144 * `leave`, `drive`, `stepRoom` and `snapshotFor` are functions of their
2245 * arguments, `src/rooms/hub.ts` holds the rooms and `src/server.ts` is the only
@@ -30,6 +53,8 @@ import { buy as spend, canBuy } from "../client/economy.ts";
3053 import type { Level } from "../client/level.ts";
3154 import { COMPETITIVE, SHORT, type Rules, type Team } from "../client/match.ts";
3255 import { seedOf, type PlayedMap } from "../mapname.ts";
56 import { playKey } from "../matchid.ts";
57 import type { Result } from "../result.ts";
3358 import {
3459 INPUT_BYTES, readInput, resolveSwitch, SNAPSHOT_MAX, writeSnapshot,
3560 type Hello, type Log, type Pressed, type Roster, type Seated,
@@ -82,8 +107,16 @@ export type Member = {
82107 sentAt: number;
83108 /** How many frames of theirs were refused for being the wrong shape. */
84109 dropped: number;
110 /** What the body had already done when they sat in it, which was the bot's. */
111 from: Tally;
85112 };
86113
114 /** A body's counters at one moment, and the world clock it was read at. */
115 export type Tally = { kills: number; deaths: number; headshots: number; damage: number; at: number };
116
117 const tallyOf = (p: Player, at: number): Tally =>
118 ({ kills: p.kills, deaths: p.deaths, headshots: p.headshots, damage: p.damage, at });
119
87120 export type Room = {
88121 code: string;
89122 created: number;
@@ -109,6 +142,10 @@ export type Room = {
109142 usedAt: number;
110143 /** One buffer per room, written through by every snapshot. */
111144 frame: Uint8Array;
145 /** The match this room plays, as the leaderboard keys it: a fresh UUID a room. */
146 matchId: string;
147 /** Whether this room's result has been handed to a writer. Once, ever. */
148 recorded: boolean;
112149 };
113150
114151 /** The level a room stands on: de_forge, or the one that map name carves. */
@@ -124,7 +161,7 @@ export const levelOf = (map: PlayedMap): Level =>
124161 */
125162 export function createRoom(
126163 code: string,
127 options: { map: PlayedMap; seed: number; length: "short" | "long"; now: number },
164 options: { map: PlayedMap; seed: number; length: "short" | "long"; now: number; matchId?: string },
128165 ): Room {
129166 const rules = options.length === "long" ? COMPETITIVE : SHORT;
130167 const level = levelOf(options.map);
@@ -149,6 +186,8 @@ export function createRoom(
149186 loggedSay: 0,
150187 usedAt: options.now,
151188 frame: new Uint8Array(SNAPSHOT_MAX),
189 matchId: options.matchId ?? crypto.randomUUID(),
190 recorded: false,
152191 };
153192 }
154193
@@ -219,7 +258,9 @@ export function join(room: Room, id: string, wanted: unknown, now: number): Memb
219258 body.name = name;
220259 // A bot's brain is left where it is: if this member leaves, the bot picks the
221260 // body up with everything else it had.
222 const member: Member = { id, name, seat, joined: now, lastTick: 0, sentAt: 0, dropped: 0 };
261 const member: Member = {
262 id, name, seat, joined: now, lastTick: 0, sentAt: 0, dropped: 0, from: tallyOf(body, room.world.time),
263 };
223264 room.members.set(id, member);
224265 room.usedAt = now;
225266 return member;
@@ -357,6 +398,52 @@ export const helloFor = (room: Room, member: Member): Hello => ({
357398 roster: rosterOf(room),
358399 });
359400
401 /** Whether this room's match is over: the one moment it has a result. */
402 export const isOver = (room: Room): boolean => room.world.match.phase === "match_over";
403
404 /** One member's result, and who to write it for: the member id the server joined. */
405 export type Finished = { id: string; result: Result };
406
407 /**
408 * The finished match, one result per member still seated, read off the world
409 * this process stepped and nothing else — see the rule at the top of this file.
410 * Empty until the match is over, so a caller cannot write a match in progress.
411 *
412 * The shape is `resultOf`'s in `src/client/summary.ts`, the one a page posts,
413 * so a room's row and a page's row are the same columns meaning the same
414 * things: the score read off the side the body is on *now*, which is the side
415 * it finished on after the half-time swap.
416 */
417 export function resultsOf(room: Room): Finished[] {
418 if (!isOver(room)) return [];
419 const { world } = room;
420 const score = world.match.score;
421 const out: Finished[] = [];
422 for (const member of room.members.values()) {
423 const body = world.players[member.seat];
424 if (body === undefined) continue;
425 const from = member.from;
426 out.push({
427 id: member.id,
428 result: {
429 matchId: playKey(room.matchId, String(member.seat).padStart(12, "0")),
430 name: member.name,
431 map: room.map,
432 rounds: score.t + score.ct,
433 scoreMine: score[body.team],
434 scoreTheirs: score[body.team === "t" ? "ct" : "t"],
435 kills: body.kills - from.kills,
436 deaths: body.deaths - from.deaths,
437 headshots: body.headshots - from.headshots,
438 damage: Math.round(body.damage - from.damage),
439 seconds: Math.round(world.time - from.at),
440 finished: true,
441 },
442 });
443 }
444 return out;
445 }
446
360447 /** A room with nobody in it, for longer than `idle` milliseconds. */
361448 export const isIdle = (room: Room, now: number, idle: number): boolean =>
362449 room.members.size === 0 && now - room.usedAt > idle;
src/rooms/stage.test-helper.ts +23 −3
@@ -11,12 +11,16 @@
1111 * sends for one tick of holding W, and a `Wire` that remembers what it was
1212 * handed instead of sending it.
1313 *
14 * It ships in no bundle: only `wire.test.ts`, `room.test.ts` and `hub.test.ts`
15 * import it.
14 * And one more, since a room's result is written: a round one step from the end
15 * of the match, so a test reaches `match_over` by the world's own clock in five
16 * seconds of steps rather than a whole match of them.
17 *
18 * It ships in no bundle: only `wire.test.ts`, `room.test.ts`, `hub.test.ts` and
19 * `src/db.test.ts`, which plays a room to the end into a real database, import it.
1620 */
1721
1822 import { DE_FORGE } from "../client/map.ts";
19 import { SHORT } from "../client/match.ts";
23 import { endRound, SHORT } from "../client/match.ts";
2024 import { useShipped } from "../client/shipped.ts";
2125 import { createWorld, NO_INPUT, type Player, type World } from "../client/world.ts";
2226 import { createRoom, stepRoom, type Room } from "./room.ts";
@@ -51,6 +55,22 @@ export function busy(): Room {
5155 return r;
5256 }
5357
58 /**
59 * The last round of a short match, just won: CT on the winning score with the
60 * end delay still to run, and T on `t`. The world's own `tick` turns that into
61 * `match_over` once the delay is out — `SHORT.endDelay` seconds, which is
62 * `ENDING_TICKS` steps — so the match ends the way every match does.
63 */
64 export function lastRound(r: Room, t = 2): void {
65 const match = r.world.match;
66 match.phase = "live";
67 match.score = { t, ct: SHORT.winScore - 1 };
68 endRound(match, "ct", "elimination");
69 }
70
71 /** Steps enough for the end delay of the last round to run out, and one spare. */
72 export const ENDING_TICKS = Math.ceil(SHORT.endDelay * 64) + 1;
73
5474 /**
5575 * The other end of the wire: the same ten bodies on the same map from the same
5676 * seed, which has never stepped the world once. It is what a member's browser
src/seated.ts added +79
@@ -0,0 +1,79 @@
1 /**
2 * Who is sitting in a room, as this server knows them, and the row their
3 * finished match becomes. This is the one path that writes to the database with
4 * no request behind it — a room's clock reaches `match_over` and the rows are
5 * written from inside `hub.advance` — so it goes through the same two doors a
6 * posted result goes through and no others:
7 *
8 * - **The person** is `playerOf` in `src/gate.ts`, fed the identity `who` read
9 * off the socket's upgrade request and the session cookie that came with it.
10 * A signed-in member's match is theirs under their forge name, whatever they
11 * typed into the name field; a guest's is that browser's, under the name they
12 * typed and shown as a guest's. A room cannot be a way into somebody else's
13 * row, because it asks the question `/api/match` asks, the same way.
14 * - **The numbers** are `parseResult` in `src/result.ts`, although they were
15 * read off a world this process stepped: the leaderboard holds one shape of
16 * row whichever door it came through, and a room that ever produced one no
17 * posted match could is a bug to refuse rather than a row to rank.
18 *
19 * `saveMatch` keeps its ownership rule underneath both: a row id is a room's
20 * match and a seat (`resultsOf` in `src/rooms/room.ts`), so a second write of
21 * the same seat is the same row and never a second one.
22 *
23 * `src/db.test.ts` plays a room to the end through this writer, with one member
24 * signed in and one a guest, and reads the leaderboard back.
25 */
26
27 import type { Database } from "bun:sqlite";
28 import { saveMatch } from "./db.ts";
29 import { playerOf } from "./gate.ts";
30 import { parseResult } from "./result.ts";
31 import type { Writer } from "./rooms/hub.ts";
32 import type { Who } from "./who.ts";
33
34 /** What one connection to a room is: which member, in which room, as whom. */
35 export type Seated = {
36 /** The member id the hub knows them by: one connection, not one browser. */
37 id: string;
38 /** The room's code. */
39 code: string;
40 /** The name the page asked for. Only ever a guest's name; see `playerOf`. */
41 name: string;
42 /** The forge identity, verified at the upgrade: `anon` for a guest. */
43 who: Who;
44 /** The browser's session cookie, which is who a guest's row belongs to. */
45 session: string;
46 };
47
48 /**
49 * The writer a hub is handed. `seatOf` finds the connection a member id was
50 * joined under — the server's own table of open sockets — and a member whose
51 * connection is gone is written for nobody, which is the leaver's rule said a
52 * second time at the last possible moment.
53 *
54 * A write that fails is said in the run log and goes no further: it runs on the
55 * clock every room steps on, and one bad row must not stop nineteen rounds.
56 */
57 export function roomWriter(
58 db: Database,
59 seatOf: (id: string) => Seated | undefined,
60 log: (line: string) => void = (line) => console.error(line),
61 ): Writer {
62 return {
63 write({ id, result }) {
64 const seated = seatOf(id);
65 if (seated === undefined) return;
66 const checked = parseResult(result);
67 if (checked === null) {
68 log(`room ${seated.code}: a result no match could produce was not written (${result.matchId})`);
69 return;
70 }
71 try {
72 const player = playerOf(seated.who, seated.session, seated.name);
73 if (!saveMatch(db, player, checked)) log(`room ${seated.code}: ${result.matchId} belongs to someone else`);
74 } catch (error) {
75 log(`room ${seated.code}: ${result.matchId} was not written: ${String(error)}`);
76 }
77 },
78 };
79 }
src/server.test.ts +43 −4
@@ -26,7 +26,7 @@ import { roomCodeFrom } from "./engine/index.ts";
2626 import { CLOSE_TOO_BIG, MESSAGE_MAX, ROOM_CAP } from "./rooms/hub.ts";
2727 import { levelOf, SNAPSHOT_HZ, TEAM_SIZE } from "./rooms/room.ts";
2828 import { applySnapshot, readDown, writeInput, INPUT_BYTES, SNAPSHOT_FRAME, type Hello } from "./rooms/wire.ts";
29 import { COMPETITIVE, SHORT } from "./client/match.ts";
29 import { COMPETITIVE, endRound, SHORT } from "./client/match.ts";
3030 import type { PlayedMap } from "./mapname.ts";
3131 import { createWorld, NO_INPUT } from "./client/world.ts";
3232
@@ -1316,9 +1316,9 @@ type Client = {
13161316 close: () => Promise<void>;
13171317 };
13181318
1319 /** A real browser's end of a room, connected and open. */
1320 async function connect(query: string): Promise<Client> {
1321 const ws = new WebSocket(`${base.replace("http://", "ws://")}${SOCKET_PATH}?${query}`);
1319 /** A real browser's end of a room, connected and open — as whoever those headers say. */
1320 async function connect(query: string, headers: Record<string, string> = {}): Promise<Client> {
1321 const ws = new WebSocket(`${base.replace("http://", "ws://")}${SOCKET_PATH}?${query}`, { headers } as never);
13221322 ws.binaryType = "arraybuffer";
13231323 const frames: (string | Uint8Array)[] = [];
13241324 let closed: number | null = null;
@@ -1762,3 +1762,42 @@ test("every room these tests opened was given back, and every socket closed", as
17621762 expect(await until(() => hub.members() === 0)).toBe(true);
17631763 expect([...hub.rooms.keys()]).toEqual([]);
17641764 });
1765
1766 test("a room's match is written by the server under the identity read at the upgrade, and a typed name does not override it", async () => {
1767 const room = await openedRoom("room result");
1768 // One signed in who typed somebody else's name, and one guest.
1769 const signed = await connect(`room=${room.code}&name=impostor`, { ...as("u-318", "ringo"), cookie: caller("ringo") });
1770 const guest = await connect(`room=${room.code}&name=paul`, { cookie: caller("paul") });
1771 try {
1772 expect(await until(() => hub.find(room.code)?.members.size === 2)).toBe(true);
1773 const held = hub.find(room.code);
1774 if (held === undefined) throw new Error("the room went away");
1775 // The last round, just won: the world's own clock ends the match.
1776 held.world.match.phase = "live";
1777 held.world.match.score = { t: 3, ct: SHORT.winScore - 1 };
1778 endRound(held.world.match, "ct", "elimination");
1779 // A tenth of a second of the end delay rather than five: this is a real clock.
1780 held.world.match.timer = 0.1;
1781 expect(await until(() => held.recorded, 5000)).toBe(true);
1782 const db = openDatabase(process.env.FORGE_APP_DATA as string);
1783 const rows = db.query<{ user_id: string; name: string; session: string; rounds: number; finished: number }, [string]>(
1784 "SELECT user_id, name, session, rounds, finished FROM match WHERE id LIKE ?1 || '--%' ORDER BY user_id",
1785 ).all(held.matchId);
1786 db.close();
1787 expect(rows.map((r) => [r.user_id, r.name])).toEqual([["", "paul"], ["u-318", "ringo"]]);
1788 expect(rows.every((r) => r.finished === 1 && r.rounds === SHORT.winScore + 3)).toBe(true);
1789 // The guest's row is that browser's: the session its cookie named.
1790 expect(`cs_session=${rows[0]?.session}`).toBe(caller("paul"));
1791 // And the home page's leaderboard now has the match.
1792 const home = await (await fetch(base + "/")).text();
1793 expect(home).toContain("ringo");
1794 expect(home).toContain("paul (guest)");
1795 } finally {
1796 await finish(room.code, signed, guest);
1797 }
1798 });
1799
1800 test("the menu says who a room's match counts for before anyone joins one", async () => {
1801 const home = await (await fetch(base + "/")).text();
1802 expect(home).toContain("still seated — leave before the end and it is not counted for you");
1803 });
src/server.ts +18 −5
@@ -71,6 +71,7 @@ import { STYLE as WEAPONS_STYLE, weaponsPage, WEAPONS_HREF } from "./pages/weapo
7171 import { policyFor } from "./policy.ts";
7272 import { roomCodeFrom } from "./engine/index.ts";
7373 import { createHub, CLOSE_GONE, MESSAGE_MAX, ROOM_CAP } from "./rooms/hub.ts";
74 import { roomWriter, type Seated } from "./seated.ts";
7475 import { playedOn } from "./mapname.ts";
7576 import { styleFor, type Linked } from "./pages/shell.ts";
7677 import { parseResult } from "./result.ts";
@@ -282,10 +283,19 @@ export const hub = createHub({
282283 close(id, code, reason) {
283284 sockets.get(id)?.close(code, reason);
284285 },
285 }, { now: Date.now() });
286 }, {
287 now: Date.now(),
288 // A finished room's rows, under the identity each connection was upgraded
289 // with (`src/seated.ts`): the hub hands over a member id and the numbers,
290 // and the person is looked up here, on the socket that is still open.
291 writer: roomWriter(db, (id) => sockets.get(id)?.data),
292 });
286293
287 /** What one connection is: which member, in which room, under what name. */
288 type Joined = { id: string; code: string; name: string };
294 /**
295 * What one connection is: which member, in which room, under what name — and
296 * as whom, read once at the upgrade the way `/api/match` reads it per post.
297 */
298 type Joined = Seated;
289299
290300 /**
291301 * The clock every room steps on. One interval for the whole process rather than
@@ -853,7 +863,10 @@ async function route(request: Request, listener: Bun.Server<Joined>): Promise<Re
853863 * falls through to the 404 every other wrong path gets.
854864 *
855865 * What it takes: same-origin, a room that exists, and a session. The name is
856 * whatever the page sent, cleaned and made unique inside the room.
866 * whatever the page sent, cleaned and made unique inside the room. Who the
867 * member *is* is the forge's word, verified here once and carried on the
868 * connection: it is what a finished room's row is written under, and a name
869 * typed into the page never overrides it.
857870 */
858871 if (isRead(request) && url.pathname === SOCKET_PATH) {
859872 if (!sameOrigin(request)) return text("forbidden", 403);
@@ -864,7 +877,7 @@ async function route(request: Request, listener: Bun.Server<Joined>): Promise<Re
864877 const id = `${session}.${crypto.randomUUID().slice(0, 8)}`;
865878 const name = (url.searchParams.get("name") ?? "").slice(0, 24);
866879 const upgraded = listener.upgrade(request, {
867 data: { id, code, name },
880 data: { id, code, name, who: who(request, identityKey), session },
868881 headers: { ...SECURITY, "set-cookie": sessionCookie(session) },
869882 });
870883 // A plain GET of this path — a person following a link, a crawler — is not

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs