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
torque/cs-engine:torque/318-a-room-s-result-counts-the-server-writes → main 1 commit fork
13 files
changed +636
−40
README.md +35 −3
| @@ -1668,9 +1668,41 @@ What is real and what is not, today: | ||
| 1668 | 1668 | after ninety seconds (`src/rooms/hub.ts`). |
| 1669 | 1669 | - Not yet: your own movement is **not** predicted, so it answers on the round |
| 1670 | 1670 | 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). | |
| 1674 | 1706 | |
| 1675 | 1707 | ### What a room costs, measured |
| 1676 | 1708 |
src/client/main.test.ts +38 −1
| @@ -31,7 +31,8 @@ | ||
| 31 | 31 | import { expect, test } from "bun:test"; |
| 32 | 32 | import { generateLevel, nameOf, seedFrom, seedOf } from "./generate.ts"; |
| 33 | 33 | 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"; | |
| 35 | 36 | import { NO_PICTURE } from "./support.ts"; |
| 36 | 37 | import { HINT } from "./buy.ts"; |
| 37 | 38 | 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 | ||
| 1729 | 1730 | expect(stage.by("ontoday").textContent).toBe(saidToday(after)); |
| 1730 | 1731 | expect(stage.by("willplay").textContent).toBe(after.map); |
| 1731 | 1732 | }); |
| 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 | ||
| 153 | 153 | /** And what it says when a room could not be made or joined. */ |
| 154 | 154 | export const NO_ROOM = "That room could not be joined — check the code, or make a new room."; |
| 155 | 155 | 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."; | |
| 156 | 162 | |
| 157 | 163 | export const COPIED = "Copied"; |
| 158 | 164 | export const NOT_COPIED = "Copy failed — the link still works"; |
| @@ -911,7 +917,7 @@ export function boot(host: Host): Frame { | ||
| 911 | 917 | * on the board while bytes are still in the page's hand. |
| 912 | 918 | */ |
| 913 | 919 | function sayMatch(): void { |
| 914 | sumSaved.textContent = reporter.standing(); | |
| 920 | sumSaved.textContent = session !== null ? ROOM_SAVED : reporter.standing(); | |
| 915 | 921 | } |
| 916 | 922 | |
| 917 | 923 | /** |
| @@ -1236,7 +1242,6 @@ export function boot(host: Host): Frame { | ||
| 1236 | 1242 | loadout = createLoadout(); |
| 1237 | 1243 | roomBuy = null; |
| 1238 | 1244 | posted = -1; |
| 1239 | postedFinal = true; // a room's result is the server's to write, never this page's | |
| 1240 | 1245 | loop.reset(); |
| 1241 | 1246 | controls.sensitivity = clamp(Number(sensField.value) || 2.2, 0.2, 12); |
| 1242 | 1247 | motion = parseMotion(motionField.value); |
| @@ -1567,7 +1572,10 @@ export function boot(host: Host): Frame { | ||
| 1567 | 1572 | // again although no round was scored with it: the last round of a match is |
| 1568 | 1573 | // won five seconds before the match is over, so a post keyed on the score |
| 1569 | 1574 | // 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))) { | |
| 1571 | 1579 | posted = played; |
| 1572 | 1580 | postedFinal = over; |
| 1573 | 1581 | reporter.post(world, playId, nameField.value, over); |
src/db.test.ts +84 −2
| @@ -10,9 +10,15 @@ import { mkdtempSync } from "node:fs"; | ||
| 10 | 10 | import { tmpdir } from "node:os"; |
| 11 | 11 | import { join } from "node:path"; |
| 12 | 12 | 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, | |
| 15 | 15 | } 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"; | |
| 16 | 22 | import { playKey } from "./matchid.ts"; |
| 17 | 23 | import type { Result } from "./result.ts"; |
| 18 | 24 | import type { RangeRun } from "./run.ts"; |
| @@ -445,3 +451,79 @@ test("a run belongs to whoever posted it: nobody else writes over that id", () = | ||
| 445 | 451 | expect(saveRun(db, guest("finch", "s10"), range({ runId: mine, score: 9000 }))).toBe(false); |
| 446 | 452 | expect(recentRuns(db).find((r) => r.id === mine)!.score).toBe(800); |
| 447 | 453 | }); |
| 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 => ` | ||
| 387 | 387 | </div> |
| 388 | 388 | <p class="hint" id="roomsaid">Play with friends: make a room, read the code out, and they join it. A room's |
| 389 | 389 | 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'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> | |
| 392 | 393 | <p class="hint" id="prep">Preparing textures…</p> |
| 393 | 394 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk · |
| 394 | 395 | 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 @@ | ||
| 26 | 26 | import { expect, test } from "bun:test"; |
| 27 | 27 | import { existsSync, readFileSync, readdirSync } from "node:fs"; |
| 28 | 28 | 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"; | |
| 32 | 32 | import { type Player } from "../client/world.ts"; |
| 33 | 33 | |
| 34 | 34 | 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", () => { | ||
| 123 | 123 | expect(r.world.players.every((p) => p.bot)).toBe(true); |
| 124 | 124 | }); |
| 125 | 125 | |
| 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 | ||
| 126 | 195 | // --- the directory's own documentation ------------------------------------ |
| 127 | 196 | |
| 128 | 197 | const ROOMS = import.meta.dir; |
src/rooms/hub.ts +54 −9
| @@ -28,22 +28,55 @@ | ||
| 28 | 28 | * idle sweep and a room cap without opening a port, and `src/engine/rooms.test.ts` |
| 29 | 29 | * runs the same machinery under a game that is not this one. |
| 30 | 30 | * |
| 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. | |
| 35 | 45 | */ |
| 36 | 46 | |
| 37 | 47 | import { createHub as createEngineHub, type Hub as EngineHub, type RoomGame, type Wire } from "../engine/index.ts"; |
| 38 | 48 | import { createLimiter, type Limiter } from "../limit.ts"; |
| 39 | 49 | 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, | |
| 42 | 52 | } from "./room.ts"; |
| 43 | 53 | import type { PlayedMap } from "../mapname.ts"; |
| 44 | 54 | |
| 45 | 55 | export type { Wire }; |
| 46 | 56 | |
| 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 | ||
| 47 | 80 | /** |
| 48 | 81 | * How many rooms one deployment holds at once. Twenty rooms is a measured |
| 49 | 82 | * 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 | ||
| 107 | 140 | * The shooter, as the hub sees it: eleven functions of `room.ts` and the two |
| 108 | 141 | * rates a room runs at. This is the whole of what the engine knows about this |
| 109 | 142 | * 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. | |
| 111 | 145 | */ |
| 112 | 146 | export const SHOOTER: RoomGame<Room, Member, RoomOptions> = { |
| 113 | 147 | tick: TICK, |
| @@ -125,6 +159,14 @@ export const SHOOTER: RoomGame<Room, Member, RoomOptions> = { | ||
| 125 | 159 | describe: describeRoom, |
| 126 | 160 | }; |
| 127 | 161 | |
| 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 | ||
| 128 | 170 | /** |
| 129 | 171 | * Every room this process is holding. The engine's hub with this game's table |
| 130 | 172 | * 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"> & { | ||
| 136 | 178 | open(options: RoomOptions): Room | null; |
| 137 | 179 | }; |
| 138 | 180 | |
| 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), { | |
| 141 | 186 | now: options.now, |
| 142 | 187 | limiter: options.limiter ?? createLimiter(FRAME_LIMIT), |
| 143 | 188 | rooms: ROOM_CAP, |
src/rooms/room.test.ts +87 −3
| @@ -13,14 +13,16 @@ | ||
| 13 | 13 | |
| 14 | 14 | import { expect, test } from "bun:test"; |
| 15 | 15 | 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, | |
| 18 | 18 | } from "./room.ts"; |
| 19 | 19 | import { writeInput, SWITCH_PRIMARY, SWITCH_SECONDARY } from "./wire.ts"; |
| 20 | 20 | // The cap is the hub's, because the hub is what enforces it on a socket; a |
| 21 | 21 | // frame of that size is refused here too, and this is where that is read. |
| 22 | 22 | 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"; | |
| 24 | 26 | import { NO_INPUT, type Player } from "../client/world.ts"; |
| 25 | 27 | import { SHORT } from "../client/match.ts"; |
| 26 | 28 | |
| @@ -169,3 +171,85 @@ test("one member's snapshot is a snapshot of the room they are in", () => { | ||
| 169 | 171 | // Asking again sends only what happened since: the clock moved on. |
| 170 | 172 | expect(member.sentAt).toBeCloseTo(r.world.time, 5); |
| 171 | 173 | }); |
| 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 @@ | ||
| 17 | 17 | * never reaches the world: `drive` looks the member up and writes to their own |
| 18 | 18 | * seat or to nothing. |
| 19 | 19 | * |
| 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 | * | |
| 20 | 43 | * Nothing here is a socket. A room is plain data with a world in it: `join`, |
| 21 | 44 | * `leave`, `drive`, `stepRoom` and `snapshotFor` are functions of their |
| 22 | 45 | * 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"; | ||
| 30 | 53 | import type { Level } from "../client/level.ts"; |
| 31 | 54 | import { COMPETITIVE, SHORT, type Rules, type Team } from "../client/match.ts"; |
| 32 | 55 | import { seedOf, type PlayedMap } from "../mapname.ts"; |
| 56 | import { playKey } from "../matchid.ts"; | |
| 57 | import type { Result } from "../result.ts"; | |
| 33 | 58 | import { |
| 34 | 59 | INPUT_BYTES, readInput, resolveSwitch, SNAPSHOT_MAX, writeSnapshot, |
| 35 | 60 | type Hello, type Log, type Pressed, type Roster, type Seated, |
| @@ -82,8 +107,16 @@ export type Member = { | ||
| 82 | 107 | sentAt: number; |
| 83 | 108 | /** How many frames of theirs were refused for being the wrong shape. */ |
| 84 | 109 | dropped: number; |
| 110 | /** What the body had already done when they sat in it, which was the bot's. */ | |
| 111 | from: Tally; | |
| 85 | 112 | }; |
| 86 | 113 | |
| 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 | ||
| 87 | 120 | export type Room = { |
| 88 | 121 | code: string; |
| 89 | 122 | created: number; |
| @@ -109,6 +142,10 @@ export type Room = { | ||
| 109 | 142 | usedAt: number; |
| 110 | 143 | /** One buffer per room, written through by every snapshot. */ |
| 111 | 144 | 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; | |
| 112 | 149 | }; |
| 113 | 150 | |
| 114 | 151 | /** 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 => | ||
| 124 | 161 | */ |
| 125 | 162 | export function createRoom( |
| 126 | 163 | 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 }, | |
| 128 | 165 | ): Room { |
| 129 | 166 | const rules = options.length === "long" ? COMPETITIVE : SHORT; |
| 130 | 167 | const level = levelOf(options.map); |
| @@ -149,6 +186,8 @@ export function createRoom( | ||
| 149 | 186 | loggedSay: 0, |
| 150 | 187 | usedAt: options.now, |
| 151 | 188 | frame: new Uint8Array(SNAPSHOT_MAX), |
| 189 | matchId: options.matchId ?? crypto.randomUUID(), | |
| 190 | recorded: false, | |
| 152 | 191 | }; |
| 153 | 192 | } |
| 154 | 193 | |
| @@ -219,7 +258,9 @@ export function join(room: Room, id: string, wanted: unknown, now: number): Memb | ||
| 219 | 258 | body.name = name; |
| 220 | 259 | // A bot's brain is left where it is: if this member leaves, the bot picks the |
| 221 | 260 | // 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 | }; | |
| 223 | 264 | room.members.set(id, member); |
| 224 | 265 | room.usedAt = now; |
| 225 | 266 | return member; |
| @@ -357,6 +398,52 @@ export const helloFor = (room: Room, member: Member): Hello => ({ | ||
| 357 | 398 | roster: rosterOf(room), |
| 358 | 399 | }); |
| 359 | 400 | |
| 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 | ||
| 360 | 447 | /** A room with nobody in it, for longer than `idle` milliseconds. */ |
| 361 | 448 | export const isIdle = (room: Room, now: number, idle: number): boolean => |
| 362 | 449 | room.members.size === 0 && now - room.usedAt > idle; |
src/rooms/stage.test-helper.ts +23 −3
| @@ -11,12 +11,16 @@ | ||
| 11 | 11 | * sends for one tick of holding W, and a `Wire` that remembers what it was |
| 12 | 12 | * handed instead of sending it. |
| 13 | 13 | * |
| 14 | * It ships in no bundle: only `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. | |
| 16 | 20 | */ |
| 17 | 21 | |
| 18 | 22 | import { DE_FORGE } from "../client/map.ts"; |
| 19 | import { SHORT } from "../client/match.ts"; | |
| 23 | import { endRound, SHORT } from "../client/match.ts"; | |
| 20 | 24 | import { useShipped } from "../client/shipped.ts"; |
| 21 | 25 | import { createWorld, NO_INPUT, type Player, type World } from "../client/world.ts"; |
| 22 | 26 | import { createRoom, stepRoom, type Room } from "./room.ts"; |
| @@ -51,6 +55,22 @@ export function busy(): Room { | ||
| 51 | 55 | return r; |
| 52 | 56 | } |
| 53 | 57 | |
| 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 | ||
| 54 | 74 | /** |
| 55 | 75 | * The other end of the wire: the same ten bodies on the same map from the same |
| 56 | 76 | * 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"; | ||
| 26 | 26 | import { CLOSE_TOO_BIG, MESSAGE_MAX, ROOM_CAP } from "./rooms/hub.ts"; |
| 27 | 27 | import { levelOf, SNAPSHOT_HZ, TEAM_SIZE } from "./rooms/room.ts"; |
| 28 | 28 | 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"; | |
| 30 | 30 | import type { PlayedMap } from "./mapname.ts"; |
| 31 | 31 | import { createWorld, NO_INPUT } from "./client/world.ts"; |
| 32 | 32 | |
| @@ -1316,9 +1316,9 @@ type Client = { | ||
| 1316 | 1316 | close: () => Promise<void>; |
| 1317 | 1317 | }; |
| 1318 | 1318 | |
| 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); | |
| 1322 | 1322 | ws.binaryType = "arraybuffer"; |
| 1323 | 1323 | const frames: (string | Uint8Array)[] = []; |
| 1324 | 1324 | let closed: number | null = null; |
| @@ -1762,3 +1762,42 @@ test("every room these tests opened was given back, and every socket closed", as | ||
| 1762 | 1762 | expect(await until(() => hub.members() === 0)).toBe(true); |
| 1763 | 1763 | expect([...hub.rooms.keys()]).toEqual([]); |
| 1764 | 1764 | }); |
| 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 | ||
| 71 | 71 | import { policyFor } from "./policy.ts"; |
| 72 | 72 | import { roomCodeFrom } from "./engine/index.ts"; |
| 73 | 73 | import { createHub, CLOSE_GONE, MESSAGE_MAX, ROOM_CAP } from "./rooms/hub.ts"; |
| 74 | import { roomWriter, type Seated } from "./seated.ts"; | |
| 74 | 75 | import { playedOn } from "./mapname.ts"; |
| 75 | 76 | import { styleFor, type Linked } from "./pages/shell.ts"; |
| 76 | 77 | import { parseResult } from "./result.ts"; |
| @@ -282,10 +283,19 @@ export const hub = createHub({ | ||
| 282 | 283 | close(id, code, reason) { |
| 283 | 284 | sockets.get(id)?.close(code, reason); |
| 284 | 285 | }, |
| 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 | }); | |
| 286 | 293 | |
| 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; | |
| 289 | 299 | |
| 290 | 300 | /** |
| 291 | 301 | * 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 | ||
| 853 | 863 | * falls through to the 404 every other wrong path gets. |
| 854 | 864 | * |
| 855 | 865 | * 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. | |
| 857 | 870 | */ |
| 858 | 871 | if (isRead(request) && url.pathname === SOCKET_PATH) { |
| 859 | 872 | if (!sameOrigin(request)) return text("forbidden", 403); |
| @@ -864,7 +877,7 @@ async function route(request: Request, listener: Bun.Server<Joined>): Promise<Re | ||
| 864 | 877 | const id = `${session}.${crypto.randomUUID().slice(0, 8)}`; |
| 865 | 878 | const name = (url.searchParams.get("name") ?? "").slice(0, 24); |
| 866 | 879 | const upgraded = listener.upgrade(request, { |
| 867 | data: { id, code, name }, | |
| 880 | data: { id, code, name, who: who(request, identityKey), session }, | |
| 868 | 881 | headers: { ...SECURITY, "set-cookie": sessionCookie(session) }, |
| 869 | 882 | }); |
| 870 | 883 | // A plain GET of this path — a person following a link, a crawler — is not |