Public: anyone can read this; the crew can change it.
forked from trevato/cs · proposes
A match you can play again: the id is a thing you can paste, and you get the same map, seed and bots #197
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/173-a-match-you-can-play-again-the-id-is-a-t → main 5 commits fork
16 files
changed +657
−133
README.md +42 −1
| @@ -78,6 +78,43 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | ||
| 78 | 78 | rounds) and rejects the rest. Treat the leaderboard as a scoreboard among |
| 79 | 79 | friends, not as a ranking. |
| 80 | 80 | |
| 81 | ## A match you can play again | |
| 82 | ||
| 83 | A match is one short string. The id picks the map, the spawns, the bots, the | |
| 84 | bomb carrier and the seed every roll in the round comes out of, so handing that | |
| 85 | string back is a rematch, a challenge to a friend and the first line of a bug | |
| 86 | report. | |
| 87 | ||
| 88 | - **Paste it.** The menu has a **Match id** field. Put an id in it and Play | |
| 89 | plays that match again — the same layout, the same bots, the same seed. Leave | |
| 90 | it empty and you get a new match, exactly as before. | |
| 91 | - **Link to it.** `/?match=<id>&map=<de_forge|generated>` preselects both. The | |
| 92 | page's own **Link to this match**, under the stage, is that URL for whatever | |
| 93 | you are playing: a plain `<a href>` a keyboard reaches and a thumb can hit, | |
| 94 | not a clipboard trick. The map is in the link because the id carves a map only | |
| 95 | when the match asked for a generated one; on `de_forge` the id is still the | |
| 96 | whole of everything else. | |
| 97 | - **Read it.** The id of the match you are playing is in the *This match* | |
| 98 | section under the stage with the map's name beside it (one click selects the | |
| 99 | whole id), and in the pause panel, which is all there is to read when the | |
| 100 | stage has the screen. In the terminal, the matches tab prints the id of the | |
| 101 | match under the cursor, ready to paste into the same field. | |
| 102 | - **A rematch is a new row.** The id is the match; the key a result is stored | |
| 103 | under is one *playing* of it — `<match>--<nonce>`, where the nonce is twelve | |
| 104 | hex from a fresh uuid. So two people playing one id, or one person playing it | |
| 105 | twice, are separate rows on the leaderboard rather than one overwriting the | |
| 106 | other, and `matchOf` reads the match back out of a key, which is why pasting a | |
| 107 | row's id works as well as pasting the id you were given. | |
| 108 | ||
| 109 | A pasted id is an input, and it is checked twice against one definition: | |
| 110 | `src/matchid.ts` says what an id is (hex and dashes, eight to sixty-four | |
| 111 | characters — the shape `src/result.ts` has always accepted for a result's | |
| 112 | `matchId`), `src/server.ts` runs the `?match=` query through it before the page | |
| 113 | is rendered, the page escapes what it renders anyway, and | |
| 114 | `src/client/main.ts` runs the field through the same door again before anything | |
| 115 | reaches `generateLevel`. Anything else is a fresh match and a sentence saying | |
| 116 | so, never an error page and never a refusal to play. | |
| 117 | ||
| 81 | 118 | ## Who a score belongs to |
| 82 | 119 | |
| 83 | 120 | The numbers in a result are the browser's word; the name on it is not. Whoever |
| @@ -645,7 +682,11 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and | ||
| 645 | 682 | spread over — with every one of those rules pinned in `src/result.test.ts`; |
| 646 | 683 | `bun run scripts/mutate-result.ts` breaks each line of it in turn and names |
| 647 | 684 | the test that catches it, so a rule nothing tests is visible rather than |
| 648 | assumed. `src/who.ts` verifies the forge's signed identity headers, `src/limit.ts` is a | |
| 685 | assumed. `src/matchid.ts` is the one place that says what a match id is — the shape | |
| 686 | above, the `<match>--<nonce>` key one playing of a match is stored under, and | |
| 687 | the match read back out of it — and the parser, the `?match=` query and the | |
| 688 | page's field all ask it rather than each holding a regular expression; | |
| 689 | `src/who.ts` verifies the forge's signed identity headers, `src/limit.ts` is a | |
| 649 | 690 | token bucket per caller (the clock is a parameter, so its test does not wait), |
| 650 | 691 | and `src/bind.ts` decides the socket: `PORT` and `HOST` are the forge's, but |
| 651 | 692 | `HOST` is also what a shell sets to the machine's name, so only an address — |
src/client/main.test.ts +122 −2
| @@ -26,12 +26,13 @@ | ||
| 26 | 26 | import { expect, test } from "bun:test"; |
| 27 | 27 | import { nameOf, seedFrom } from "./generate.ts"; |
| 28 | 28 | import type { Level } from "./level.ts"; |
| 29 | import { boot, gameMeshes, type Frame } from "./main.ts"; | |
| 29 | import { boot, gameMeshes, NOT_AN_ID, type Frame } from "./main.ts"; | |
| 30 | 30 | import { DE_FORGE } from "./map.ts"; |
| 31 | 31 | import { SHORT } from "./match.ts"; |
| 32 | 32 | import { LAYERS, materialTextures } from "./materials.ts"; |
| 33 | import { snapshot, withoutEntropy } from "./replay.ts"; | |
| 33 | import { digest, snapshot, withoutEntropy } from "./replay.ts"; | |
| 34 | 34 | import { WEAPONS } from "./weapons.ts"; |
| 35 | import { matchOf } from "../matchid.ts"; | |
| 35 | 36 | import { parseResult } from "../result.ts"; |
| 36 | 37 | import { fakeStage, type FakeStage } from "./stage.test-helper.ts"; |
| 37 | 38 | import { createWorld, type World } from "./world.ts"; |
| @@ -647,3 +648,122 @@ test("a match is its id: the same id is the same match, and another id is anothe | ||
| 647 | 648 | expect(played(ID)).toBe(played(ID)); |
| 648 | 649 | expect(played(ID)).not.toBe(played(other)); |
| 649 | 650 | }); |
| 651 | ||
| 652 | // --- a match you can play again ------------------------------------------- | |
| 653 | ||
| 654 | /** A match id shaped like the ones `crypto.randomUUID` hands out. */ | |
| 655 | const PASTED = "7f1c2a4e-1111-4000-8000-9b3d5e7a2c64"; | |
| 656 | ||
| 657 | /** | |
| 658 | * A stage with `id` pasted into the menu's field, played from there. The page | |
| 659 | * still hands out its own fresh ids — this is the id a *person* supplied, which | |
| 660 | * is the whole point — so what the world is seeded with is the field and not | |
| 661 | * the uuid behind it. | |
| 662 | */ | |
| 663 | function pasted(id: string, options: { map?: string } = {}): FakeStage { | |
| 664 | const stage = painted(start(fakeStage())); | |
| 665 | stage.by("matchid").value = id; | |
| 666 | if (options.map !== undefined) stage.by("map").value = options.map; | |
| 667 | stage.by("play").fire("click"); | |
| 668 | return stage; | |
| 669 | } | |
| 670 | ||
| 671 | test("a pasted id is the match: the same id is the same world, a typo is a fresh one", () => { | |
| 672 | const world = worldOf(pasted(PASTED)); | |
| 673 | const mirror = createWorld(DE_FORGE, SHORT, "you", "ct", 5, seedFrom(PASTED)); | |
| 674 | expect(world).not.toBeNull(); | |
| 675 | // The id came from the field, not from the page's own source of ids: the | |
| 676 | // uuid it drew is the row key, and the match is the one that was pasted. | |
| 677 | expect(snapshot(world as World)).toBe(snapshot(mirror)); | |
| 678 | ||
| 679 | // Junk is not an error and not a refusal to play: a fresh match, and a line | |
| 680 | // under the field saying which of the two happened. | |
| 681 | const typo = pasted("not a match id"); | |
| 682 | expect(typo.by("onid").textContent).toBe(NOT_AN_ID); | |
| 683 | expect(typo.by("menu").hidden).toBe(true); | |
| 684 | expect(snapshot(worldOf(typo) as World)) | |
| 685 | .toBe(snapshot(createWorld(DE_FORGE, SHORT, "you", "ct", 5, seedFrom(typo.ids[0] as string)))); | |
| 686 | ||
| 687 | // Fix the typo and the warning goes: the line under the field is about the | |
| 688 | // match that is being played now, not about one that was mistyped earlier. | |
| 689 | typo.by("quit").fire("click"); | |
| 690 | typo.by("matchid").value = PASTED; | |
| 691 | typo.by("play").fire("click"); | |
| 692 | expect(typo.by("onid").textContent).not.toBe(NOT_AN_ID); | |
| 693 | ||
| 694 | // And an empty field is what it always was: the id the page drew, nothing | |
| 695 | // said, and the world seeded from it. | |
| 696 | const fresh = playing(); | |
| 697 | expect(fresh.by("onid").textContent).not.toBe(NOT_AN_ID); | |
| 698 | expect(snapshot(worldOf(fresh) as World)) | |
| 699 | .toBe(snapshot(createWorld(DE_FORGE, SHORT, "you", "ct", 5, seedFrom(fresh.ids[0] as string)))); | |
| 700 | }); | |
| 701 | ||
| 702 | test("two worlds from one id fold to the same digest, and another id folds elsewhere", () => { | |
| 703 | // The claim the id makes, end to end and through the engine's `digest` door: | |
| 704 | // fourteen seconds — the whole freeze and two seconds of a live round, so the | |
| 705 | // bots have decided something — of the same fixed script over a world the | |
| 706 | // *page* started from a pasted id. Same id, same hash, to the tick; one | |
| 707 | // character away, another hash. `withoutEntropy` is what makes that a fact | |
| 708 | // rather than a coincidence: a frame that reached for `Math.random` or a | |
| 709 | // clock on the way would throw here instead. | |
| 710 | const TICKS = 14 * 64; | |
| 711 | const run = (id: string): string => | |
| 712 | withoutEntropy(() => { | |
| 713 | const world = worldOf(pasted(id)); | |
| 714 | if (world === null) throw new Error(`the id ${id} started no match`); | |
| 715 | return digest(world, TICKS).digest; | |
| 716 | }); | |
| 717 | expect(run(PASTED)).toBe(run(PASTED)); | |
| 718 | expect(run(PASTED)).not.toBe(run(`${PASTED.slice(0, -1)}5`)); | |
| 719 | }); | |
| 720 | ||
| 721 | test("a rematch is a new row: the id is the match, the posted key is this playing of it", () => { | |
| 722 | const stage = pasted(PASTED); | |
| 723 | stage.step(FRAME); | |
| 724 | const first = JSON.parse(stage.posts[0]?.body ?? "{}"); | |
| 725 | // The row key is not the id: it is the id with this play's nonce on it, so | |
| 726 | // the leaderboard cannot fold two games of one match into one row — and it | |
| 727 | // still reads back as the match it was a playing of. | |
| 728 | expect(first.matchId).not.toBe(PASTED); | |
| 729 | expect(matchOf(first.matchId)).toBe(PASTED); | |
| 730 | expect(parseResult(first)).not.toBeNull(); | |
| 731 | ||
| 732 | // Play it again from the same menu: the same match, a different row. | |
| 733 | stage.by("quit").fire("click"); | |
| 734 | stage.by("play").fire("click"); | |
| 735 | // The same match over again — the same world the first click started — | |
| 736 | // written to a key of its own. | |
| 737 | expect(snapshot(worldOf(stage) as World)) | |
| 738 | .toBe(snapshot(createWorld(DE_FORGE, SHORT, "you", "ct", 5, seedFrom(PASTED)))); | |
| 739 | stage.step(FRAME); | |
| 740 | const second = JSON.parse(stage.posts[1]?.body ?? "{}"); | |
| 741 | expect(second.matchId).not.toBe(first.matchId); | |
| 742 | expect(matchOf(second.matchId)).toBe(PASTED); | |
| 743 | ||
| 744 | // A match nobody asked for is its own row key, exactly as before today. | |
| 745 | const plain = playing(); | |
| 746 | plain.step(FRAME); | |
| 747 | expect(JSON.parse(plain.posts[0]?.body ?? "{}").matchId).toBe(plain.ids[0]); | |
| 748 | }); | |
| 749 | ||
| 750 | test("the match being played is on the page: the id, the map, the link and the pause line", () => { | |
| 751 | const stage = pasted(PASTED, { map: "generated" }); | |
| 752 | const level = worldOf(stage)?.level; | |
| 753 | expect(level?.name).toBe(nameOf(seedFrom(PASTED))); | |
| 754 | expect(stage.by("playing").textContent).toBe(PASTED); | |
| 755 | expect(stage.by("mapname").textContent).toBe(level?.name); | |
| 756 | // A real href, to this match on this map: following it is the rematch. | |
| 757 | expect(stage.by("share").attributes.href).toBe(`/?match=${PASTED}&map=generated`); | |
| 758 | // The pause panel says the same thing in words, because on a full screen | |
| 759 | // there is no page under the stage to read. | |
| 760 | expect(stage.by("pauseid").textContent).toBe(`Match ${PASTED} on ${level?.name}.`); | |
| 761 | ||
| 762 | // de_forge is not a map the id carves, and the link says so rather than | |
| 763 | // handing a friend a different map. | |
| 764 | stage.by("quit").fire("click"); | |
| 765 | stage.by("map").value = "de_forge"; | |
| 766 | stage.by("play").fire("click"); | |
| 767 | expect(stage.by("mapname").textContent).toBe("de_forge"); | |
| 768 | expect(stage.by("share").attributes.href).toBe(`/?match=${PASTED}&map=de_forge`); | |
| 769 | }); |
src/client/main.ts +69 −3
| @@ -6,6 +6,12 @@ | ||
| 6 | 6 | * game's atlas and this game's meshes — and the HUD is painted on top. The page's overlays — menu, buy, pause — are plain DOM, so they are |
| 7 | 7 | * reachable with a keyboard and big enough for a thumb. |
| 8 | 8 | * |
| 9 | * A match is its id. The menu's field takes one — pasted, or put there by | |
| 10 | * `/?match=<id>` — and `start` below validates it through `src/matchid.ts`, the | |
| 11 | * same door the server validated the query with, before it becomes the seed the | |
| 12 | * map and the round are both drawn from. What is stored is a key for this | |
| 13 | * playing of it, so a rematch is a row of its own. | |
| 14 | * | |
| 9 | 15 | * Booting does not freeze the page. This game's atlas is nine 128-pixel layers |
| 10 | 16 | * and about 170 ms of arithmetic, so `boot` paints one layer per frame through |
| 11 | 17 | * the engine's `painter` and builds the renderer on the frame the last row |
| @@ -31,6 +37,7 @@ import { | ||
| 31 | 37 | type Button, type Camera, type KeyboardLock, type Listens, type Mesh, |
| 32 | 38 | } from "../engine/index.ts"; |
| 33 | 39 | import { generateLevel, seedFrom } from "./generate.ts"; |
| 40 | import { matchFrom, playKey } from "../matchid.ts"; | |
| 34 | 41 | import { DE_FORGE } from "./map.ts"; |
| 35 | 42 | import type { Level } from "./level.ts"; |
| 36 | 43 | import { COMPETITIVE, SHORT, type Rules } from "./match.ts"; |
| @@ -44,6 +51,13 @@ import { createWorld, eyePos, update, type Player, type World } from "./world.ts | ||
| 44 | 51 | |
| 45 | 52 | const TICK = 1 / 64; |
| 46 | 53 | |
| 54 | /** | |
| 55 | * What the menu says when the field holds something that is not a match id. | |
| 56 | * A typo is not an error page and not a refusal to play: the match starts, it | |
| 57 | * is a fresh one, and the line under the field says which of the two happened. | |
| 58 | */ | |
| 59 | export const NOT_AN_ID = "That is not a match id — playing a fresh match instead."; | |
| 60 | ||
| 47 | 61 | // --- the host: what a page has to be for this frame to run on it ----------- |
| 48 | 62 | |
| 49 | 63 | /** One element on the page, as far as this file is concerned. */ |
| @@ -139,7 +153,11 @@ export type Host = { | ||
| 139 | 153 | keyboard?: KeyboardLock; |
| 140 | 154 | storage: Store; |
| 141 | 155 | fetch(url: string, init: { method: string; headers: Record<string, string>; body: string }): Promise<Reply>; |
| 142 | /** A fresh match id. `crypto.randomUUID` is one; `src/result.ts` checks it. */ | |
| 156 | /** | |
| 157 | * A fresh id: the match when nobody asked for one, and the nonce on the row | |
| 158 | * key when somebody did. `crypto.randomUUID` is one; `src/matchid.ts` says | |
| 159 | * what shape it has to be. | |
| 160 | */ | |
| 143 | 161 | randomUUID(): string; |
| 144 | 162 | /** Null when this browser has no WebGL2: the menu says so and nothing starts. */ |
| 145 | 163 | createRenderer(canvas: Stage): BuildRenderer | null; |
| @@ -178,6 +196,20 @@ export function boot(host: Host): Frame { | ||
| 178 | 196 | const sideField = by<Field>("side"); |
| 179 | 197 | const sensField = by<Field>("sens"); |
| 180 | 198 | const mapField = by<Field>("map"); |
| 199 | // The id a person pasted, the line that answers them, and the three places | |
| 200 | // the match being played says what it is: the section under the stage (the | |
| 201 | // id, the map and the link), and the pause panel, which is all there is to | |
| 202 | // read when the stage has the whole screen. | |
| 203 | const idField = by<Field>("matchid"); | |
| 204 | const onId = by("onid"); | |
| 205 | // What that line said before anything was typed — the page's own sentence | |
| 206 | // about what an id is, kept so the warning below can be taken back off when | |
| 207 | // the field stops being junk rather than staying there as a lie. | |
| 208 | const aboutId = onId.textContent; | |
| 209 | const playingId = by("playing"); | |
| 210 | const mapName = by("mapname"); | |
| 211 | const share = by<Toggle>("share"); | |
| 212 | const pauseId = by("pauseid"); | |
| 181 | 213 | const onMap = by("onmap"); |
| 182 | 214 | const prep = by("prep"); |
| 183 | 215 | const play = by<Panel & { disabled: boolean }>("play"); |
| @@ -268,7 +300,10 @@ export function boot(host: Host): Frame { | ||
| 268 | 300 | // Who the camera is behind once you are dead, kept across the whole run: |
| 269 | 301 | // `spectate.ts` moves it, and a fresh match cuts it back to you. |
| 270 | 302 | const spectator = createSpectator(); |
| 303 | // The match being played, and the row it is being written to. They are one | |
| 304 | // string until somebody plays a match twice: see `start` below. | |
| 271 | 305 | let matchId = ""; |
| 306 | let playId = ""; | |
| 272 | 307 | // The map the renderer is holding the geometry of. It starts as the one the |
| 273 | 308 | // meshes were built from, and a match on another map swaps that one mesh. |
| 274 | 309 | let shown: Level = DE_FORGE; |
| @@ -386,7 +421,9 @@ export function boot(host: Host): Frame { | ||
| 386 | 421 | if (world === null) return; |
| 387 | 422 | const me = world.me; |
| 388 | 423 | const body = { |
| 389 | matchId, | |
| 424 | // The row is this playing of the match, not the match: two people who | |
| 425 | // play one id, and one person who plays it twice, are three rows. | |
| 426 | matchId: playId, | |
| 390 | 427 | name: nameField.value, |
| 391 | 428 | rounds: world.match.score.t + world.match.score.ct, |
| 392 | 429 | scoreMine: world.match.score[me.team], |
| @@ -420,7 +457,27 @@ export function boot(host: Host): Frame { | ||
| 420 | 457 | const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT; |
| 421 | 458 | const team = sideField.value === "t" ? "t" : "ct"; |
| 422 | 459 | const name = nameField.value.trim().slice(0, 24) || "you"; |
| 423 | matchId = host.randomUUID(); | |
| 460 | /** | |
| 461 | * Which match this is. The field is the boundary in the page — the server | |
| 462 | * validated the `?match=` that filled it and this checks it again, because | |
| 463 | * a person can type into it and neither side trusts the other. Anything | |
| 464 | * that is not an id is a fresh match and a sentence saying so; an empty | |
| 465 | * field is a fresh match and nothing said, which is every match before | |
| 466 | * today. | |
| 467 | * | |
| 468 | * The id and the row key are not the same string. The id is the match — the | |
| 469 | * thing you paste, and what the seed below is taken from — and the key is | |
| 470 | * this *playing* of it, which is the id with a nonce on it when the match | |
| 471 | * came from one. So a rematch lands beside the first game on the | |
| 472 | * leaderboard rather than on top of it, and the key still reads back as the | |
| 473 | * match it was a playing of. A fresh match is its own key, as before. | |
| 474 | */ | |
| 475 | const typed = idField.value.trim(); | |
| 476 | const wanted = matchFrom(typed); | |
| 477 | onId.textContent = typed !== "" && wanted === null ? NOT_AN_ID : aboutId; | |
| 478 | const fresh = host.randomUUID(); | |
| 479 | matchId = wanted ?? fresh; | |
| 480 | playId = wanted === null ? fresh : playKey(wanted, fresh); | |
| 424 | 481 | /** |
| 425 | 482 | * The whole of the match, as a number. It used to buy only the map, and the |
| 426 | 483 | * world took the wall clock behind it, so the id the leaderboard stores said |
| @@ -439,6 +496,15 @@ export function boot(host: Host): Frame { | ||
| 439 | 496 | shown = level; |
| 440 | 497 | } |
| 441 | 498 | onMap.textContent = `Playing ${level.name}.`; |
| 499 | // What is being played, where a person can copy it: the id and the map in | |
| 500 | // the section under the stage, the link that plays them again beside them, | |
| 501 | // and the same sentence in the pause panel for a full screen. `?map=` is | |
| 502 | // the Map control's own value, because the id carves a map only when the | |
| 503 | // match asked for one. | |
| 504 | playingId.textContent = matchId; | |
| 505 | mapName.textContent = level.name; | |
| 506 | pauseId.textContent = `Match ${matchId} on ${level.name}.`; | |
| 507 | share.setAttribute("href", `/?match=${matchId}&map=${level === DE_FORGE ? "de_forge" : "generated"}`); | |
| 442 | 508 | world = createWorld(level, rules, name, team, 5, seed); |
| 443 | 509 | posted = -1; |
| 444 | 510 | loop.reset(); |
src/client/stage.test-helper.ts +1 −1
| @@ -155,7 +155,7 @@ export type FakeStage = { | ||
| 155 | 155 | * contract between the page and this file, so `src/server.test.ts` checks the |
| 156 | 156 | * served page against this same list rather than a second copy of it. |
| 157 | 157 | */ |
| 158 | export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "name", "length", "side", "sens", "map", "onmap", "play", "prep", "resume", "quit", "esc", "fps", "full"]; | |
| 158 | export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "name", "length", "side", "sens", "map", "matchid", "onid", "onmap", "play", "prep", "resume", "quit", "esc", "pauseid", "playing", "mapname", "share", "fps", "full"]; | |
| 159 | 159 | |
| 160 | 160 | /** The ones the server renders with `hidden` on them: the page starts at the menu. */ |
| 161 | 161 | const HIDDEN = ["pause", "buy", "touch", "full"]; |
src/games.ts +15
| @@ -280,6 +280,21 @@ export type Game = { | ||
| 280 | 280 | * reasoning #164 raised the last one. Neither other bundle moved a byte — |
| 281 | 281 | * nothing outside `src/client/` changed. Measured 2026-09-20, with #190 merged |
| 282 | 282 | * in. |
| 283 | * #173 made the match id a thing a person can paste: `src/matchid.ts` — the one | |
| 284 | * door that says what an id is, what a playing of one is keyed by and how to | |
| 285 | * read the match back out of that key — plus the field the page validates | |
| 286 | * again, the line that answers a typo, and the id, the map and the share link | |
| 287 | * the frame writes out when a match starts. The app grew 728 raw / 350 gzipped | |
| 288 | * bytes for all of it, measured on top of main with #185, #195 and #196 in it: | |
| 289 | * 92,018 → 92,746 raw and 41,774 → 42,124 gzipped. It fits the 96,000 / 43,500 | |
| 290 | * #185 raised with 2,254 raw and 1,376 gzipped bytes to spare, so no ceiling | |
| 291 | * moved here — this branch asked for 95,000 before #185 landed and took #185's | |
| 292 | * larger number instead of arguing with it. The id module ships in the bundle | |
| 293 | * on purpose: the page has to validate what a person typed with the same shape | |
| 294 | * the server accepts, and a second copy of that regular expression is how two | |
| 295 | * of them drift apart. Neither other bundle moved a byte: nothing outside | |
| 296 | * `src/client/`, `src/pages/game.ts` and the new module changed, and neither of | |
| 297 | * the other two games imports any of them. Measured 2026-09-20. | |
| 283 | 298 | */ |
| 284 | 299 | export const GAMES: readonly Game[] = [ |
| 285 | 300 | { |
src/matchid.test.ts added +64
| @@ -0,0 +1,64 @@ | ||
| 1 | /** | |
| 2 | * The id, from both ends: what is accepted, what a row is keyed by, and that | |
| 3 | * a key reads back as the match it was a playing of. | |
| 4 | */ | |
| 5 | ||
| 6 | import { expect, test } from "bun:test"; | |
| 7 | import { isMatchId, matchFrom, matchOf, playKey } from "./matchid.ts"; | |
| 8 | import { parseResult } from "./result.ts"; | |
| 9 | ||
| 10 | const ID = "3d9b7c1e-0000-4000-8000-5a2f6b4c8d11"; | |
| 11 | ||
| 12 | test("a match id is hex and dashes, eight to sixty-four characters, and nothing else", () => { | |
| 13 | expect(isMatchId(ID)).toBe(true); | |
| 14 | expect(isMatchId("deadbeef")).toBe(true); | |
| 15 | expect(isMatchId("-".repeat(64))).toBe(true); | |
| 16 | ||
| 17 | expect(isMatchId("deadbee")).toBe(false); // seven | |
| 18 | expect(isMatchId("a".repeat(65))).toBe(false); | |
| 19 | expect(isMatchId("gggggggg")).toBe(false); // not hex | |
| 20 | expect(isMatchId("dead beef")).toBe(false); | |
| 21 | expect(isMatchId("<script>x</script>")).toBe(false); | |
| 22 | expect(isMatchId("../../etc/passwd")).toBe(false); | |
| 23 | expect(isMatchId(null)).toBe(false); | |
| 24 | expect(isMatchId(12345678)).toBe(false); | |
| 25 | }); | |
| 26 | ||
| 27 | test("the server's parser accepts exactly what this door does", () => { | |
| 28 | // src/result.ts asks here rather than carrying a second copy of the shape: | |
| 29 | // an id the page would play is an id the leaderboard would store. | |
| 30 | const result = (matchId: unknown): unknown => ({ | |
| 31 | matchId, name: "x", rounds: 1, scoreMine: 1, scoreTheirs: 0, | |
| 32 | kills: 0, deaths: 0, headshots: 0, damage: 0, seconds: 60, finished: false, | |
| 33 | }); | |
| 34 | for (const id of [ID, "deadbeef", playKey(ID, "00000000-0000-4000-8000-00000000abcd")]) | |
| 35 | expect(parseResult(result(id))).not.toBeNull(); | |
| 36 | for (const id of ["deadbee", "gggggggg", "", null]) expect(parseResult(result(id))).toBeNull(); | |
| 37 | }); | |
| 38 | ||
| 39 | test("a play key is unique per play, still an id, and reads back as its match", () => { | |
| 40 | const first = playKey(ID, "00000000-0000-4000-8000-00000000000a"); | |
| 41 | const second = playKey(ID, "00000000-0000-4000-8000-00000000000b"); | |
| 42 | expect(first).not.toBe(second); | |
| 43 | expect(isMatchId(first)).toBe(true); | |
| 44 | expect(matchOf(first)).toBe(ID); | |
| 45 | expect(matchFrom(first)).toBe(ID); | |
| 46 | // A match with no nonce on it is its own match. | |
| 47 | expect(matchOf(ID)).toBe(ID); | |
| 48 | }); | |
| 49 | ||
| 50 | test("a key that would not fit keeps the fresh id alone", () => { | |
| 51 | const long = "a".repeat(60); | |
| 52 | const fresh = "00000000-0000-4000-8000-00000000000a"; | |
| 53 | expect(playKey(long, fresh)).toBe(fresh); | |
| 54 | expect(isMatchId(playKey(long, fresh))).toBe(true); | |
| 55 | }); | |
| 56 | ||
| 57 | test("what a person pastes is a match, or it is nothing", () => { | |
| 58 | expect(matchFrom(ID)).toBe(ID); | |
| 59 | expect(matchFrom(` ${ID}`)).toBeNull(); // the page trims; this door does not guess | |
| 60 | expect(matchFrom("not-an-id!")).toBeNull(); | |
| 61 | expect(matchFrom(undefined)).toBeNull(); | |
| 62 | // A nonce with nothing in front of it is not a match to play. | |
| 63 | expect(matchFrom("--abcdefabcdef")).toBeNull(); | |
| 64 | }); |
src/matchid.ts added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | /** | |
| 2 | * What a match id is, in one place: the shape a browser may hand back, the | |
| 3 | * match a person typed, and the key one *playing* of a match is stored under. | |
| 4 | * | |
| 5 | * The id stopped being bookkeeping when a match became reproducible from it. | |
| 6 | * The same id is the same map, the same spawns, the same bots and the same | |
| 7 | * seed, so it is a rematch, a challenge and the first line of a bug report — | |
| 8 | * which means a person pastes it, and a pasted string is an input. `isMatchId` | |
| 9 | * is the whole of what is accepted, and `src/result.ts` accepts exactly this | |
| 10 | * shape for the id on a result, because it is this one: the server's parser, | |
| 11 | * the server's query, the page's field and the terminal all ask here. | |
| 12 | * | |
| 13 | * A match and a playing of it are not the same thing. Two people can play one | |
| 14 | * id, and one person can play it twice, and each of those is its own row on | |
| 15 | * the leaderboard — so the row is keyed by `<match>--<nonce>`: the match, a | |
| 16 | * double dash, and twelve hex from a fresh uuid. It is still an id of the same | |
| 17 | * shape, it is still unique per play, and the match is readable back out of it | |
| 18 | * with `matchOf`, so the id on a row is a thing you can paste and play. | |
| 19 | * | |
| 20 | * It imports nothing and ships in the bundle: `src/client/main.ts` validates | |
| 21 | * the field with the same door the server validates the query with. | |
| 22 | */ | |
| 23 | ||
| 24 | /** | |
| 25 | * The shape, and the only one. Hex and dashes, eight to sixty-four characters: | |
| 26 | * a uuid (`3d9b7c1e-0000-4000-8000-5a2f6b4c8d11`), a play key made of one, or | |
| 27 | * any short hex id a browser might hand out. Nothing else is a match id, and | |
| 28 | * nothing that is not one ever reaches `generateLevel` or the database. | |
| 29 | */ | |
| 30 | const SHAPE = /^[0-9a-fA-F-]{8,64}$/; | |
| 31 | ||
| 32 | /** The longest an id may be, which is `SHAPE`'s upper bound written down. */ | |
| 33 | const MAX = 64; | |
| 34 | ||
| 35 | /** What separates a match from the playing of it. Two dashes; a uuid has none. */ | |
| 36 | const PLAY = "--"; | |
| 37 | ||
| 38 | /** Twelve hex of a fresh uuid — its last field, which is the random one. */ | |
| 39 | const NONCE = 12; | |
| 40 | ||
| 41 | /** Is this a match id at all? The shape above, and nothing else. */ | |
| 42 | export const isMatchId = (value: unknown): value is string => | |
| 43 | typeof value === "string" && SHAPE.test(value); | |
| 44 | ||
| 45 | /** | |
| 46 | * The match an id names: everything before the play nonce, so the key a row is | |
| 47 | * stored under reads back as the match it was a playing of. An id with no | |
| 48 | * nonce in it is its own match, which is the usual case. | |
| 49 | */ | |
| 50 | export const matchOf = (id: string): string => id.split(PLAY)[0] ?? id; | |
| 51 | ||
| 52 | /** | |
| 53 | * The match a person typed or a link carried, or null if it is not one. This | |
| 54 | * is the boundary: a query parameter goes through it on the server and the | |
| 55 | * menu's field goes through it again in the page, and neither trusts the | |
| 56 | * other. A row's key is accepted as the match it names, so pasting either an | |
| 57 | * id or the key of one playing of it plays the same match. | |
| 58 | */ | |
| 59 | export function matchFrom(value: unknown): string | null { | |
| 60 | if (!isMatchId(value)) return null; | |
| 61 | const match = matchOf(value); | |
| 62 | return isMatchId(match) ? match : null; | |
| 63 | } | |
| 64 | ||
| 65 | /** | |
| 66 | * The key this playing of `match` is stored under: the match, a double dash | |
| 67 | * and the tail of a fresh uuid. Unique per play, so a rematch is a new row | |
| 68 | * rather than an overwrite, and still the match it came from to read. | |
| 69 | * | |
| 70 | * An id long enough that there is no room for a nonce — sixty-odd characters, | |
| 71 | * which nothing this app hands out is — keeps the fresh id alone instead: a | |
| 72 | * row key that is unique but says nothing is better than one that is neither. | |
| 73 | */ | |
| 74 | export function playKey(match: string, fresh: string): string { | |
| 75 | const key = `${match}${PLAY}${fresh.slice(-NONCE)}`; | |
| 76 | return key.length <= MAX && isMatchId(key) ? key : fresh; | |
| 77 | } |
src/pages/game.ts +80 −6
| @@ -43,12 +43,50 @@ ${table.length === 0 | ||
| 43 | 43 | <ul class="recent">${feed || `<li class="empty">nothing yet</li>`}</ul>`; |
| 44 | 44 | } |
| 45 | 45 | |
| 46 | /** | |
| 47 | * What the link a person followed asked for, validated in `src/server.ts` and | |
| 48 | * escaped here. `match` is a match id or null — never the raw parameter — and | |
| 49 | * `badMatch` says a parameter was there and was not one, which is a sentence | |
| 50 | * in the menu and a fresh match, not an error page. | |
| 51 | */ | |
| 52 | export type Asked = { | |
| 53 | match: string | null; | |
| 54 | badMatch: boolean; | |
| 55 | /** Which of the Map control's own values the link picked. */ | |
| 56 | map: "de_forge" | "generated"; | |
| 57 | }; | |
| 58 | ||
| 59 | /** A link with nothing on it: no id asked for, no map asked for, a fresh match. */ | |
| 60 | export const NOTHING_ASKED: Asked = { match: null, badMatch: false, map: "de_forge" }; | |
| 61 | ||
| 46 | 62 | /** What the shell needs that this page cannot know: the environment it runs in. */ |
| 47 | 63 | export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh"> & { |
| 48 | 64 | /** The forge identity the request carried, verified in `src/who.ts`. */ |
| 49 | 65 | who: Who; |
| 66 | /** What `/?match=…&map=…` asked for. Absent is `NOTHING_ASKED`. */ | |
| 67 | asked?: Asked; | |
| 50 | 68 | }; |
| 51 | 69 | |
| 70 | /** | |
| 71 | * This page's own CSS: the share link, which is an anchor and has to be as big | |
| 72 | * as the buttons around it, and the id itself, which one click selects whole | |
| 73 | * because the only thing anyone does with it is copy it. | |
| 74 | */ | |
| 75 | const STYLE = ` | |
| 76 | #thismatch code { user-select:all; word-break:break-all; } | |
| 77 | a.share { display:inline-flex; align-items:center; min-height:44px; padding:0 .7rem; border-radius:.45rem; | |
| 78 | background:#1b2230; border:1px solid var(--line); color:var(--ct); text-decoration:none; } | |
| 79 | a.share:hover, a.share:focus-visible { background:#27303f; text-decoration:underline; } | |
| 80 | #thismatch p { margin:.35rem 0; } | |
| 81 | `; | |
| 82 | ||
| 83 | /** What the menu's hint under the id field says when the link carried junk. */ | |
| 84 | export const BAD_ID = "That link's match id is not one — this is a fresh match instead."; | |
| 85 | ||
| 86 | /** The link that plays this match again: the id it names and the map it was on. */ | |
| 87 | export const shareHref = (asked: Asked): string => | |
| 88 | asked.match === null ? "/" : `/?match=${asked.match}&map=${asked.map}`; | |
| 89 | ||
| 52 | 90 | /** |
| 53 | 91 | * Where a guest goes to become someone. The forge serves its sign-in on this |
| 54 | 92 | * origin, so the link carries `rel="noreferrer"`: `Referrer-Policy` is decided |
| @@ -67,7 +105,7 @@ const SIGN_IN = "/__forge/signin?to=%2F"; | ||
| 67 | 105 | * Rendered that way rather than switched on by the script so the first paint is |
| 68 | 106 | * already true and nothing moves under the pointer afterwards. |
| 69 | 107 | */ |
| 70 | const menu = (visitor: Who): string => ` | |
| 108 | const menu = (visitor: Who, asked: Asked): string => ` | |
| 71 | 109 | <h2>Defuse or detonate</h2> |
| 72 | 110 | <p>Five against five on <b>de_forge</b>, or on a map generated for the match. Bomb rounds, |
| 73 | 111 | CS movement and economy, bots that push the sites, and a radar in the corner so a map |
| @@ -80,11 +118,21 @@ const menu = (visitor: Who): string => ` | ||
| 80 | 118 | <label>Name<input id="name" maxlength="24" placeholder="you" autocomplete="nickname"${ |
| 81 | 119 | visitor.role === "anon" ? "" : ` value="${escape(visitor.name)}" readonly` |
| 82 | 120 | }></label> |
| 83 | <label>Match<select id="length"><option value="short">Short — first to 5</option><option value="long">Competitive — first to 13</option></select></label> | |
| 121 | <label>Length<select id="length"><option value="short">Short — first to 5</option><option value="long">Competitive — first to 13</option></select></label> | |
| 84 | 122 | <label>Side<select id="side"><option value="ct">Counter-Terrorists</option><option value="t">Terrorists</option></select></label> |
| 85 | <label>Map<select id="map"><option value="de_forge">de_forge</option><option value="generated">generated — a new one every match</option></select></label> | |
| 123 | <label>Map<select id="map"><option value="de_forge">de_forge</option><option value="generated"${ | |
| 124 | asked.map === "generated" ? " selected" : "" | |
| 125 | }>generated — a new one every match</option></select></label> | |
| 86 | 126 | <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label> |
| 87 | 127 | </div> |
| 128 | <div class="row"> | |
| 129 | <label>Match id<input id="matchid" maxlength="64" placeholder="paste one to play it again" | |
| 130 | autocomplete="off" spellcheck="false"${asked.match === null ? "" : ` value="${escape(asked.match)}"`}></label> | |
| 131 | </div> | |
| 132 | <p class="hint" id="onid">${asked.badMatch | |
| 133 | ? escape(BAD_ID) | |
| 134 | : `An id is the whole match: the same map, the same spawns, the same bots and the same seed. | |
| 135 | Paste one here — or open its link — to play it again; empty is a new match, as ever.`}</p> | |
| 88 | 136 | <div class="row"><button type="button" class="primary" id="play" disabled>Play</button></div> |
| 89 | 137 | <p class="hint" id="onmap">A generated map is carved from the match id: two bombsites, two spawns |
| 90 | 138 | and cover, and the same map for both sides — it is the layout turned half a circle, so the walk |
| @@ -113,11 +161,35 @@ const PAUSE = ` | ||
| 113 | 161 | <h2>Paused</h2> |
| 114 | 162 | <div class="row"><button type="button" class="primary" id="resume">Resume</button><button type="button" id="quit">Leave match</button></div> |
| 115 | 163 | <p class="hint" id="esc"><kbd>Esc</kbd> or <kbd>P</kbd> pauses, and Resume goes back.</p> |
| 164 | <p class="hint" id="pauseid"></p> | |
| 116 | 165 | `; |
| 117 | 166 | |
| 118 | export function gamePage(db: Database, { who: visitor, ...options }: PageOptions): string { | |
| 167 | /** | |
| 168 | * The match you are playing, under the stage rather than over it: the id, the | |
| 169 | * map it is on and a link that plays it again. It sits in a section instead of | |
| 170 | * in an overlay because both overlays cover the stage and only one of them is | |
| 171 | * ever up — this is on the screen while the menu is, while the pause is, and | |
| 172 | * while the round is running, and a mouse can select the id out of it at any | |
| 173 | * of those moments. The pause panel carries the same line in words, for the | |
| 174 | * full screen, where there is no page to look at. | |
| 175 | * | |
| 176 | * It is server-rendered with whatever the link asked for and `src/client/main.ts` | |
| 177 | * fills it in on the frame a match starts; nothing here moves when it does. | |
| 178 | */ | |
| 179 | const thisMatch = (asked: Asked): string => ` <section id="thismatch"> | |
| 180 | <h2>This match</h2> | |
| 181 | <p>Match <code id="playing">${asked.match === null ? "none yet" : escape(asked.match)}</code> | |
| 182 | on <b id="mapname">${asked.match === null ? "—" : "the map its id carves"}</b> | |
| 183 | · <a class="share" id="share" href="${escape(shareHref(asked))}">Link to this match</a></p> | |
| 184 | <p class="empty">Press Play and the id of the match you are playing appears here. Paste it into | |
| 185 | <b>Match id</b> above, or open the link, and you get that match again — the same layout, the same | |
| 186 | spawns, the same bots and the same seed. Every playing of it is its own row on the leaderboard.</p> | |
| 187 | </section>`; | |
| 188 | ||
| 189 | export function gamePage(db: Database, { who: visitor, asked = NOTHING_ASKED, ...options }: PageOptions): string { | |
| 119 | 190 | return page({ |
| 120 | 191 | ...options, |
| 192 | extraStyle: STYLE, | |
| 121 | 193 | title: "cs — a browser defusal shooter", |
| 122 | 194 | description: |
| 123 | 195 | "A Counter-Strike-shaped defusal shooter that runs in the browser: WebGL2, bots, bomb rounds and an economy.", |
| @@ -141,12 +213,14 @@ export function gamePage(db: Database, { who: visitor, ...options }: PageOptions | ||
| 141 | 213 | ], |
| 142 | 214 | ], |
| 143 | 215 | overlays: [ |
| 144 | { id: "menu", html: menu(visitor) }, | |
| 216 | { id: "menu", html: menu(visitor, asked) }, | |
| 145 | 217 | { id: "pause", html: PAUSE, hidden: true }, |
| 146 | 218 | { id: "buy", html: "", hidden: true }, |
| 147 | 219 | ], |
| 148 | 220 | }), |
| 149 | sectionsHtml: ` <section id="stats">${statsHtml(db)}</section> | |
| 221 | sectionsHtml: `${thisMatch(asked)} | |
| 222 | ||
| 223 | <section id="stats">${statsHtml(db)}</section> | |
| 150 | 224 | |
| 151 | 225 | <section> |
| 152 | 226 | <h2>What this is</h2> |
src/result.ts +7 −2
| @@ -5,6 +5,8 @@ | ||
| 5 | 5 | * refuse anything else outright. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { isMatchId } from "./matchid.ts"; | |
| 9 | ||
| 8 | 10 | export type Result = { |
| 9 | 11 | matchId: string; |
| 10 | 12 | name: string; |
| @@ -50,8 +52,11 @@ export function cleanName(value: unknown): string { | ||
| 50 | 52 | export function parseResult(value: unknown): Result | null { |
| 51 | 53 | if (typeof value !== "object" || value === null) return null; |
| 52 | 54 | const body = value as Record<string, unknown>; |
| 53 | const matchId = typeof body.matchId === "string" ? body.matchId : ""; | |
| 54 | if (!/^[0-9a-fA-F-]{8,64}$/.test(matchId)) return null; | |
| 55 | // The id is the one shape `src/matchid.ts` accepts, asked for rather than | |
| 56 | // repeated: the menu's field, the server's query and this parser all agree | |
| 57 | // about what an id is because there is one of them. | |
| 58 | const matchId = body.matchId; | |
| 59 | if (!isMatchId(matchId)) return null; | |
| 55 | 60 | const rounds = integer(body.rounds, MAX_ROUNDS); |
| 56 | 61 | const scoreMine = integer(body.scoreMine, MAX_ROUNDS); |
| 57 | 62 | const scoreTheirs = integer(body.scoreTheirs, MAX_ROUNDS); |
src/server.test.ts +63
| @@ -4,6 +4,8 @@ import { tmpdir } from "node:os"; | ||
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { bundle, STATIC_DIR } from "../scripts/build.ts"; |
| 6 | 6 | import { GAMES } from "./games.ts"; |
| 7 | import { matchOf, playKey } from "./matchid.ts"; | |
| 8 | import { BAD_ID } from "./pages/game.ts"; | |
| 7 | 9 | import { escape } from "./pages/shell.ts"; |
| 8 | 10 | import { sign } from "./who.ts"; |
| 9 | 11 | import { PAGE_ACTIONS, PAGE_IDS } from "./client/stage.test-helper.ts"; |
| @@ -65,6 +67,67 @@ test("the page carries every id and action the client reaches for", async () => | ||
| 65 | 67 | expect(body).toContain(`id="prep">Preparing textures…</p>`); |
| 66 | 68 | }); |
| 67 | 69 | |
| 70 | test("`/?match=` preselects a match to play again, and says so in the first paint", async () => { | |
| 71 | const id = "7f1c2a4e-1111-4000-8000-9b3d5e7a2c64"; | |
| 72 | const body = await (await fetch(`${base}/?match=${id}&map=generated`)).text(); | |
| 73 | // The field the client validates again is served holding it, the section | |
| 74 | // under the stage names it, and the link beside it plays it again. | |
| 75 | expect(body).toContain(`id="matchid" maxlength="64"`); | |
| 76 | expect(body).toContain(`value="${id}"`); | |
| 77 | expect(body).toContain(`<code id="playing">${id}</code>`); | |
| 78 | expect(body).toContain(`href="/?match=${id}&map=generated"`); | |
| 79 | // The map the link asked for is the one the control is on. | |
| 80 | expect(body).toContain(`<option value="generated" selected>`); | |
| 81 | expect(body).not.toContain(`<option value="de_forge" selected>`); | |
| 82 | expect(body).not.toContain(escape(BAD_ID)); | |
| 83 | }); | |
| 84 | ||
| 85 | test("a `?match=` that is not an id is a fresh match and a sentence, never an error page", async () => { | |
| 86 | // Every one of these is refused by the same door `src/result.ts` refuses a | |
| 87 | // result's id with, and not one of them is rendered anywhere on the page. | |
| 88 | for (const junk of ["<script>alert(1)</script>", "'; drop table match--", "../../etc/passwd", "zzz", "%00", ""]) { | |
| 89 | const response = await fetch(`${base}/?match=${encodeURIComponent(junk)}`); | |
| 90 | expect(response.status).toBe(200); | |
| 91 | const body = await response.text(); | |
| 92 | expect(body).toContain(escape(BAD_ID)); | |
| 93 | expect(body).toContain(`<code id="playing">none yet</code>`); | |
| 94 | expect(body).toContain(`href="/"`); | |
| 95 | // Nothing of what was asked for came back, escaped or otherwise. | |
| 96 | if (junk !== "") { | |
| 97 | expect(body).not.toContain(junk); | |
| 98 | expect(body).not.toContain(escape(junk)); | |
| 99 | } | |
| 100 | expect(body).not.toContain("alert(1)"); | |
| 101 | } | |
| 102 | }); | |
| 103 | ||
| 104 | test("a page nobody linked to is the page it always was: no id, a fresh match, de_forge", async () => { | |
| 105 | const body = await (await fetch(base + "/")).text(); | |
| 106 | expect(body).toContain(`<code id="playing">none yet</code>`); | |
| 107 | expect(body).not.toContain(escape(BAD_ID)); | |
| 108 | // de_forge is the first option and so the one a browser picks: the markup a | |
| 109 | // person who followed no link gets is the markup they always got. | |
| 110 | expect(body).not.toContain("selected"); | |
| 111 | // The field is there and it is empty: an id is something a person supplies. | |
| 112 | expect(body).toContain(`id="matchid" maxlength="64"`); | |
| 113 | expect(body).not.toMatch(/id="matchid"[^>]*value=/); | |
| 114 | }); | |
| 115 | ||
| 116 | test("the row key one playing of a match is stored under is a row of its own", async () => { | |
| 117 | // What the page posts for a rematch: the match, a double dash and this | |
| 118 | // play's nonce. Two of them are two rows, and both are the same match. | |
| 119 | const id = "7f1c2a4e-1111-4000-8000-9b3d5e7a2c64"; | |
| 120 | const first = playKey(id, "00000000-0000-4000-8000-0000000000a1"); | |
| 121 | const second = playKey(id, "00000000-0000-4000-8000-0000000000a2"); | |
| 122 | const cookie = "cs_session=" + "a".repeat(32); | |
| 123 | for (const key of [first, second]) | |
| 124 | expect((await post(result({ matchId: key, name: "rematcher" }), cookie)).status).toBe(200); | |
| 125 | expect(matchOf(first)).toBe(id); | |
| 126 | const feed = await (await fetch(base + "/")).text(); | |
| 127 | // Two matches on the board under that name, not one overwritten. | |
| 128 | expect(feed.match(/rematcher/g)?.length).toBeGreaterThanOrEqual(2); | |
| 129 | }); | |
| 130 | ||
| 68 | 131 | test("serves the range on its own route, with its own page and its own bundle", async () => { |
| 69 | 132 | const response = await fetch(base + "/range"); |
| 70 | 133 | expect(response.status).toBe(200); |
src/server.ts +25 −2
| @@ -26,7 +26,8 @@ import { openDatabase, type Player, saveMatch } from "./db.ts"; | ||
| 26 | 26 | import { GAMES } from "./games.ts"; |
| 27 | 27 | import { createLimiter } from "./limit.ts"; |
| 28 | 28 | import { examplePage } from "./pages/example.ts"; |
| 29 | import { gamePage, statsHtml } from "./pages/game.ts"; | |
| 29 | import { type Asked, gamePage, statsHtml } from "./pages/game.ts"; | |
| 30 | import { matchFrom } from "./matchid.ts"; | |
| 30 | 31 | import { errorPage, notFoundPage } from "./pages/miss.ts"; |
| 31 | 32 | import { rangePage } from "./pages/range.ts"; |
| 32 | 33 | import { cleanName, parseResult } from "./result.ts"; |
| @@ -191,13 +192,35 @@ const scripts = findScripts(); | ||
| 191 | 192 | */ |
| 192 | 193 | type Render = (request: Request, script: string) => Response; |
| 193 | 194 | |
| 195 | /** | |
| 196 | * What a link to the shooter asked for. `/?match=<id>` plays that match again | |
| 197 | * and `?map=` picks which of the Map control's two values it is played on — | |
| 198 | * together they are what the page's own "Link to this match" hands out. | |
| 199 | * | |
| 200 | * Both are inputs and both are settled here: the id is whatever `src/matchid.ts` | |
| 201 | * accepts and null otherwise, the map is one of two known words, and nothing a | |
| 202 | * caller wrote reaches the page as itself. A parameter that is not an id is not | |
| 203 | * an error — the page says so in a sentence and plays a fresh match — and the | |
| 204 | * page escapes what it renders anyway, because two checks are the point. | |
| 205 | */ | |
| 206 | const asked = (request: Request): Asked => { | |
| 207 | const query = new URL(request.url).searchParams; | |
| 208 | const wanted = query.get("match"); | |
| 209 | const match = matchFrom(wanted); | |
| 210 | return { | |
| 211 | match, | |
| 212 | badMatch: wanted !== null && match === null, | |
| 213 | map: query.get("map") === "generated" ? "generated" : "de_forge", | |
| 214 | }; | |
| 215 | }; | |
| 216 | ||
| 194 | 217 | const RENDER: Record<string, Render> = { |
| 195 | 218 | // The shooter: the forge's identity, this environment's database, and a |
| 196 | 219 | // session cookie for a browser that arrives without one. |
| 197 | 220 | app: (request, script) => { |
| 198 | 221 | const session = readSession(request); |
| 199 | 222 | return html( |
| 200 | gamePage(db, { env, pull, commit, script, ssh, who: who(request, identityKey) }), | |
| 223 | gamePage(db, { env, pull, commit, script, ssh, who: who(request, identityKey), asked: asked(request) }), | |
| 201 | 224 | 200, |
| 202 | 225 | session === null ? { "set-cookie": sessionCookie(newSession()) } : {}, |
| 203 | 226 | ); |
src/terminal/screen.test.ts +24
| @@ -106,6 +106,30 @@ test("the match list keeps its columns and marks the ones still being played", ( | ||
| 106 | 106 | expect(lines[6]).toBe(" bo 3-2 7/4 950 2026-09-16 10:00:00 (live)"); |
| 107 | 107 | }); |
| 108 | 108 | |
| 109 | test("the matches tab prints the id of the match under the cursor, ready to paste", () => { | |
| 110 | // A match somebody played is a match anybody can play again, and the page is | |
| 111 | // not the only place that is true: the id under the cursor is printed as the | |
| 112 | // string the menu's Match id field takes. | |
| 113 | const rows = data({ | |
| 114 | matches: [ | |
| 115 | match("7f1c2a4e-1111-4000-8000-9b3d5e7a2c64--0000000000a1", "ace"), | |
| 116 | match("3d9b7c1e-0000-4000-8000-5a2f6b4c8d11", "bo"), | |
| 117 | ], | |
| 118 | }); | |
| 119 | const lines = text(view({ tab: "matches" }), rows); | |
| 120 | // The key the row is stored under is one playing of a match; what is offered | |
| 121 | // is the match inside it. | |
| 122 | const offer = (ls: string[]): string | undefined => ls.find((l) => l.startsWith("play this match again")); | |
| 123 | expect(offer(lines)).toBe("play this match again: paste 7f1c2a4e-1111-4000-8000-9b3d5e7a2c64 into Match id"); | |
| 124 | // It follows the cursor, and a match with no nonce on it is its own id. | |
| 125 | expect(offer(text(view({ tab: "matches", cursor: 1 }), rows))) | |
| 126 | .toBe("play this match again: paste 3d9b7c1e-0000-4000-8000-5a2f6b4c8d11 into Match id"); | |
| 127 | // Nothing to stand on, nothing offered. | |
| 128 | expect(text(view({ tab: "matches" }), data({ matches: [] })).some((l) => l.includes("paste"))).toBe(false); | |
| 129 | // And it is the matches tab's line alone. | |
| 130 | expect(text(view()).some((l) => l.includes("paste"))).toBe(false); | |
| 131 | }); | |
| 132 | ||
| 109 | 133 | test("a guest is named as one on both lists, so no row is read as a signed-in player's", () => { |
| 110 | 134 | const guest = data({ |
| 111 | 135 | standings: [standing("kestrel"), standing("kestrel", { user_id: "" })], |
src/terminal/screen.ts +10
| @@ -16,6 +16,7 @@ | ||
| 16 | 16 | import type { Card } from "../client/weights.ts"; |
| 17 | 17 | import type { MatchRow, Standing } from "../db.ts"; |
| 18 | 18 | import { displayName, headshotShare, ratio } from "../db.ts"; |
| 19 | import { matchOf } from "../matchid.ts"; | |
| 19 | 20 | |
| 20 | 21 | export type Tab = "standings" | "matches" | "policy"; |
| 21 | 22 | |
| @@ -123,6 +124,15 @@ function matchLines(matches: MatchRow[], cursor: number): string[] { | ||
| 123 | 124 | `${m.finished === 1 ? "" : " (live)"}`; |
| 124 | 125 | lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`); |
| 125 | 126 | }); |
| 127 | // The match the cursor is on, as the id that plays it again. A row is keyed | |
| 128 | // by one *playing* of a match, so what is printed is the match inside that | |
| 129 | // key — the same string the page's Match id field takes, which is the point | |
| 130 | // of printing it: a match somebody played is a match you can play here too, | |
| 131 | // by pasting this into the menu. Never the raw column: `printable` guards | |
| 132 | // every line here, and an id that came from a browser is no exception. | |
| 133 | const selected = matches[cursor]; | |
| 134 | if (selected !== undefined) | |
| 135 | lines.push("", `${esc}2mplay this match again: paste ${printable(matchOf(selected.id))} into Match id${esc}0m`); | |
| 126 | 136 | return lines; |
| 127 | 137 | } |
| 128 | 138 |
static/app.46e58255bb.js deleted −58
Diff not shown — a line 73,131 characters wide (limit 2,000). −58
View filestatic/app.6bbbaf11e0.js deleted −58
Diff not shown — a line 72,599 characters wide (limit 2,000). −58
View filestatic/app.bb7df12ff4.js added +58
Diff not shown — a line 73,851 characters wide (limit 2,000). +58
View file
new above
offline