Public: anyone can read this; the crew can change it.
forked from trevato/cs
The map you played has a name you can keep: the seed on the menu, in the URL, and a link that plays it again #213
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/148-the-map-you-played-has-a-name-you-can-ke → main 2 commits fork
16 files
changed +748
−108
README.md +41 −6
| @@ -105,12 +105,12 @@ report. | ||
| 105 | 105 | - **Paste it.** The menu has a **Match id** field. Put an id in it and Play |
| 106 | 106 | plays that match again — the same layout, the same bots, the same seed. Leave |
| 107 | 107 | it empty and you get a new match, exactly as before. |
| 108 | - **Link to it.** `/?match=<id>&map=<de_forge|generated>` preselects both. The | |
| 109 | page's own **Link to this match**, under the stage, is that URL for whatever | |
| 110 | you are playing: a plain `<a href>` a keyboard reaches and a thumb can hit, | |
| 111 | not a clipboard trick. The map is in the link because the id carves a map only | |
| 112 | when the match asked for a generated one; on `de_forge` the id is still the | |
| 113 | whole of everything else. | |
| 108 | - **Link to it.** `/?match=<id>&map=<de_forge|generated|gen_…>` preselects both. | |
| 109 | The page's own **Link to this match**, under the stage, is that URL for | |
| 110 | whatever you are playing: a plain `<a href>` a keyboard reaches and a thumb | |
| 111 | can hit, not a clipboard trick. The map is in the link because the id carves a | |
| 112 | map only when the match asked for a generated one; on `de_forge` the id is | |
| 113 | still the whole of everything else. | |
| 114 | 114 | - **Read it.** The id of the match you are playing is in the *This match* |
| 115 | 115 | section under the stage with the map's name beside it (one click selects the |
| 116 | 116 | whole id), and in the pause panel, which is all there is to read when the |
| @@ -132,6 +132,41 @@ is rendered, the page escapes what it renders anyway, and | ||
| 132 | 132 | reaches `generateLevel`. Anything else is a fresh match and a sentence saying |
| 133 | 133 | so, never an error page and never a refusal to play. |
| 134 | 134 | |
| 135 | ## A map you can keep | |
| 136 | ||
| 137 | A generated map is carved from a number, and that number has a name: `gen_14u5j6g`, | |
| 138 | base36 of the seed. The name is the map — the same name is the same forty by forty | |
| 139 | cells on every machine, for ever — so a layout you liked is a thing you can write | |
| 140 | down and hand to somebody who has never played the round it happened in. | |
| 141 | ||
| 142 | - **Choose it.** The menu's Map control is three things in two fields: the select | |
| 143 | (`de_forge`, or `generated — a new one every match`) and a **Map name** field. | |
| 144 | A name in the field is that exact map and wins over the select; empty, the | |
| 145 | select decides. | |
| 146 | - **Read it before you play.** The line above Play names the map Play is about to | |
| 147 | give you — `de_forge`, or the name of the map that is about to be carved. On | |
| 148 | `generated` the page draws the match id the moment it needs to name that map, | |
| 149 | so the name on the screen is the map you get and not a guess. | |
| 150 | - **Keep it.** Beside that name is **Copy this map's link**, which puts the whole | |
| 151 | URL on the clipboard. It is an `<a href="/?map=gen_…">` first: a keyboard | |
| 152 | reaches it, it is 44px to press, and a plain click — or a browser with no | |
| 153 | clipboard, or one that refuses the write — simply opens the page on that map. | |
| 154 | The name of the map you are playing, under the stage, is a link to it too. | |
| 155 | - **Open it.** `/?map=gen_14u5j6g` preselects that map. `?map=` is the shooter's | |
| 156 | parameter and no other page reads it. | |
| 157 | ||
| 158 | A name is an input and it is checked twice against one definition: | |
| 159 | `src/mapname.ts` says what a name is — `gen_` and base36, and the test is the | |
| 160 | round trip, so `gen_0ya` and `gen_zzzzzzzz` are typos rather than maps — | |
| 161 | `src/server.ts` runs the `?map=` query through it before the page is rendered, | |
| 162 | the page escapes what it renders anyway, and `src/client/main.ts` runs the field | |
| 163 | through the same door again before anything reaches `generateLevel`. Anything | |
| 164 | else is `de_forge` and a sentence saying so, never an error page. The seed itself | |
| 165 | is pinned: `src/client/generate.test.ts` folds `gen_14u5j6g`'s cells into a | |
| 166 | digest through the engine's `digest` door and holds it against a committed | |
| 167 | constant, so a change to the generator that would move somebody's map fails the | |
| 168 | test rather than surprising them. | |
| 169 | ||
| 135 | 170 | ## Who a score belongs to |
| 136 | 171 | |
| 137 | 172 | The numbers in a result are the browser's word; the name on it is not. Whoever |
scripts/build.test.ts +1 −1
| @@ -38,7 +38,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri | ||
| 38 | 38 | |
| 39 | 39 | test("the entry table is what the app ships today, each with the size it may not pass", () => { |
| 40 | 40 | expect(ENTRIES).toEqual([ |
| 41 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 96_000, gzip: 43_500 } }, | |
| 41 | { name: "app", entry: "src/client/main.ts", ceiling: { raw: 100_000, gzip: 45_000 } }, | |
| 42 | 42 | { name: "range", entry: "src/range/main.ts", ceiling: { raw: 37_000, gzip: 15_500 } }, |
| 43 | 43 | { name: "example", entry: "src/example/main.ts", ceiling: { raw: 10_800, gzip: 5_100 } }, |
| 44 | 44 | ]); |
src/client/generate.test.ts +55 −2
| @@ -14,12 +14,12 @@ | ||
| 14 | 14 | import { expect, test } from "bun:test"; |
| 15 | 15 | import { botThink } from "./bots.ts"; |
| 16 | 16 | import { MAT, type Cell } from "./cells.ts"; |
| 17 | import { flaws, generateGrid, generateLevel, nameOf, page, SIZE } from "./generate.ts"; | |
| 17 | import { flaws, generateGrid, generateLevel, nameOf, page, seedOf, SIZE } from "./generate.ts"; | |
| 18 | 18 | import { colOf, findPath, openCells, rowOf, siteAt, walkable, worldOf, type Level } from "./level.ts"; |
| 19 | 19 | import { SHORT } from "./match.ts"; |
| 20 | 20 | import { digest, withoutEntropy } from "./replay.ts"; |
| 21 | 21 | import { applyInput, createWorld, type Player } from "./world.ts"; |
| 22 | import { region, type Vec3 } from "../engine/index.ts"; | |
| 22 | import { compare, fold, region, type Vec3 } from "../engine/index.ts"; | |
| 23 | 23 | |
| 24 | 24 | /** How long a walk is, in Source units: the sum of the steps A* handed back. */ |
| 25 | 25 | const walkLength = (path: Vec3[], from: Vec3): number => { |
| @@ -33,6 +33,9 @@ const walkLength = (path: Vec3[], from: Vec3): number => { | ||
| 33 | 33 | |
| 34 | 34 | const letters = (level: Level): Cell[] => level.grid.cells as Cell[]; |
| 35 | 35 | |
| 36 | /** A level's own cells as a page of letters: `page` over the grid it carries. */ | |
| 37 | const rowsOf = (level: Level): string[] => page({ ...level.grid, cells: letters(level) }); | |
| 38 | ||
| 36 | 39 | test("a seed is a map, and the same seed is the same map for ever", () => { |
| 37 | 40 | expect(page(generateGrid(7))).toEqual(page(generateGrid(7))); |
| 38 | 41 | expect(page(generateGrid(7))).not.toEqual(page(generateGrid(8))); |
| @@ -45,6 +48,56 @@ test("a seed is a map, and the same seed is the same map for ever", () => { | ||
| 45 | 48 | expect(a.sites).toEqual(b.sites); |
| 46 | 49 | }); |
| 47 | 50 | |
| 51 | /** | |
| 52 | * The map `gen_14u5j6g` is, folded cell by cell into a hash through the | |
| 53 | * engine's `digest` door — the same instrument the rounds are pinned with, | |
| 54 | * given rows of letters instead of ticks of a world. | |
| 55 | * | |
| 56 | * This is the claim the name makes: a person writes `gen_14u5j6g` down, hands | |
| 57 | * it to somebody on another machine, and that machine carves the same forty by | |
| 58 | * forty cells. Nothing here is timed, seeded by a clock or read off a file, so | |
| 59 | * a digest that moves is the generator's shape changing — which is allowed, and | |
| 60 | * is a line in the pull that says which maps changed, with these constants | |
| 61 | * pasted in from the failure. | |
| 62 | */ | |
| 63 | const NAMED = "gen_14u5j6g"; | |
| 64 | const NAMED_MAP = "d548dcae07259c836d93ad7d8df3a195281c4da8ee1ef53f9000f5d3e115def9"; | |
| 65 | const NAMED_BANDS: readonly string[] = [ | |
| 66 | "c674e6e4c9f2", "45bb896cd286", "e16819fdf569", "6b799f4e0392", | |
| 67 | ]; | |
| 68 | ||
| 69 | test("a map you can name is the same map on every machine, cell for cell", () => { | |
| 70 | const seed = seedOf(NAMED); | |
| 71 | if (seed === null) throw new Error(`${NAMED} is not a map name, and it is the one this test is about`); | |
| 72 | const level = generateLevel(seed); | |
| 73 | // The name is the map's own name, not a label put beside it. | |
| 74 | expect(level.name).toBe(NAMED); | |
| 75 | expect(nameOf(seed)).toBe(NAMED); | |
| 76 | ||
| 77 | const rows = rowsOf(level); | |
| 78 | let at = 0; | |
| 79 | const run = fold({ | |
| 80 | ticks: rows.length, | |
| 81 | step: (row) => void (at = row), | |
| 82 | line: () => rows[at] as string, | |
| 83 | every: 10, | |
| 84 | }); | |
| 85 | const why = compare( | |
| 86 | run, | |
| 87 | { label: `map ${NAMED}`, constant: "NAMED_MAP", digest: NAMED_MAP, checkpoints: NAMED_BANDS }, | |
| 88 | { | |
| 89 | file: "src/client/generate.test.ts", | |
| 90 | list: "NAMED_BANDS", | |
| 91 | hashes: "every cell of the level's grid, one row of letters a line", | |
| 92 | every: 10, | |
| 93 | unit: "band", | |
| 94 | }, | |
| 95 | ); | |
| 96 | if (why !== null) throw new Error(why); | |
| 97 | // Asking twice is asking once: the whole point of a seed. | |
| 98 | expect(rowsOf(generateLevel(seed))).toEqual(rows); | |
| 99 | }); | |
| 100 | ||
| 48 | 101 | test("a map is written on a page of letters, one string a row", () => { |
| 49 | 102 | const grid = generateGrid(3); |
| 50 | 103 | const rows = page(grid); |
src/client/generate.ts +11 −3
| @@ -28,9 +28,20 @@ | ||
| 28 | 28 | */ |
| 29 | 29 | |
| 30 | 30 | import { region, rng, type Grid, type Vec3 } from "../engine/index.ts"; |
| 31 | import { nameOf, seedOf } from "../mapname.ts"; | |
| 31 | 32 | import { CELL, levelOf, type Cell } from "./cells.ts"; |
| 32 | 33 | import { colOf, findPath, rowOf, type Level } from "./level.ts"; |
| 33 | 34 | |
| 35 | /** | |
| 36 | * What a map is called and what a name means, both from `src/mapname.ts` and | |
| 37 | * re-exported here so the two live beside each other for a reader: `nameOf` is | |
| 38 | * the name of a seed's map and `seedOf` is the seed of a name, which is how a | |
| 39 | * map you played is a map you can ask for again. The grammar is there rather | |
| 40 | * than here because the server validates `?map=` with it and may not import a | |
| 41 | * generator to do it. | |
| 42 | */ | |
| 43 | export { nameOf, seedOf }; | |
| 44 | ||
| 34 | 45 | /** Cells on a side. 40 is 5,120 Source units: a little tighter than de_forge. */ |
| 35 | 46 | export const SIZE = 40; |
| 36 | 47 | |
| @@ -59,9 +70,6 @@ export function seedFrom(text: string): number { | ||
| 59 | 70 | return hash >>> 0; |
| 60 | 71 | } |
| 61 | 72 | |
| 62 | /** The name a seed's map answers to: `gen_1z3f`, short enough for a menu. */ | |
| 63 | export const nameOf = (seed: number): string => `gen_${(seed >>> 0).toString(36)}`; | |
| 64 | ||
| 65 | 73 | /** The page of letters a grid is, one string a row: what a test or a terminal reads. */ |
| 66 | 74 | export const page = (grid: Grid<Cell>): string[] => |
| 67 | 75 | Array.from({ length: grid.height }, (_, r) => grid.cells.slice(r * grid.width, (r + 1) * grid.width).join("")); |
src/client/main.test.ts +122 −3
| @@ -24,9 +24,9 @@ | ||
| 24 | 24 | */ |
| 25 | 25 | |
| 26 | 26 | import { expect, test } from "bun:test"; |
| 27 | import { nameOf, seedFrom } from "./generate.ts"; | |
| 27 | import { generateLevel, nameOf, seedFrom, seedOf } from "./generate.ts"; | |
| 28 | 28 | import type { Level } from "./level.ts"; |
| 29 | import { boot, gameMeshes, NOT_AN_ID, type Frame } from "./main.ts"; | |
| 29 | import { boot, COPIED, gameMeshes, NOT_AN_ID, NOT_A_MAP, NOT_COPIED, 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"; |
| @@ -37,7 +37,7 @@ import { digest, snapshot, withoutEntropy } from "./replay.ts"; | ||
| 37 | 37 | import { WEAPONS } from "./weapons.ts"; |
| 38 | 38 | import { matchOf } from "../matchid.ts"; |
| 39 | 39 | import { parseResult } from "../result.ts"; |
| 40 | import { fakeStage, type FakeStage } from "./stage.test-helper.ts"; | |
| 40 | import { fakeStage, ORIGIN, type FakeStage } from "./stage.test-helper.ts"; | |
| 41 | 41 | import { createWorld, type World } from "./world.ts"; |
| 42 | 42 | |
| 43 | 43 | /** Every frame booted here, so a stage can be asked what world it is running. */ |
| @@ -791,6 +791,125 @@ test("the map field chooses the map, and a generated one costs one mesh", () => | ||
| 791 | 791 | expect(stage.replaced.map((r) => r.name)).toEqual(["level", "level"]); |
| 792 | 792 | }); |
| 793 | 793 | |
| 794 | // --- a map you can keep --------------------------------------------------- | |
| 795 | ||
| 796 | /** One map's name, base36 of a seed, the way the page hands them out. */ | |
| 797 | const MAP = "gen_14u5j6g"; | |
| 798 | ||
| 799 | test("the map Play will give you is named before Play, and named again as a link", () => { | |
| 800 | const stage = painted(start(fakeStage())); | |
| 801 | // Nothing touched: de_forge, named and linked, and no id drawn to name it. | |
| 802 | expect(stage.by("willplay").textContent).toBe("de_forge"); | |
| 803 | expect(stage.by("copymap").attributes.href).toBe("/?map=de_forge"); | |
| 804 | expect(stage.ids.length).toBe(0); | |
| 805 | ||
| 806 | // A new map every match: the id that map is carved from is drawn now, so the | |
| 807 | // name is on the screen *before* anything is played — and the link beside it | |
| 808 | // is that map by name, not another new one. | |
| 809 | stage.by("map").value = "generated"; | |
| 810 | stage.by("map").fire("change"); | |
| 811 | expect(stage.ids.length).toBe(1); | |
| 812 | const name = nameOf(seedFrom(stage.ids[0] as string)); | |
| 813 | expect(stage.by("willplay").textContent).toBe(name); | |
| 814 | expect(stage.by("copymap").attributes.href).toBe(`/?map=${name}`); | |
| 815 | ||
| 816 | // And it is the map that is played: the promise the menu made is kept, out of | |
| 817 | // the id it had already drawn rather than a second one. | |
| 818 | stage.by("play").fire("click"); | |
| 819 | expect(worldOf(stage)?.level.name).toBe(name); | |
| 820 | expect(stage.ids.length).toBe(1); | |
| 821 | expect(stage.by("mapname").textContent).toBe(name); | |
| 822 | expect(stage.by("mapname").attributes.href).toBe(`/?map=${name}`); | |
| 823 | ||
| 824 | // Back at the menu, the line is about the next match again: another map, its | |
| 825 | // own name, its own link, and the one just played still named under the stage. | |
| 826 | stage.by("quit").fire("click"); | |
| 827 | expect(stage.ids.length).toBe(2); | |
| 828 | const next = nameOf(seedFrom(stage.ids[1] as string)); | |
| 829 | expect(next).not.toBe(name); | |
| 830 | expect(stage.by("willplay").textContent).toBe(next); | |
| 831 | expect(stage.by("mapname").textContent).toBe(name); | |
| 832 | }); | |
| 833 | ||
| 834 | test("a map by name is that map, and a name that is not one is de_forge and says so", () => { | |
| 835 | const stage = painted(start(fakeStage())); | |
| 836 | stage.by("mapseed").value = MAP; | |
| 837 | stage.by("mapseed").fire("input"); | |
| 838 | expect(stage.by("willplay").textContent).toBe(MAP); | |
| 839 | expect(stage.by("copymap").attributes.href).toBe(`/?map=${MAP}`); | |
| 840 | // A name needs no id: the map is the seed the name means and nothing else. | |
| 841 | expect(stage.ids.length).toBe(0); | |
| 842 | ||
| 843 | stage.by("play").fire("click"); | |
| 844 | const level = worldOf(stage)?.level; | |
| 845 | expect(level?.name).toBe(MAP); | |
| 846 | // The same cells `generateLevel` carves from that seed — the field is a seed, | |
| 847 | // not a label put on whatever the match would have made anyway. | |
| 848 | expect(level?.grid.cells).toEqual(generateLevel(seedOf(MAP) as number).grid.cells); | |
| 849 | // The match is still the match: a fresh id seeded the round it was played in. | |
| 850 | expect(stage.ids.length).toBe(1); | |
| 851 | expect(worldOf(stage)?.level.name).toBe(MAP); | |
| 852 | ||
| 853 | // The name wins over the select, because a person who typed one meant it. | |
| 854 | stage.by("quit").fire("click"); | |
| 855 | stage.by("map").value = "generated"; | |
| 856 | stage.by("map").fire("change"); | |
| 857 | expect(stage.by("willplay").textContent).toBe(MAP); | |
| 858 | stage.by("play").fire("click"); | |
| 859 | expect(worldOf(stage)?.level.name).toBe(MAP); | |
| 860 | ||
| 861 | // And junk is de_forge with a line saying so: `gen_0ya` is `gen_ya` written | |
| 862 | // badly, which is a typo and not a map. Never an error, never a refusal. | |
| 863 | stage.by("quit").fire("click"); | |
| 864 | stage.by("mapseed").value = "gen_0ya"; | |
| 865 | stage.by("mapseed").fire("input"); | |
| 866 | expect(stage.by("onmap").textContent).toBe(NOT_A_MAP); | |
| 867 | expect(stage.by("willplay").textContent).toBe("de_forge"); | |
| 868 | stage.by("play").fire("click"); | |
| 869 | expect(worldOf(stage)?.level.name).toBe("de_forge"); | |
| 870 | expect(stage.by("onmap").textContent).toBe(NOT_A_MAP); | |
| 871 | expect(stage.by("menu").hidden).toBe(true); | |
| 872 | }); | |
| 873 | ||
| 874 | test("copy this map is a link first and a clipboard second", async () => { | |
| 875 | const LABEL = "Copy this map's link"; | |
| 876 | const stage = fakeStage(); | |
| 877 | // The label the page renders, so the control can be put back to it. | |
| 878 | stage.by("copymap").textContent = LABEL; | |
| 879 | painted(start(stage)); | |
| 880 | stage.by("mapseed").value = MAP; | |
| 881 | stage.by("mapseed").fire("input"); | |
| 882 | ||
| 883 | // A click with a clipboard behind it copies the whole URL — origin and all, | |
| 884 | // because a link is pasted into a message and not into this page — and the | |
| 885 | // navigation is the one thing it takes back. | |
| 886 | let prevented = 0; | |
| 887 | const click = (): void => stage.by("copymap").fire("click", { preventDefault: () => void (prevented += 1) }); | |
| 888 | click(); | |
| 889 | expect(prevented).toBe(1); | |
| 890 | expect(stage.copied).toEqual([`${ORIGIN}/?map=${MAP}`]); | |
| 891 | await settled(); | |
| 892 | expect(stage.by("copymap").textContent).toBe(COPIED); | |
| 893 | ||
| 894 | // A browser that refuses the write says so rather than pretending. | |
| 895 | stage.refuseCopy = true; | |
| 896 | click(); | |
| 897 | await settled(); | |
| 898 | expect(stage.by("copymap").textContent).toBe(NOT_COPIED); | |
| 899 | ||
| 900 | // Choosing another map puts the label back: it is a control, not a log. | |
| 901 | stage.refuseCopy = false; | |
| 902 | stage.by("map").fire("change"); | |
| 903 | expect(stage.by("copymap").textContent).toBe(LABEL); | |
| 904 | ||
| 905 | // And a browser with no clipboard at all leaves the click alone: nothing is | |
| 906 | // prevented, so the anchor navigates, which is this page on that map. | |
| 907 | stage.host.copy = undefined; | |
| 908 | click(); | |
| 909 | expect(prevented).toBe(2); | |
| 910 | expect(stage.copied.length).toBe(2); | |
| 911 | }); | |
| 912 | ||
| 794 | 913 | /** |
| 795 | 914 | * A match played from one id: the page hands that id out instead of a fresh |
| 796 | 915 | * one, Play is pressed, and `frames` frames are stepped. The id is the only |
src/client/main.ts +143 −7
| @@ -12,6 +12,14 @@ | ||
| 12 | 12 | * map and the round are both drawn from. What is stored is a key for this |
| 13 | 13 | * playing of it, so a rematch is a row of its own. |
| 14 | 14 | * |
| 15 | * A map is its name, the same way: `gen_14u5j6g` is one layout for ever, and | |
| 16 | * the Map name field takes one — typed, or put there by `/?map=gen_14u5j6g` — | |
| 17 | * through `src/mapname.ts`, which is the door the server validated that query | |
| 18 | * with. The menu names the map Play is about to give you *before* Play, which | |
| 19 | * is why the id for a match that wants a new map is drawn when the menu asks | |
| 20 | * rather than when the button is pressed, and the link beside that name is the | |
| 21 | * map by name: press it and you get that layout, not another new one. | |
| 22 | * | |
| 15 | 23 | * A round that did not land is not lost quietly. The result of every round is |
| 16 | 24 | * posted as it is scored, and a post that threw or came back anything but 200 |
| 17 | 25 | * is kept as the one pending round and tried again — on the next round's post |
| @@ -47,6 +55,7 @@ import { | ||
| 47 | 55 | type Button, type Camera, type KeyboardLock, type Listens, type Mesh, |
| 48 | 56 | } from "../engine/index.ts"; |
| 49 | 57 | import { generateLevel, seedFrom } from "./generate.ts"; |
| 58 | import { mapFrom, mapLink, nameOf, seedOf, type MapChoice } from "../mapname.ts"; | |
| 50 | 59 | import { matchFrom, playKey } from "../matchid.ts"; |
| 51 | 60 | import { DE_FORGE } from "./map.ts"; |
| 52 | 61 | import type { Level } from "./level.ts"; |
| @@ -69,6 +78,13 @@ const TICK = 1 / 64; | ||
| 69 | 78 | */ |
| 70 | 79 | export const NOT_AN_ID = "That is not a match id — playing a fresh match instead."; |
| 71 | 80 | |
| 81 | /** And what it says when the Map name field holds something that is not one. */ | |
| 82 | export const NOT_A_MAP = "That is not a map name — playing de_forge instead."; | |
| 83 | ||
| 84 | /** What the copy control says once the URL is on the clipboard, and if it is not. */ | |
| 85 | export const COPIED = "Copied"; | |
| 86 | export const NOT_COPIED = "Copy failed — the link still works"; | |
| 87 | ||
| 72 | 88 | // --- the host: what a page has to be for this frame to run on it ----------- |
| 73 | 89 | |
| 74 | 90 | /** One element on the page, as far as this file is concerned. */ |
| @@ -177,6 +193,17 @@ export type Host = { | ||
| 177 | 193 | /** Null when this browser has no WebGL2: the menu says so and nothing starts. */ |
| 178 | 194 | createRenderer(canvas: Stage): BuildRenderer | null; |
| 179 | 195 | createAudio(): Audio; |
| 196 | /** | |
| 197 | * Where this page is, so a link to a map is a whole URL somebody can paste | |
| 198 | * into a message. `location.origin`; no path, no query, nothing of the id. | |
| 199 | */ | |
| 200 | origin: string; | |
| 201 | /** | |
| 202 | * `navigator.clipboard.writeText`, when this browser has one and the page is | |
| 203 | * allowed it. Absent is a browser where the copy control is only a link, and | |
| 204 | * a link is what it already was: the click is left to the browser. | |
| 205 | */ | |
| 206 | copy?(text: string): Promise<void>; | |
| 180 | 207 | }; |
| 181 | 208 | |
| 182 | 209 | /** |
| @@ -225,10 +252,20 @@ export function boot(host: Host): Frame { | ||
| 225 | 252 | // the field stops being junk rather than staying there as a lie. |
| 226 | 253 | const aboutId = onId.textContent; |
| 227 | 254 | const playingId = by("playing"); |
| 228 | const mapName = by("mapname"); | |
| 255 | const mapName = by<Toggle>("mapname"); | |
| 229 | 256 | const share = by<Toggle>("share"); |
| 230 | 257 | const pauseId = by("pauseid"); |
| 231 | 258 | const onMap = by("onmap"); |
| 259 | // What that line said before anything was typed, kept for the same reason | |
| 260 | // `aboutId` is: a warning about a map name has to be takeable back off. | |
| 261 | const aboutMap = onMap.textContent; | |
| 262 | // The Map control's other half: one exact map, by name. It wins over the | |
| 263 | // select, because a person who typed a name meant that map. | |
| 264 | const seedField = by<Field>("mapseed"); | |
| 265 | // The name of the map Play is about to give you, and the link that keeps it. | |
| 266 | const willPlay = by("willplay"); | |
| 267 | const copyMap = by<Toggle>("copymap"); | |
| 268 | const copyLabel = copyMap.textContent; | |
| 232 | 269 | const prep = by("prep"); |
| 233 | 270 | const play = by<Panel & { disabled: boolean }>("play"); |
| 234 | 271 | const resumeButton = by("resume"); |
| @@ -326,6 +363,16 @@ export function boot(host: Host): Frame { | ||
| 326 | 363 | // string until somebody plays a match twice: see `start` below. |
| 327 | 364 | let matchId = ""; |
| 328 | 365 | let playId = ""; |
| 366 | /** | |
| 367 | * The id the next match will use if nobody pastes one, drawn the moment | |
| 368 | * something needs to *name* the map that id would carve — the menu asking | |
| 369 | * what Play is about to give you, or Play itself — and never before. A page | |
| 370 | * nobody has touched hands out no ids, and a map the menu named is the map | |
| 371 | * that is played: the name before Play is not a guess. | |
| 372 | */ | |
| 373 | let drawn: string | null = null; | |
| 374 | /** The URL the copy control is holding, which is the one it puts on the clipboard. */ | |
| 375 | let keeping = mapLink("de_forge"); | |
| 329 | 376 | // The map the renderer is holding the geometry of. It starts as the one the |
| 330 | 377 | // meshes were built from, and a match on another map swaps that one mesh. |
| 331 | 378 | let shown: Level = DE_FORGE; |
| @@ -528,6 +575,75 @@ export function boot(host: Host): Frame { | ||
| 528 | 575 | send(win.performance.now()); |
| 529 | 576 | } |
| 530 | 577 | |
| 578 | // --- which map, and what it is called ------------------------------------- | |
| 579 | ||
| 580 | /** | |
| 581 | * What the Map control is asking for, through the same door `src/server.ts` | |
| 582 | * validated `?map=` with. Two fields, one value: a name in the Map name field | |
| 583 | * is one exact map and wins over the select, because a person who typed a name | |
| 584 | * meant that map; an empty field leaves the select's two words; and a name | |
| 585 | * that is not one is de_forge, like an unknown anything here. | |
| 586 | */ | |
| 587 | const chosenMap = (): MapChoice => { | |
| 588 | const typed = seedField.value.trim(); | |
| 589 | if (typed !== "") return mapFrom(typed); | |
| 590 | return mapField.value === "generated" ? "generated" : "de_forge"; | |
| 591 | }; | |
| 592 | ||
| 593 | /** Whether the Map name field holds something that is not a map name. */ | |
| 594 | const badName = (): boolean => { | |
| 595 | const typed = seedField.value.trim(); | |
| 596 | return typed !== "" && seedOf(typed) === null; | |
| 597 | }; | |
| 598 | ||
| 599 | /** The match the next Play will be, which is what "generated" draws its map from. */ | |
| 600 | const nextMatch = (): string => matchFrom(idField.value.trim()) ?? (drawn ??= host.randomUUID()); | |
| 601 | ||
| 602 | /** The level a choice names. `generated` is this match's own id and nothing else. */ | |
| 603 | const levelOfChoice = (choice: MapChoice, seed: number): Level => | |
| 604 | choice === "de_forge" ? DE_FORGE : generateLevel(choice === "generated" ? seed : seedOf(choice) ?? seed); | |
| 605 | ||
| 606 | /** | |
| 607 | * The map Play is about to give you, named and linked, before Play — which is | |
| 608 | * the whole of #148: a seeded map whose seed you cannot read is a slot | |
| 609 | * machine. For `generated` the name is the name of the map this match's id | |
| 610 | * carves, so the id is drawn now rather than at Play and the link is that | |
| 611 | * map by name — a link that plays *this* layout, not another new one. | |
| 612 | * | |
| 613 | * Called whenever either field changes and whenever the menu comes back, and | |
| 614 | * never while a match runs: the section under the stage says what is being | |
| 615 | * played, and this line is about what pressing Play would do. | |
| 616 | */ | |
| 617 | function nameNextMap(): void { | |
| 618 | const choice = chosenMap(); | |
| 619 | const name = choice === "generated" ? nameOf(seedFrom(nextMatch())) : choice; | |
| 620 | keeping = mapLink(name); | |
| 621 | willPlay.textContent = name; | |
| 622 | copyMap.setAttribute("href", keeping); | |
| 623 | copyMap.textContent = copyLabel; | |
| 624 | onMap.textContent = badName() ? NOT_A_MAP : aboutMap; | |
| 625 | } | |
| 626 | ||
| 627 | for (const field of [mapField, seedField, idField]) | |
| 628 | for (const event of ["change", "input"]) field.addEventListener(event, nameNextMap); | |
| 629 | ||
| 630 | /** | |
| 631 | * The copy control, which is an anchor first and a clipboard second: a browser | |
| 632 | * with no clipboard — or one that refuses the write — follows the link, which | |
| 633 | * opens the page on that map and is the thing the URL says it is. A keyboard | |
| 634 | * reaches it and a thumb can hit it because it is a link and not a script. | |
| 635 | */ | |
| 636 | copyMap.addEventListener<{ preventDefault(): void }>("click", (event) => { | |
| 637 | if (host.copy === undefined) return; | |
| 638 | event.preventDefault(); | |
| 639 | void host.copy(host.origin + keeping).then( | |
| 640 | () => void (copyMap.textContent = COPIED), | |
| 641 | () => void (copyMap.textContent = NOT_COPIED), | |
| 642 | ); | |
| 643 | }); | |
| 644 | ||
| 645 | nameNextMap(); | |
| 646 | ||
| 531 | 647 | function start(): void { |
| 532 | 648 | // The button is disabled until the atlas is painted; a click that arrives |
| 533 | 649 | // anyway — a keyboard, a script, a browser being generous — is not a match. |
| @@ -553,7 +669,11 @@ export function boot(host: Host): Frame { | ||
| 553 | 669 | const typed = idField.value.trim(); |
| 554 | 670 | const wanted = matchFrom(typed); |
| 555 | 671 | onId.textContent = typed !== "" && wanted === null ? NOT_AN_ID : aboutId; |
| 556 | const fresh = host.randomUUID(); | |
| 672 | // The id the menu already named a map for, if it drew one, so the map that | |
| 673 | // was promised is the map that is played. A fresh one otherwise, and a | |
| 674 | // fresh one for the nonce on the row key when an id was pasted. | |
| 675 | const fresh = drawn ?? host.randomUUID(); | |
| 676 | drawn = null; | |
| 557 | 677 | matchId = wanted ?? fresh; |
| 558 | 678 | playId = wanted === null ? fresh : playKey(wanted, fresh); |
| 559 | 679 | /** |
| @@ -566,14 +686,17 @@ export function boot(host: Host): Frame { | ||
| 566 | 686 | * different things. |
| 567 | 687 | */ |
| 568 | 688 | const seed = seedFrom(matchId); |
| 569 | // A map a person chose: de_forge, or one carved out of this match's id. An | |
| 570 | // unknown value in the field is de_forge, like an unknown anything here. | |
| 571 | const level = mapField.value === "generated" ? generateLevel(seed) : DE_FORGE; | |
| 689 | // A map a person chose: de_forge, one carved out of this match's id, or one | |
| 690 | // they named — which is the same carving from the seed that name means, so | |
| 691 | // it is that layout on every machine for ever. An unknown value in either | |
| 692 | // field is de_forge, like an unknown anything here. | |
| 693 | const choice = chosenMap(); | |
| 694 | const level = levelOfChoice(choice, seed); | |
| 572 | 695 | if (level !== shown) { |
| 573 | 696 | renderer.replace("level", meshFor("level", level)); |
| 574 | 697 | shown = level; |
| 575 | 698 | } |
| 576 | onMap.textContent = `Playing ${level.name}.`; | |
| 699 | onMap.textContent = badName() ? NOT_A_MAP : `Playing ${level.name}.`; | |
| 577 | 700 | // What is being played, where a person can copy it: the id and the map in |
| 578 | 701 | // the section under the stage, the link that plays them again beside them, |
| 579 | 702 | // and the same sentence in the pause panel for a full screen. `?map=` is |
| @@ -581,8 +704,11 @@ export function boot(host: Host): Frame { | ||
| 581 | 704 | // match asked for one. |
| 582 | 705 | playingId.textContent = matchId; |
| 583 | 706 | mapName.textContent = level.name; |
| 707 | // The played map's own name is a link to that map and nothing else of the | |
| 708 | // match: a layout you liked, handed to somebody who never played the round. | |
| 709 | mapName.setAttribute("href", mapLink(mapFrom(level.name))); | |
| 584 | 710 | pauseId.textContent = `Match ${matchId} on ${level.name}.`; |
| 585 | share.setAttribute("href", `/?match=${matchId}&map=${level === DE_FORGE ? "de_forge" : "generated"}`); | |
| 711 | share.setAttribute("href", `/?match=${matchId}&map=${choice}`); | |
| 586 | 712 | world = createWorld(level, rules, name, team, 5, seed); |
| 587 | 713 | posted = -1; |
| 588 | 714 | loop.reset(); |
| @@ -611,6 +737,10 @@ export function boot(host: Host): Frame { | ||
| 611 | 737 | show(pause, false); |
| 612 | 738 | show(menu, true); |
| 613 | 739 | show(touchPanel, false); |
| 740 | // The menu is back, so the line before Play is about the next match again — | |
| 741 | // which on `generated` is a map nobody has drawn yet. The one just played | |
| 742 | // is still named under the stage, and its name is still a link. | |
| 743 | nameNextMap(); | |
| 614 | 744 | }); |
| 615 | 745 | const saved = host.storage.getItem("cs.name"); |
| 616 | 746 | if (saved !== null && nameField.value === "") nameField.value = saved; |
| @@ -685,6 +815,7 @@ export function boot(host: Host): Frame { | ||
| 685 | 815 | running = false; |
| 686 | 816 | doc.exitPointerLock(); |
| 687 | 817 | show(menu, true); |
| 818 | nameNextMap(); | |
| 688 | 819 | play.focus(); |
| 689 | 820 | } |
| 690 | 821 | |
| @@ -751,6 +882,11 @@ const browser = (): Host => ({ | ||
| 751 | 882 | storage: localStorage, |
| 752 | 883 | fetch: (url, init) => fetch(url, init), |
| 753 | 884 | randomUUID: () => crypto.randomUUID(), |
| 885 | origin: location.origin, | |
| 886 | // The clipboard, if this browser has one. Read off `navigator` rather than | |
| 887 | // asked for: a browser without it has no such member, and the copy control | |
| 888 | // stays the plain link it is rendered as. | |
| 889 | copy: navigator.clipboard === undefined ? undefined : (text) => navigator.clipboard.writeText(text), | |
| 754 | 890 | createRenderer: (canvas) => { |
| 755 | 891 | const surface = canvas as unknown as HTMLCanvasElement; |
| 756 | 892 | if (surface.getContext("webgl2") === null) return null; |
src/client/stage.test-helper.ts +25 −3
| @@ -94,6 +94,9 @@ export function glRecorder(): GlRecorder { | ||
| 94 | 94 | /** One of this page's canvases: it always has a 2D recorder, and its GL one is this game's. */ |
| 95 | 95 | export type StageCanvas = FakeCanvas & { paint: FakePaint; gl: GlRecorder | null }; |
| 96 | 96 | |
| 97 | /** Where this stub page is, for the whole URL a copied link has to be. */ | |
| 98 | export const ORIGIN = "https://cs.example"; | |
| 99 | ||
| 97 | 100 | /** One POST the frame made, as it was made. */ |
| 98 | 101 | export type Post = { url: string; method: string; headers: Record<string, string>; body: string }; |
| 99 | 102 | |
| @@ -139,6 +142,10 @@ export type FakeStage = { | ||
| 139 | 142 | store: Map<string, string>; |
| 140 | 143 | /** The match ids handed out, newest last. */ |
| 141 | 144 | ids: string[]; |
| 145 | /** Every text the page put on the clipboard, in order. */ | |
| 146 | copied: string[]; | |
| 147 | /** Set before a click to have the clipboard refuse the write, as a browser may. */ | |
| 148 | refuseCopy: boolean; | |
| 142 | 149 | /** |
| 143 | 150 | * Install this page as the globals the shipped entry reads for itself — |
| 144 | 151 | * `document`, `window`, `localStorage` and `fetch` — and hand back the undo |
| @@ -155,7 +162,7 @@ export type FakeStage = { | ||
| 155 | 162 | * contract between the page and this file, so `src/server.test.ts` checks the |
| 156 | 163 | * served page against this same list rather than a second copy of it. |
| 157 | 164 | */ |
| 158 | export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "matchid", "onid", "onmap", "play", "prep", "resume", "quit", "esc", "pauseid", "playing", "mapname", "share", "fps", "full"]; | |
| 165 | export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "willplay", "copymap", "play", "prep", "resume", "quit", "esc", "pauseid", "playing", "mapname", "share", "fps", "full"]; | |
| 159 | 166 | |
| 160 | 167 | /** The ones the server renders with `hidden` on them: the page starts at the menu. */ |
| 161 | 168 | const HIDDEN = ["pause", "buy", "touch", "full"]; |
| @@ -168,8 +175,13 @@ export const PAGE_ACTIONS = ["duck", "jump", "use", "reload", "buy", "fire"]; | ||
| 168 | 175 | * `install()` puts in front of the shipped entry. Nothing else is touched: |
| 169 | 176 | * `crypto.randomUUID` is real, and no `AudioContext` is installed, which is a |
| 170 | 177 | * browser that makes no sound rather than one that throws. |
| 178 | * | |
| 179 | * `location` is here because a link to a map is a whole URL and the entry asks | |
| 180 | * where it is; `navigator` is not, because the runtime has one already and it | |
| 181 | * has neither a keyboard lock nor a clipboard — which is a browser without | |
| 182 | * either, and both of those are a path the game has to work on. | |
| 171 | 183 | */ |
| 172 | const GLOBALS = ["document", "window", "localStorage", "fetch"]; | |
| 184 | const GLOBALS = ["document", "window", "localStorage", "fetch", "location"]; | |
| 173 | 185 | |
| 174 | 186 | /** |
| 175 | 187 | * The whole page the shooter's entry expects, plus the host around it. Pass |
| @@ -205,6 +217,7 @@ export function fakeStage( | ||
| 205 | 217 | const sounds: string[] = []; |
| 206 | 218 | const store = new Map<string, string>(); |
| 207 | 219 | const ids: string[] = []; |
| 220 | const copied: string[] = []; | |
| 208 | 221 | let uuid = 0; |
| 209 | 222 | |
| 210 | 223 | const audio: Audio = { |
| @@ -256,6 +269,13 @@ export function fakeStage( | ||
| 256 | 269 | return renderer; |
| 257 | 270 | }, |
| 258 | 271 | createAudio: () => audio, |
| 272 | origin: ORIGIN, | |
| 273 | // A clipboard that writes down what it was handed, and refuses once a | |
| 274 | // test says so: both halves of the copy control are behaviour. | |
| 275 | copy: (text) => { | |
| 276 | copied.push(text); | |
| 277 | return stub.refuseCopy ? Promise.reject(new Error("denied")) : Promise.resolve(); | |
| 278 | }, | |
| 259 | 279 | }, |
| 260 | 280 | document: page.document, |
| 261 | 281 | window: page.window, |
| @@ -264,7 +284,8 @@ export function fakeStage( | ||
| 264 | 284 | hud, |
| 265 | 285 | buttons: page.buttons, |
| 266 | 286 | frames: page.frames, |
| 267 | posts, draws, replaced, atlases, sounds, store, ids, | |
| 287 | posts, draws, replaced, atlases, sounds, store, ids, copied, | |
| 288 | refuseCopy: false, | |
| 268 | 289 | by: page.by, |
| 269 | 290 | step: page.step, |
| 270 | 291 | lock(on) { |
| @@ -291,6 +312,7 @@ export function fakeStage( | ||
| 291 | 312 | globals.document = page.document; |
| 292 | 313 | globals.window = page.window; |
| 293 | 314 | globals.localStorage = stub.host.storage; |
| 315 | globals.location = { origin: ORIGIN }; | |
| 294 | 316 | globals.fetch = (url: string, init: Parameters<Host["fetch"]>[1]) => stub.host.fetch(url, init); |
| 295 | 317 | return () => { |
| 296 | 318 | for (const { key, there, value } of was) |
src/games.ts +16 −1
| @@ -315,6 +315,21 @@ export type Game = { | ||
| 315 | 315 | * thinnest headroom on this page, and the next thing the shooter gains will want |
| 316 | 316 | * it raised. Neither other bundle moved a byte: nothing outside `src/client/` |
| 317 | 317 | * changed. Measured 2026-09-21. |
| 318 | * #148 gave a generated map a name you can keep — `src/mapname.ts`, the one door | |
| 319 | * that says what a name is and what seed it means, plus the field the page | |
| 320 | * validates with it, the line that names the map before Play and the link that | |
| 321 | * copies it — and the app grew 1,358 raw / 531 gzipped bytes for it: 95,230 → | |
| 322 | * 96,588 raw and 43,006 → 43,537 gzipped. That is 588 bytes past the raw half of | |
| 323 | * the 96,000 / 43,500 #185 set and 37 past the gzipped half, which is the raise | |
| 324 | * #135 said the next feature would want: the ceiling is 100,000 / 45,000 here, | |
| 325 | * today's size plus about three per cent of each, on the same reasoning #164 and | |
| 326 | * #185 raised the last two. The name module ships in the bundle for the reason | |
| 327 | * `src/matchid.ts` does — the page validates what a person typed with the same | |
| 328 | * grammar the server accepts a query with, and a second copy of that grammar is | |
| 329 | * how two of them drift apart — and most of the rest is sentences a minifier | |
| 330 | * cannot shorten. Neither other bundle moved a byte: `?map=` is the shooter's | |
| 331 | * parameter and the other two games neither read it nor import any of this. | |
| 332 | * Measured 2026-09-21. | |
| 318 | 333 | */ |
| 319 | 334 | export const GAMES: readonly Game[] = [ |
| 320 | 335 | { |
| @@ -322,7 +337,7 @@ export const GAMES: readonly Game[] = [ | ||
| 322 | 337 | href: "/", |
| 323 | 338 | label: "cs", |
| 324 | 339 | entry: "src/client/main.ts", |
| 325 | ceiling: { raw: 96_000, gzip: 43_500 }, | |
| 340 | ceiling: { raw: 100_000, gzip: 45_000 }, | |
| 326 | 341 | blurb: |
| 327 | 342 | "a defusal shooter in the browser, with bots that learned to aim, bomb rounds, an economy, maps carved from a seed and the leaderboard this server keeps.", |
| 328 | 343 | }, |
src/mapname.test.ts added +47
| @@ -0,0 +1,47 @@ | ||
| 1 | /** | |
| 2 | * The one door a map name goes through, held to the round trip it promises: one | |
| 3 | * canonical name per map, one map per name, and everything else refused. It is | |
| 4 | * the same shape of test `matchid.test.ts` is, for the same reason — this is | |
| 5 | * what the server validates a query with and what the page validates a field | |
| 6 | * with, and a hole here is a hole in both. | |
| 7 | */ | |
| 8 | ||
| 9 | import { expect, test } from "bun:test"; | |
| 10 | import { mapFrom, mapLink, nameOf, seedOf } from "./mapname.ts"; | |
| 11 | ||
| 12 | test("a name is a seed and a seed is a name, both ways round", () => { | |
| 13 | expect(nameOf(0)).toBe("gen_0"); | |
| 14 | expect(nameOf(1234)).toBe("gen_ya"); | |
| 15 | expect(nameOf(0xffffffff)).toBe("gen_1z141z3"); | |
| 16 | for (const seed of [0, 1, 7, 1234, 0x0fffffff, 0xffffffff, 2166136261]) { | |
| 17 | expect(seedOf(nameOf(seed))).toBe(seed); | |
| 18 | // And the seed a name means is the seed that names it: no second name for | |
| 19 | // one map, which is what makes the name worth writing down. | |
| 20 | expect(nameOf(seedOf(nameOf(seed)) as number)).toBe(nameOf(seed)); | |
| 21 | } | |
| 22 | // A seed is thirty-two bits however it arrives, so the name is the same name. | |
| 23 | expect(nameOf(-1)).toBe(nameOf(0xffffffff)); | |
| 24 | }); | |
| 25 | ||
| 26 | test("what is not a name is not a name: a typo, a shout, a padded number, a script", () => { | |
| 27 | for (const junk of [ | |
| 28 | "", "gen_", "gen", "de_forge", "generated", "gen_14u5j6g!", "gen 14", "GEN_ya", "gen_YA", | |
| 29 | // `parseInt` would take every one of these; the round trip does not. | |
| 30 | "gen_0ya", "gen_ya ", " gen_ya", "gen_-1", "gen_1.5", "gen_zzzzzzzz", "gen_1z141z4", | |
| 31 | "gen_<script>", "../../etc/passwd", "gen_%00", "gen_ya#frag", | |
| 32 | null, undefined, 7, {}, ["gen_ya"], | |
| 33 | ]) expect(seedOf(junk)).toBeNull(); | |
| 34 | // `gen_1z141z3` is the last name there is; one past it is nobody's map. | |
| 35 | expect(seedOf("gen_1z141z3")).toBe(0xffffffff); | |
| 36 | }); | |
| 37 | ||
| 38 | test("the Map control's value is one of three things, and anything else is de_forge", () => { | |
| 39 | expect(mapFrom("generated")).toBe("generated"); | |
| 40 | expect(mapFrom("de_forge")).toBe("de_forge"); | |
| 41 | expect(mapFrom("gen_ya")).toBe("gen_ya"); | |
| 42 | for (const junk of [null, undefined, "", "de_moon", "GENERATED", "gen_0ya", "<script>", 7]) | |
| 43 | expect(mapFrom(junk)).toBe("de_forge"); | |
| 44 | // What comes back out is this module's own string, never the caller's bytes. | |
| 45 | expect(mapLink(mapFrom("gen_ya"))).toBe("/?map=gen_ya"); | |
| 46 | expect(mapLink(mapFrom("generated"))).toBe("/?map=generated"); | |
| 47 | }); |
src/mapname.ts added +69
| @@ -0,0 +1,69 @@ | ||
| 1 | /** | |
| 2 | * What a map is called, in one place: the name a generated map answers to, the | |
| 3 | * seed that name means, and the three things the Map control can be on. | |
| 4 | * | |
| 5 | * A generated map was unreachable as long as its seed was. `generateLevel` | |
| 6 | * carves a layout out of a number and `nameOf` has always given that number a | |
| 7 | * short name — `gen_14u5j6g`, base36 — but a name you cannot type back in is a | |
| 8 | * receipt for a map that no longer exists. `seedOf` is the way back, and it is | |
| 9 | * the boundary: a name is something a person types into a field and something a | |
| 10 | * link carries in `?map=`, so the server validates it here and the page | |
| 11 | * validates it here again, and neither trusts the other. | |
| 12 | * | |
| 13 | * The grammar is exactly `gen_` and base36, and the test it is held to is not | |
| 14 | * the regular expression but the round trip: a name is a name when `nameOf` of | |
| 15 | * the seed it parses to is that name again. So `gen_0014` is not one (it is | |
| 16 | * `gen_14` written badly), `gen_ZZZ` is not one (upper case is not what a seed | |
| 17 | * prints as), and `gen_zzzzzzzz` is not one (no thirty-two-bit seed reaches it). | |
| 18 | * One canonical name per map, and one map per name, which is what makes the | |
| 19 | * name worth keeping. | |
| 20 | * | |
| 21 | * It imports nothing and ships in the bundle, for the same reason | |
| 22 | * `src/matchid.ts` does: a second copy of this grammar in the page is how the | |
| 23 | * two drift apart. | |
| 24 | */ | |
| 25 | ||
| 26 | /** | |
| 27 | * The name a seed's map answers to: `gen_1z3f`, short enough for a menu. The | |
| 28 | * return type is the name's own shape, so `MapChoice` below needs no cast. | |
| 29 | */ | |
| 30 | export const nameOf = (seed: number): `gen_${string}` => `gen_${(seed >>> 0).toString(36)}`; | |
| 31 | ||
| 32 | /** | |
| 33 | * `gen_` and base36. Seven digits is what `0xffffffff` prints as (`1z141z3`), | |
| 34 | * so nothing longer can be a thirty-two-bit seed and nothing longer is parsed. | |
| 35 | */ | |
| 36 | const SHAPE = /^gen_[0-9a-z]{1,7}$/; | |
| 37 | ||
| 38 | /** | |
| 39 | * The seed a map name means, or null when it is not one. The round trip is the | |
| 40 | * check: a name that parses to a seed which prints as some other name is not a | |
| 41 | * name, it is a typo, and a typo is not a map. | |
| 42 | */ | |
| 43 | export function seedOf(value: unknown): number | null { | |
| 44 | if (typeof value !== "string" || !SHAPE.test(value)) return null; | |
| 45 | const seed = Number.parseInt(value.slice(4), 36) >>> 0; | |
| 46 | return nameOf(seed) === value ? seed : null; | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * What the Map control is on: de_forge, a new map every match, or one map by | |
| 51 | * name. It is one string, because `?map=` is one parameter — the link and the | |
| 52 | * control say the same thing in the same word. | |
| 53 | */ | |
| 54 | export type MapChoice = "de_forge" | "generated" | `gen_${string}`; | |
| 55 | ||
| 56 | /** | |
| 57 | * The map a link or a field is asking for. `generated` is itself, a name is | |
| 58 | * its canonical self, and everything else — a typo, a missing parameter, a | |
| 59 | * script tag, a name nobody could have meant — is `de_forge`, which is what an | |
| 60 | * unknown value has always been here. | |
| 61 | */ | |
| 62 | export function mapFrom(value: unknown): MapChoice { | |
| 63 | if (value === "generated") return "generated"; | |
| 64 | const seed = seedOf(value); | |
| 65 | return seed === null ? "de_forge" : nameOf(seed); | |
| 66 | } | |
| 67 | ||
| 68 | /** The link that plays this map: the one URL a person copies and hands over. */ | |
| 69 | export const mapLink = (map: MapChoice): string => `/?map=${map}`; |
src/pages/game.test.ts +25 −4
| @@ -19,7 +19,7 @@ import { openDatabase, type Player, saveMatch } from "../db.ts"; | ||
| 19 | 19 | import type { Result } from "../result.ts"; |
| 20 | 20 | import { CARD, type Card } from "../client/weights.ts"; |
| 21 | 21 | import { GAMES } from "../games.ts"; |
| 22 | import { gamePage, policyHtml, shareHref, BAD_ID, type PageOptions } from "./game.ts"; | |
| 22 | import { gamePage, policyHtml, shareHref, BAD_ID, BAD_MAP, type PageOptions } from "./game.ts"; | |
| 23 | 23 | import { escape } from "./shell.ts"; |
| 24 | 24 | |
| 25 | 25 | const db = (): ReturnType<typeof openDatabase> => openDatabase(mkdtempSync(join(tmpdir(), "game-"))); |
| @@ -77,13 +77,34 @@ test("the leaderboard and the menu are in the first paint, not fetched", () => { | ||
| 77 | 77 | |
| 78 | 78 | test("a link that asked for a match is what the page opens with, and junk is a sentence", () => { |
| 79 | 79 | const id = "9f8a7b6c5d4e3f21"; |
| 80 | const html = gamePage(db(), { ...options, asked: { match: id, badMatch: false, map: "generated" } }); | |
| 80 | const asked = { match: id, badMatch: false, map: "generated", badMap: false } as const; | |
| 81 | const html = gamePage(db(), { ...options, asked }); | |
| 81 | 82 | expect(html).toContain(`<code id="playing">${id}</code>`); |
| 82 | expect(html).toContain(`href="${escape(shareHref({ match: id, badMatch: false, map: "generated" }))}"`); | |
| 83 | expect(gamePage(db(), { ...options, asked: { match: null, badMatch: true, map: "de_forge" } })) | |
| 83 | expect(html).toContain(`href="${escape(shareHref(asked))}"`); | |
| 84 | expect(gamePage(db(), { ...options, asked: { match: null, badMatch: true, map: "de_forge", badMap: false } })) | |
| 84 | 85 | .toContain(escape(BAD_ID)); |
| 85 | 86 | }); |
| 86 | 87 | |
| 88 | test("a link that asked for one map by name opens on it, before Play and as a link", () => { | |
| 89 | // The name is the whole of what that link carried: the field holds it, the | |
| 90 | // line above Play names it, and the control beside the name is that map's | |
| 91 | // own URL — a match id is not needed for any of it. | |
| 92 | const name = "gen_14u5j6g"; | |
| 93 | const html = gamePage(db(), { ...options, asked: { match: null, badMatch: false, map: name, badMap: false } }); | |
| 94 | expect(html).toContain(`id="mapseed" maxlength="12" placeholder="gen_14u5j6g"`); | |
| 95 | expect(html).toContain(`value="${name}"`); | |
| 96 | expect(html).toContain(`<b id="willplay">${name}</b>`); | |
| 97 | expect(html).toContain(`id="copymap" href="/?map=${name}"`); | |
| 98 | expect(html).toContain(`<code id="playing">none yet</code>`); | |
| 99 | expect(html).not.toContain(escape(BAD_MAP)); | |
| 100 | // And a `?map=` that was not a map is de_forge with a sentence, the way a | |
| 101 | // `?match=` that was not an id is a fresh match with one. | |
| 102 | const bad = gamePage(db(), { ...options, asked: { match: null, badMatch: false, map: "de_forge", badMap: true } }); | |
| 103 | expect(bad).toContain(escape(BAD_MAP)); | |
| 104 | expect(bad).toContain(`<b id="willplay">de_forge</b>`); | |
| 105 | expect(bad).not.toMatch(/id="mapseed"[^>]*value=/); | |
| 106 | }); | |
| 107 | ||
| 87 | 108 | test("the boast about the bots is the card, formatted the way the page formats numbers", () => { |
| 88 | 109 | const said = boast(gamePage(db(), options)); |
| 89 | 110 | expect(said).toContain(`trained with PPO against ${CARD.envSteps.toLocaleString("en")} ticks of this same simulation`); |
src/pages/game.ts +52 −12
| @@ -13,6 +13,7 @@ import type { Database } from "bun:sqlite"; | ||
| 13 | 13 | import { displayName, headshotShare, ratio, recentMatches, standings } from "../db.ts"; |
| 14 | 14 | import { NOTHING_POSTED } from "../client/pending.ts"; |
| 15 | 15 | import { CARD, type Card } from "../client/weights.ts"; |
| 16 | import { mapLink, type MapChoice } from "../mapname.ts"; | |
| 16 | 17 | import type { Who } from "../who.ts"; |
| 17 | 18 | import { navFor, othersHtml } from "./games.ts"; |
| 18 | 19 | import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts"; |
| @@ -48,17 +49,24 @@ ${table.length === 0 | ||
| 48 | 49 | * What the link a person followed asked for, validated in `src/server.ts` and |
| 49 | 50 | * escaped here. `match` is a match id or null — never the raw parameter — and |
| 50 | 51 | * `badMatch` says a parameter was there and was not one, which is a sentence |
| 51 | * in the menu and a fresh match, not an error page. | |
| 52 | * in the menu and a fresh match, not an error page. `map` and `badMap` are the | |
| 53 | * same two things for `?map=`, through `src/mapname.ts`. | |
| 52 | 54 | */ |
| 53 | 55 | export type Asked = { |
| 54 | 56 | match: string | null; |
| 55 | 57 | badMatch: boolean; |
| 56 | /** Which of the Map control's own values the link picked. */ | |
| 57 | map: "de_forge" | "generated"; | |
| 58 | /** Which map the link picked: the Map control's two words, or a map by name. */ | |
| 59 | map: MapChoice; | |
| 60 | /** A `?map=` was there and was not a map, which is de_forge and a sentence. */ | |
| 61 | badMap: boolean; | |
| 58 | 62 | }; |
| 59 | 63 | |
| 60 | 64 | /** A link with nothing on it: no id asked for, no map asked for, a fresh match. */ |
| 61 | export const NOTHING_ASKED: Asked = { match: null, badMatch: false, map: "de_forge" }; | |
| 65 | export const NOTHING_ASKED: Asked = { match: null, badMatch: false, map: "de_forge", badMap: false }; | |
| 66 | ||
| 67 | /** The name in `?map=`, or null when the link asked for one of the two words. */ | |
| 68 | export const namedMap = (map: MapChoice): string | null => | |
| 69 | map === "de_forge" || map === "generated" ? null : map; | |
| 62 | 70 | |
| 63 | 71 | /** What the shell needs that this page cannot know: the environment it runs in. */ |
| 64 | 72 | export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh"> & { |
| @@ -87,11 +95,23 @@ a.share { display:inline-flex; align-items:center; min-height:44px; padding:0 .7 | ||
| 87 | 95 | background:#1b2230; border:1px solid var(--line); color:var(--ct); text-decoration:none; } |
| 88 | 96 | a.share:hover, a.share:focus-visible { background:#27303f; text-decoration:underline; } |
| 89 | 97 | #thismatch p { margin:.35rem 0; } |
| 98 | /* The name of the map Play is about to give you, and the link that keeps it. | |
| 99 | It wraps rather than scrolls: at 360px the name is one line and the link is | |
| 100 | the next, and both are still 44px to press. */ | |
| 101 | #willmap { display:flex; flex-wrap:wrap; gap:.5rem; align-items:center; justify-content:center; | |
| 102 | margin:.35rem 0; } | |
| 103 | #willplay { user-select:all; } | |
| 90 | 104 | `; |
| 91 | 105 | |
| 92 | 106 | /** What the menu's hint under the id field says when the link carried junk. */ |
| 93 | 107 | export const BAD_ID = "That link's match id is not one — this is a fresh match instead."; |
| 94 | 108 | |
| 109 | /** And the same for `?map=`: a name that is not one is de_forge, never an error. */ | |
| 110 | export const BAD_MAP = "That link's map name is not one — this is de_forge instead."; | |
| 111 | ||
| 112 | /** What the line before Play says when the map is one a match has yet to draw. */ | |
| 113 | export const UNDRAWN = "a new one every match"; | |
| 114 | ||
| 95 | 115 | /** The link that plays this match again: the id it names and the map it was on. */ |
| 96 | 116 | export const shareHref = (asked: Asked): string => |
| 97 | 117 | asked.match === null ? "/" : `/?match=${asked.match}&map=${asked.map}`; |
| @@ -113,12 +133,19 @@ const SIGN_IN = "/__forge/signin?to=%2F"; | ||
| 113 | 133 | * hides that line and enables the button on the frame the last layer lands. |
| 114 | 134 | * Rendered that way rather than switched on by the script so the first paint is |
| 115 | 135 | * already true and nothing moves under the pointer afterwards. |
| 136 | * | |
| 137 | * The Map control is three things in two fields: the select's two words, and a | |
| 138 | * name in `#mapseed`, which is one exact map and wins over the select. The line | |
| 139 | * before Play names the map Play is about to give you and carries the link that | |
| 140 | * hands it to anyone — rendered from `?map=` here, and rewritten by | |
| 141 | * `src/client/main.ts` whenever either field changes, because the name of "a new | |
| 142 | * one every match" is the name of an id the browser has yet to draw. | |
| 116 | 143 | */ |
| 117 | 144 | const menu = (visitor: Who, asked: Asked): string => ` |
| 118 | 145 | <h2>Defuse or detonate</h2> |
| 119 | <p>Five against five on <b>de_forge</b>, or on a map generated for the match. Bomb rounds, | |
| 120 | CS movement and economy, bots that push the sites, and a radar in the corner so a map | |
| 121 | nobody has ever walked is still a place you can find your way around.</p> | |
| 146 | <p>Five against five on <b>de_forge</b>, on a map generated for the match, or on any generated | |
| 147 | map you can name. Bomb rounds, CS movement and economy, bots that push the sites, and a radar | |
| 148 | in the corner so a map nobody has ever walked is still a place you can find your way around.</p> | |
| 122 | 149 | <p class="hint">${visitor.role === "anon" |
| 123 | 150 | ? `You are playing as a guest, so the leaderboard lists your score as <b>a guest's</b>. |
| 124 | 151 | <a href="${SIGN_IN}" rel="noreferrer">Sign in to the forge</a> to play under your own name.` |
| @@ -137,15 +164,24 @@ const menu = (visitor: Who, asked: Asked): string => ` | ||
| 137 | 164 | <div class="row"> |
| 138 | 165 | <label>Match id<input id="matchid" maxlength="64" placeholder="paste one to play it again" |
| 139 | 166 | autocomplete="off" spellcheck="false"${asked.match === null ? "" : ` value="${escape(asked.match)}"`}></label> |
| 167 | <label>Map name<input id="mapseed" maxlength="12" placeholder="gen_14u5j6g" | |
| 168 | autocomplete="off" spellcheck="false"${ | |
| 169 | namedMap(asked.map) === null ? "" : ` value="${escape(asked.map)}"` | |
| 170 | }></label> | |
| 140 | 171 | </div> |
| 141 | 172 | <p class="hint" id="onid">${asked.badMatch |
| 142 | 173 | ? escape(BAD_ID) |
| 143 | 174 | : `An id is the whole match: the same map, the same spawns, the same bots and the same seed. |
| 144 | 175 | Paste one here — or open its link — to play it again; empty is a new match, as ever.`}</p> |
| 176 | <p class="hint" id="onmap">${asked.badMap | |
| 177 | ? escape(BAD_MAP) | |
| 178 | : `A generated map is carved from a seed and answers to a name: two bombsites, two spawns and | |
| 179 | cover, and the same map for both sides — it is the layout turned half a circle, so the walk | |
| 180 | to your site is the walk to theirs. A name in this field is that exact map, on any machine; | |
| 181 | empty, the Map control above decides.`}</p> | |
| 182 | <p id="willmap">Map <b id="willplay">${escape(asked.map === "generated" ? UNDRAWN : asked.map)}</b> | |
| 183 | <a class="share" id="copymap" href="${escape(mapLink(asked.map))}">Copy this map's link</a></p> | |
| 145 | 184 | <div class="row"><button type="button" class="primary" id="play" disabled>Play</button></div> |
| 146 | <p class="hint" id="onmap">A generated map is carved from the match id: two bombsites, two spawns | |
| 147 | and cover, and the same map for both sides — it is the layout turned half a circle, so the walk | |
| 148 | to your site is the walk to theirs.</p> | |
| 149 | 185 | <p class="hint" id="prep">Preparing textures…</p> |
| 150 | 186 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk · |
| 151 | 187 | mouse to aim and fire · <kbd>R</kbd> reload · <kbd>G</kbd> throw an HE · <kbd>T</kbd> throw a flashbang · |
| @@ -219,11 +255,15 @@ const PAUSE = ` | ||
| 219 | 255 | const thisMatch = (asked: Asked): string => ` <section id="thismatch"> |
| 220 | 256 | <h2>This match</h2> |
| 221 | 257 | <p>Match <code id="playing">${asked.match === null ? "none yet" : escape(asked.match)}</code> |
| 222 | on <b id="mapname">${asked.match === null ? "—" : "the map its id carves"}</b> | |
| 258 | on <a class="share" id="mapname" href="${escape(mapLink(asked.map))}">${ | |
| 259 | namedMap(asked.map) === null ? (asked.match === null ? "—" : "the map its id carves") : escape(asked.map) | |
| 260 | }</a> | |
| 223 | 261 | · <a class="share" id="share" href="${escape(shareHref(asked))}">Link to this match</a></p> |
| 224 | 262 | <p class="empty">Press Play and the id of the match you are playing appears here. Paste it into |
| 225 | 263 | <b>Match id</b> above, or open the link, and you get that match again — the same layout, the same |
| 226 | spawns, the same bots and the same seed. Every playing of it is its own row on the leaderboard.</p> | |
| 264 | spawns, the same bots and the same seed. Every playing of it is its own row on the leaderboard. | |
| 265 | The map's own name is a link too: it opens this layout and nothing else of the match, which is how | |
| 266 | a map you liked is handed to somebody who has never played it.</p> | |
| 227 | 267 | </section>`; |
| 228 | 268 | |
| 229 | 269 | /** |
src/server.test.ts +66 −1
| @@ -8,7 +8,7 @@ import { measureExample } from "./cost.ts"; | ||
| 8 | 8 | import { GAMES } from "./games.ts"; |
| 9 | 9 | import { matchOf, playKey } from "./matchid.ts"; |
| 10 | 10 | import { NOTHING_POSTED } from "./client/pending.ts"; |
| 11 | import { BAD_ID } from "./pages/game.ts"; | |
| 11 | import { BAD_ID, BAD_MAP, UNDRAWN } from "./pages/game.ts"; | |
| 12 | 12 | import { escape } from "./pages/shell.ts"; |
| 13 | 13 | import { sign } from "./who.ts"; |
| 14 | 14 | import { PAGE_ACTIONS, PAGE_IDS } from "./client/stage.test-helper.ts"; |
| @@ -137,6 +137,71 @@ test("a `?match=` that is not an id is a fresh match and a sentence, never an er | ||
| 137 | 137 | } |
| 138 | 138 | }); |
| 139 | 139 | |
| 140 | test("`/?map=gen_…` preselects one map by name, and nothing else of a match", async () => { | |
| 141 | const name = "gen_14u5j6g"; | |
| 142 | const body = await (await fetch(`${base}/?map=${name}`)).text(); | |
| 143 | // The field the client validates again is served holding it, the line before | |
| 144 | // Play names the map that is about to be played, and the link beside that | |
| 145 | // name is the map and nothing of a match: the whole of handing a map over. | |
| 146 | expect(body).toContain(`id="mapseed" maxlength="12"`); | |
| 147 | expect(body).toContain(`value="${name}"`); | |
| 148 | expect(body).toContain(`<b id="willplay">${name}</b>`); | |
| 149 | expect(body).toContain(`id="copymap" href="/?map=${name}"`); | |
| 150 | expect(body).toContain(`id="mapname" href="/?map=${name}">${name}</a>`); | |
| 151 | // A name is neither of the select's two words, so neither is picked. | |
| 152 | expect(body).not.toContain("selected"); | |
| 153 | expect(body).not.toContain(escape(BAD_MAP)); | |
| 154 | // And the name a link carries is the map a match is played on: no id asked | |
| 155 | // for, so the match is a fresh one, exactly as a page with no query is. | |
| 156 | expect(body).toContain(`<code id="playing">none yet</code>`); | |
| 157 | }); | |
| 158 | ||
| 159 | test("a `?map=` that is not a map is de_forge and a sentence, never an error page", async () => { | |
| 160 | // `gen_0ya` is `gen_ya` written badly and `gen_zzzzzzzz` is nobody's seed: | |
| 161 | // both are refused by the round trip in `src/mapname.ts`, not by a regexp | |
| 162 | // that let them through, and neither is rendered anywhere on the page. | |
| 163 | for (const junk of [ | |
| 164 | "<script>alert(1)</script>", "gen_0ya", "gen_zzzzzzzz", "gen_1z141z4", "de_moon", | |
| 165 | "GEN_YA", "../../etc/passwd", "%00", "", | |
| 166 | ]) { | |
| 167 | const response = await fetch(`${base}/?map=${encodeURIComponent(junk)}`); | |
| 168 | expect(response.status).toBe(200); | |
| 169 | const body = await response.text(); | |
| 170 | expect(body).toContain(escape(BAD_MAP)); | |
| 171 | expect(body).toContain(`<b id="willplay">de_forge</b>`); | |
| 172 | expect(body).toContain(`id="copymap" href="/?map=de_forge"`); | |
| 173 | // Nothing of what was asked for came back, escaped or otherwise. | |
| 174 | if (junk !== "") { | |
| 175 | expect(body).not.toContain(junk); | |
| 176 | expect(body).not.toContain(escape(junk)); | |
| 177 | } | |
| 178 | expect(body).not.toContain("alert(1)"); | |
| 179 | } | |
| 180 | }); | |
| 181 | ||
| 182 | test("`?map=generated` is the one map the server cannot name, and says so", async () => { | |
| 183 | // The name of "a new one every match" is the name of an id the browser has | |
| 184 | // yet to draw, so the first paint says that in words and `src/client/main.ts` | |
| 185 | // fills in the name it drew — before Play, which is the point of #148. | |
| 186 | const body = await (await fetch(`${base}/?map=generated`)).text(); | |
| 187 | expect(body).toContain(`<option value="generated" selected>`); | |
| 188 | expect(body).toContain(`<b id="willplay">${UNDRAWN}</b>`); | |
| 189 | expect(body).toContain(`id="copymap" href="/?map=generated"`); | |
| 190 | expect(body).not.toContain(escape(BAD_MAP)); | |
| 191 | expect(body).not.toMatch(/id="mapseed"[^>]*value=/); | |
| 192 | }); | |
| 193 | ||
| 194 | test("`?map=` belongs to the shooter and to no other game's page", async () => { | |
| 195 | // It is this game's parameter, read in this game's renderer. The other two | |
| 196 | // pages have no Map control and carry nothing of it, whatever a link says. | |
| 197 | for (const path of ["/range", "/example"]) { | |
| 198 | const body = await (await fetch(`${base}${path}?map=gen_14u5j6g`)).text(); | |
| 199 | expect(body).not.toContain("gen_14u5j6g"); | |
| 200 | expect(body).not.toContain("willplay"); | |
| 201 | expect(body).not.toContain("copymap"); | |
| 202 | } | |
| 203 | }); | |
| 204 | ||
| 140 | 205 | test("a page nobody linked to is the page it always was: no id, a fresh match, de_forge", async () => { |
| 141 | 206 | const body = await (await fetch(base + "/")).text(); |
| 142 | 207 | expect(body).toContain(`<code id="playing">none yet</code>`); |
src/server.ts +17 −7
| @@ -39,6 +39,7 @@ import { GAMES } from "./games.ts"; | ||
| 39 | 39 | import { createLimiter } from "./limit.ts"; |
| 40 | 40 | import { examplePage } from "./pages/example.ts"; |
| 41 | 41 | import { type Asked, gamePage, STYLE as GAME_STYLE, statsHtml } from "./pages/game.ts"; |
| 42 | import { mapFrom } from "./mapname.ts"; | |
| 42 | 43 | import { matchFrom } from "./matchid.ts"; |
| 43 | 44 | import { errorPage, notFoundPage, STYLE as MISS_STYLE } from "./pages/miss.ts"; |
| 44 | 45 | import { rangePage } from "./pages/range.ts"; |
| @@ -262,23 +263,32 @@ type Render = { extraStyle?: string; draw: (request: Request, script: string) => | ||
| 262 | 263 | |
| 263 | 264 | /** |
| 264 | 265 | * What a link to the shooter asked for. `/?match=<id>` plays that match again |
| 265 | * and `?map=` picks which of the Map control's two values it is played on — | |
| 266 | * together they are what the page's own "Link to this match" hands out. | |
| 266 | * and `?map=` says which map it is played on — one of the Map control's two | |
| 267 | * words, or the name of one generated map (`gen_14u5j6g`), which is the whole | |
| 268 | * of how a layout somebody liked is handed to somebody else. | |
| 267 | 269 | * |
| 268 | 270 | * Both are inputs and both are settled here: the id is whatever `src/matchid.ts` |
| 269 | * accepts and null otherwise, the map is one of two known words, and nothing a | |
| 270 | * caller wrote reaches the page as itself. A parameter that is not an id is not | |
| 271 | * an error — the page says so in a sentence and plays a fresh match — and the | |
| 272 | * page escapes what it renders anyway, because two checks are the point. | |
| 271 | * accepts and null otherwise, the map is whatever `src/mapname.ts` accepts and | |
| 272 | * de_forge otherwise, and nothing a caller wrote reaches the page as itself — a | |
| 273 | * name that comes back out is the canonical one this server printed, not the | |
| 274 | * bytes that arrived. A parameter that is not an id or not a map is not an | |
| 275 | * error either: the page says so in a sentence and plays the default, and it | |
| 276 | * escapes what it renders anyway, because two checks are the point. | |
| 277 | * | |
| 278 | * Both belong to this game and to no other: `?map=` is read here, in the | |
| 279 | * shooter's own renderer, and the range and the example never see it. | |
| 273 | 280 | */ |
| 274 | 281 | const asked = (request: Request): Asked => { |
| 275 | 282 | const query = new URL(request.url).searchParams; |
| 276 | 283 | const wanted = query.get("match"); |
| 277 | 284 | const match = matchFrom(wanted); |
| 285 | const wantedMap = query.get("map"); | |
| 286 | const map = mapFrom(wantedMap); | |
| 278 | 287 | return { |
| 279 | 288 | match, |
| 280 | 289 | badMatch: wanted !== null && match === null, |
| 281 | map: query.get("map") === "generated" ? "generated" : "de_forge", | |
| 290 | map, | |
| 291 | badMap: wantedMap !== null && wantedMap !== map, | |
| 282 | 292 | }; |
| 283 | 293 | }; |
| 284 | 294 |
static/app.6a9982560a.js deleted −58
Diff not shown — a line 76,328 characters wide (limit 2,000). −58
View filestatic/app.9e515111bc.js added +58
Diff not shown — a line 77,687 characters wide (limit 2,000). +58
View file
new above
offline