Public: anyone can read this; the crew can change it.
forked from trevato/cs
A pasted link plays the match it names: the Match id field takes a URL, and the page stops promising what a bare id cannot keep #277
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/215-a-pasted-link-plays-the-match-it-names-t → main 1 commit fork
8 files
changed +390
−21
README.md +26 −7
| @@ -152,14 +152,20 @@ a plan and not an error. | ||
| 152 | 152 | |
| 153 | 153 | ## A match you can play again |
| 154 | 154 | |
| 155 | A match is one short string. The id picks the map, the spawns, the bots, the | |
| 156 | bomb carrier and the seed every roll in the round comes out of, so handing that | |
| 157 | string back is a rematch, a challenge to a friend and the first line of a bug | |
| 158 | report. | |
| 155 | A match is one short string. The id picks the spawns, the bots, the bomb carrier | |
| 156 | and the seed every roll in the round comes out of — and the map too, but only | |
| 157 | when the Map control asks for one carved from the match. The map is a choice | |
| 158 | made beside the id, so the thing that carries a whole match is the *link*: | |
| 159 | handing that over is a rematch, a challenge to a friend and the first line of a | |
| 160 | bug report. | |
| 159 | 161 | |
| 160 | - **Paste it.** The menu has a **Match id** field. Put an id in it and Play | |
| 161 | plays that match again — the same layout, the same bots, the same seed. Leave | |
| 162 | it empty and you get a new match, exactly as before. | |
| 162 | - **Paste it.** The menu has a **Match id** field, and it takes both of the | |
| 163 | things people actually copy. A bare id plays that match again — the same | |
| 164 | spawns, the same bots, the same seed — on whatever map the Map control is on, | |
| 165 | which it leaves alone. A whole link from this page fills in the id *and* the | |
| 166 | map, so you get the match you were given rather than its round on somebody | |
| 167 | else's layout. Leave the field empty and you get a new match, exactly as | |
| 168 | before. | |
| 163 | 169 | - **Link to it.** `/?match=<id>&map=<de_forge|generated|gen_…>` preselects both. |
| 164 | 170 | The page's own **Link to this match**, under the stage, is that URL for |
| 165 | 171 | whatever you are playing: a plain `<a href>` a keyboard reaches and a thumb |
| @@ -187,6 +193,16 @@ is rendered, the page escapes what it renders anyway, and | ||
| 187 | 193 | reaches `generateLevel`. Anything else is a fresh match and a sentence saying |
| 188 | 194 | so, never an error page and never a refusal to play. |
| 189 | 195 | |
| 196 | A pasted *link* is an input twice over, and `src/matchlink.ts` is the one place | |
| 197 | that reads one. It reads and never follows: the URL is parsed, the origin has to | |
| 198 | be this app's and the scheme `http:` or `https:` (so `javascript:…`, | |
| 199 | `//somewhere.else/?match=…` and a different port are all refused), only `match` | |
| 200 | and `map` are taken out of it, and those two go through `src/matchid.ts` and | |
| 201 | `src/mapname.ts` exactly as the server's own query string does — the path, the | |
| 202 | fragment and every other parameter are dropped. A URL that is not ours, or ours | |
| 203 | with neither parameter on it, gets what a bad id gets: the sentence under the | |
| 204 | field and a fresh match. | |
| 205 | ||
| 190 | 206 | ## A map you can keep |
| 191 | 207 | |
| 192 | 208 | A generated map is carved from a number, and that number has a name: `gen_14u5j6g`, |
| @@ -1045,6 +1061,9 @@ never checked itself. | ||
| 1045 | 1061 | above, the `<match>--<nonce>` key one playing of a match is stored under, and |
| 1046 | 1062 | the match read back out of it — and the parser, the `?match=` query and the |
| 1047 | 1063 | page's field all ask it rather than each holding a regular expression; |
| 1064 | `src/matchlink.ts` is what the field asks when what was pasted into it is a | |
| 1065 | whole URL: it reads `match` and `map` out of a link of ours and holds no | |
| 1066 | grammar of its own, because those two modules have it; | |
| 1048 | 1067 | `src/who.ts` verifies the forge's signed identity headers, `src/limit.ts` is a |
| 1049 | 1068 | token bucket per caller (the clock is a parameter, so its test does not wait), |
| 1050 | 1069 | and `src/bind.ts` decides the socket: `PORT` and `HOST` are the forge's, but |
src/client/main.test.ts +108
| @@ -1406,6 +1406,114 @@ test("the match being played is on the page: the id, the map, the link and the p | ||
| 1406 | 1406 | expect(stage.by("share").attributes.href).toBe(`/?match=${PASTED}&map=de_forge`); |
| 1407 | 1407 | }); |
| 1408 | 1408 | |
| 1409 | // --- a pasted link ---------------------------------------------------------- | |
| 1410 | ||
| 1411 | /** Type this into the Match id field, the way a person pastes and the DOM tells us. */ | |
| 1412 | function type(stage: FakeStage, text: string): FakeStage { | |
| 1413 | stage.by("matchid").value = text; | |
| 1414 | stage.by("matchid").fire("input"); | |
| 1415 | return stage; | |
| 1416 | } | |
| 1417 | ||
| 1418 | test("a pasted link fills both controls, and the map named above Play is the map played", () => { | |
| 1419 | const stage = painted(start(fakeStage())); | |
| 1420 | type(stage, `${ORIGIN}/?match=${PASTED}&map=${MAP}`); | |
| 1421 | // What is left on the screen is what will be played: the bare id in the id | |
| 1422 | // field and the map in the Map control, both validated again on the way in. | |
| 1423 | expect(stage.by("matchid").value).toBe(PASTED); | |
| 1424 | expect(stage.by("mapseed").value).toBe(MAP); | |
| 1425 | expect(stage.by("map").value).toBe("de_forge"); | |
| 1426 | expect(stage.by("willplay").textContent).toBe(MAP); | |
| 1427 | expect(stage.by("onid").textContent).not.toBe(NOT_AN_ID); | |
| 1428 | ||
| 1429 | // And Play gives that: that match's spawns and bots on that layout, not on | |
| 1430 | // whatever the Map control happened to be left on — which is the whole bug. | |
| 1431 | stage.by("play").fire("click"); | |
| 1432 | const world = worldOf(stage) as World; | |
| 1433 | expect(world.level.name).toBe(MAP); | |
| 1434 | expect(snapshot(world)) | |
| 1435 | .toBe(snapshot(createWorld(generateLevel(seedOf(MAP) as number), SHORT, "you", "ct", 5, seedFrom(PASTED)))); | |
| 1436 | // Copying still gives the complete thing: both halves, as before today. | |
| 1437 | expect(stage.by("share").attributes.href).toBe(`/?match=${PASTED}&map=${MAP}`); | |
| 1438 | expect(stage.by("mapname").attributes.href).toBe(`/?map=${MAP}`); | |
| 1439 | ||
| 1440 | // A link asking for a map carved from its own id says so in the select, and | |
| 1441 | // the name above Play is the map that id carves — before Play, as ever. | |
| 1442 | stage.by("quit").fire("click"); | |
| 1443 | type(stage, `${ORIGIN}/?match=${PASTED}&map=generated`); | |
| 1444 | expect(stage.by("map").value).toBe("generated"); | |
| 1445 | expect(stage.by("mapseed").value).toBe(""); | |
| 1446 | expect(stage.by("willplay").textContent).toBe(nameOf(seedFrom(PASTED))); | |
| 1447 | stage.by("play").fire("click"); | |
| 1448 | expect(worldOf(stage)?.level.name).toBe(nameOf(seedFrom(PASTED))); | |
| 1449 | expect(stage.by("share").attributes.href).toBe(`/?match=${PASTED}&map=generated`); | |
| 1450 | }); | |
| 1451 | ||
| 1452 | test("a link pasted without a keystroke is still read when Play is pressed", () => { | |
| 1453 | // A value set in code — a browser filling the field, a script, this test — | |
| 1454 | // fires no event, so the link is taken apart at Play as well. | |
| 1455 | const stage = pasted(`${ORIGIN}/?match=${PASTED}&map=${MAP}`); | |
| 1456 | expect(worldOf(stage)?.level.name).toBe(MAP); | |
| 1457 | expect(stage.by("playing").textContent).toBe(PASTED); | |
| 1458 | expect(stage.by("matchid").value).toBe(PASTED); | |
| 1459 | }); | |
| 1460 | ||
| 1461 | test("a bare id leaves the Map control exactly where the player left it", () => { | |
| 1462 | const stage = painted(start(fakeStage())); | |
| 1463 | stage.by("mapseed").value = MAP; | |
| 1464 | stage.by("mapseed").fire("input"); | |
| 1465 | type(stage, PASTED); | |
| 1466 | // An id carries no map, and nothing pretends it does: the field a person set | |
| 1467 | // is untouched and the line above Play still names their map. | |
| 1468 | expect(stage.by("mapseed").value).toBe(MAP); | |
| 1469 | expect(stage.by("willplay").textContent).toBe(MAP); | |
| 1470 | stage.by("play").fire("click"); | |
| 1471 | expect(worldOf(stage)?.level.name).toBe(MAP); | |
| 1472 | ||
| 1473 | // And the same for the select: a bare id on `generated` is that id's map. | |
| 1474 | stage.by("quit").fire("click"); | |
| 1475 | stage.by("mapseed").value = ""; | |
| 1476 | stage.by("mapseed").fire("input"); | |
| 1477 | stage.by("map").value = "generated"; | |
| 1478 | stage.by("map").fire("change"); | |
| 1479 | type(stage, PASTED); | |
| 1480 | expect(stage.by("map").value).toBe("generated"); | |
| 1481 | expect(stage.by("willplay").textContent).toBe(nameOf(seedFrom(PASTED))); | |
| 1482 | }); | |
| 1483 | ||
| 1484 | test("a link that is not ours is not followed and not a match: the sentence, and de_forge", () => { | |
| 1485 | const stage = painted(start(fakeStage())); | |
| 1486 | const untouched = stage.by("map").value; | |
| 1487 | for (const text of [ | |
| 1488 | `https://evil.example/?match=${PASTED}&map=${MAP}`, | |
| 1489 | `javascript:alert(1)?match=${PASTED}&map=${MAP}`, | |
| 1490 | `${ORIGIN}/?utm=x`, | |
| 1491 | ]) { | |
| 1492 | type(stage, text); | |
| 1493 | // The existing sentence, not an error — and the Map control is untouched, | |
| 1494 | // because nothing of that URL was believed. | |
| 1495 | expect(stage.by("onid").textContent).toBe(NOT_AN_ID); | |
| 1496 | expect(stage.by("mapseed").value).toBe(""); | |
| 1497 | expect(stage.by("map").value).toBe(untouched); | |
| 1498 | expect(stage.by("matchid").value).toBe(text); | |
| 1499 | } | |
| 1500 | ||
| 1501 | // Play is a fresh match on de_forge, which is what a bad id has always been. | |
| 1502 | stage.by("play").fire("click"); | |
| 1503 | expect(worldOf(stage)?.level.name).toBe("de_forge"); | |
| 1504 | expect(stage.by("onid").textContent).toBe(NOT_AN_ID); | |
| 1505 | expect(snapshot(worldOf(stage) as World)) | |
| 1506 | .toBe(snapshot(createWorld(DE_FORGE, SHORT, "you", "ct", 5, seedFrom(stage.ids[0] as string)))); | |
| 1507 | ||
| 1508 | // And a link of ours that carried a match nobody could play says the same | |
| 1509 | // thing, while still keeping the map it did carry. | |
| 1510 | stage.by("quit").fire("click"); | |
| 1511 | type(stage, `${ORIGIN}/?match=nope&map=${MAP}`); | |
| 1512 | expect(stage.by("onid").textContent).toBe(NOT_AN_ID); | |
| 1513 | expect(stage.by("matchid").value).toBe(""); | |
| 1514 | expect(stage.by("willplay").textContent).toBe(MAP); | |
| 1515 | }); | |
| 1516 | ||
| 1409 | 1517 | // --- the round's plan ------------------------------------------------------- |
| 1410 | 1518 | |
| 1411 | 1519 | test("a round is planned in the page, and the bots are a team from the first tick", () => { |
src/client/main.ts +62 −5
| @@ -79,6 +79,7 @@ import { generateLevel, seedFrom } from "./generate.ts"; | ||
| 79 | 79 | import { mapFrom, mapLink, nameOf, seedOf, type MapChoice } from "../mapname.ts"; |
| 80 | 80 | import { dailyOn, DAILY_LENGTH, saidToday } from "../daily.ts"; |
| 81 | 81 | import { matchFrom, playKey } from "../matchid.ts"; |
| 82 | import { pastedLink } from "../matchlink.ts"; | |
| 82 | 83 | import { describe, localPlan, parsePlan, type RoundPlan } from "../plan/plan.ts"; |
| 83 | 84 | import type { Situation } from "../plan/situation.ts"; |
| 84 | 85 | import { DE_FORGE } from "./map.ts"; |
| @@ -773,6 +774,55 @@ export function boot(host: Host): Frame { | ||
| 773 | 774 | return typed !== "" && seedOf(typed) === null; |
| 774 | 775 | }; |
| 775 | 776 | |
| 777 | /** | |
| 778 | * The Map control, set to one map: the select and the name field written the | |
| 779 | * way the server renders them for the same `?map=`, so a link opened and a | |
| 780 | * link pasted leave the menu looking the same. It is the only thing that | |
| 781 | * writes those two fields, and it writes them together — `chosenMap` above | |
| 782 | * stays the one place they are read. | |
| 783 | */ | |
| 784 | function showMap(choice: MapChoice): void { | |
| 785 | mapField.value = choice === "generated" ? "generated" : "de_forge"; | |
| 786 | seedField.value = choice === "generated" || choice === "de_forge" ? "" : choice; | |
| 787 | } | |
| 788 | ||
| 789 | /** | |
| 790 | * A whole link in the Match id field, taken apart into the controls it names. | |
| 791 | * People copy a link, not the id in the middle of it, and the link is the | |
| 792 | * only thing that carries both halves of a match — the id, which is the | |
| 793 | * spawns and the bots and the seed, and the map it was played on. | |
| 794 | * | |
| 795 | * `src/matchlink.ts` reads it and never follows it: another origin, a | |
| 796 | * `javascript:` string or a URL with neither parameter is not a link, and the | |
| 797 | * field is left exactly as it was for the id's own door to judge. What is | |
| 798 | * ours becomes the bare id in this field and the map in the Map control, so | |
| 799 | * what is on the screen afterwards is what will be played, and the line above | |
| 800 | * Play names that map because `nameNextMap` runs straight after. | |
| 801 | * | |
| 802 | * Answers whether the link named a match that is not an id, which is the one | |
| 803 | * thing the field cannot say for itself once the link is out of it. | |
| 804 | */ | |
| 805 | function absorbLink(): boolean { | |
| 806 | const link = pastedLink(idField.value.trim(), host.origin); | |
| 807 | if (link === null) return false; | |
| 808 | idField.value = link.match ?? ""; | |
| 809 | showMap(link.map); | |
| 810 | return link.badMatch; | |
| 811 | } | |
| 812 | ||
| 813 | /** | |
| 814 | * The Match id field, read: a link becomes the controls it names, and then | |
| 815 | * what is left is an id, nothing, or a typo — and the line under the field | |
| 816 | * says which. The match to play comes back, so `start` below asks once. | |
| 817 | */ | |
| 818 | function readIdField(): string | null { | |
| 819 | const bad = absorbLink(); | |
| 820 | const typed = idField.value.trim(); | |
| 821 | const wanted = matchFrom(typed); | |
| 822 | onId.textContent = bad || (typed !== "" && wanted === null) ? NOT_AN_ID : aboutId; | |
| 823 | return wanted; | |
| 824 | } | |
| 825 | ||
| 776 | 826 | /** The match the next Play will be, which is what "generated" draws its map from. */ |
| 777 | 827 | const nextMatch = (): string => matchFrom(idField.value.trim()) ?? (drawn ??= host.randomUUID()); |
| 778 | 828 | |
| @@ -830,8 +880,16 @@ export function boot(host: Host): Frame { | ||
| 830 | 880 | planAlt.textContent = previewText(level); |
| 831 | 881 | } |
| 832 | 882 | |
| 833 | for (const field of [mapField, seedField, idField]) | |
| 883 | for (const field of [mapField, seedField]) | |
| 834 | 884 | for (const event of ["change", "input"]) field.addEventListener(event, nameNextMap); |
| 885 | // The id field is the one that can rewrite the other two: a pasted link is | |
| 886 | // read into them first and the map is named afterwards, out of the controls | |
| 887 | // the link left behind. | |
| 888 | for (const event of ["change", "input"]) | |
| 889 | idField.addEventListener(event, () => { | |
| 890 | readIdField(); | |
| 891 | nameNextMap(); | |
| 892 | }); | |
| 835 | 893 | |
| 836 | 894 | /** |
| 837 | 895 | * The match of the day, in the menu's own controls: today's id in the Match |
| @@ -896,7 +954,8 @@ export function boot(host: Host): Frame { | ||
| 896 | 954 | * a person can type into it and neither side trusts the other. Anything |
| 897 | 955 | * that is not an id is a fresh match and a sentence saying so; an empty |
| 898 | 956 | * field is a fresh match and nothing said, which is every match before |
| 899 | * today. | |
| 957 | * today. A whole link is read into the controls here too and not only on | |
| 958 | * the keystroke, because a value can be set without one. | |
| 900 | 959 | * |
| 901 | 960 | * The id and the row key are not the same string. The id is the match — the |
| 902 | 961 | * thing you paste, and what the seed below is taken from — and the key is |
| @@ -905,9 +964,7 @@ export function boot(host: Host): Frame { | ||
| 905 | 964 | * leaderboard rather than on top of it, and the key still reads back as the |
| 906 | 965 | * match it was a playing of. A fresh match is its own key, as before. |
| 907 | 966 | */ |
| 908 | const typed = idField.value.trim(); | |
| 909 | const wanted = matchFrom(typed); | |
| 910 | onId.textContent = typed !== "" && wanted === null ? NOT_AN_ID : aboutId; | |
| 967 | const wanted = readIdField(); | |
| 911 | 968 | // The id the menu already named a map for, if it drew one, so the map that |
| 912 | 969 | // was promised is the map that is played. A fresh one otherwise, and a |
| 913 | 970 | // fresh one for the nonce on the row key when an id was pasted. |
src/matchlink.test.ts added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | /** | |
| 2 | * The link, taken apart: what is ours, what is not, and that what comes back is | |
| 3 | * the canonical id and the canonical map name rather than the bytes that | |
| 4 | * arrived. The refusals are the point of the file — a field that takes a URL is | |
| 5 | * a field somebody will paste another site's URL into. | |
| 6 | */ | |
| 7 | ||
| 8 | import { expect, test } from "bun:test"; | |
| 9 | import { pastedLink } from "./matchlink.ts"; | |
| 10 | ||
| 11 | const HERE = "https://cs.example"; | |
| 12 | const ID = "3d9b7c1e-0000-4000-8000-5a2f6b4c8d11"; | |
| 13 | const MAP = "gen_14u5j6g"; | |
| 14 | ||
| 15 | test("a link from this app is the match it names and the map it names", () => { | |
| 16 | expect(pastedLink(`${HERE}/?match=${ID}&map=${MAP}`, HERE)).toEqual({ match: ID, badMatch: false, map: MAP }); | |
| 17 | // The order of the two parameters is the URL's business, not ours, and a | |
| 18 | // path, a fragment and a third parameter are all dropped on the floor. | |
| 19 | expect(pastedLink(`${HERE}/play?map=generated&match=${ID}&utm=x#top`, HERE)) | |
| 20 | .toEqual({ match: ID, badMatch: false, map: "generated" }); | |
| 21 | // The page's own hrefs are relative, and a relative link is ours. | |
| 22 | expect(pastedLink(`/?match=${ID}&map=${MAP}`, HERE)).toEqual({ match: ID, badMatch: false, map: MAP }); | |
| 23 | // A row key is the match it was a playing of, here as everywhere else. | |
| 24 | expect(pastedLink(`${HERE}/?match=${ID}--0123456789ab&map=${MAP}`, HERE)?.match).toBe(ID); | |
| 25 | }); | |
| 26 | ||
| 27 | test("a link with one of the two still carries that one, and the other is the default", () => { | |
| 28 | // No `?map=` is de_forge, because that is the map following the link gives: | |
| 29 | // pasting a link and opening it are the same match on the same layout. | |
| 30 | expect(pastedLink(`${HERE}/?match=${ID}`, HERE)).toEqual({ match: ID, badMatch: false, map: "de_forge" }); | |
| 31 | // And a map's own link — the one the copy control writes — names no match. | |
| 32 | expect(pastedLink(`${HERE}/?map=${MAP}`, HERE)).toEqual({ match: null, badMatch: false, map: MAP }); | |
| 33 | }); | |
| 34 | ||
| 35 | test("what the link carried is revalidated, and junk in it is the default with a flag", () => { | |
| 36 | // A `?match=` that is not an id is a fresh match and said so, the way the | |
| 37 | // server says it: not an error, not a refusal. | |
| 38 | expect(pastedLink(`${HERE}/?match=nope&map=${MAP}`, HERE)).toEqual({ match: null, badMatch: true, map: MAP }); | |
| 39 | // A `?map=` that is not a name is de_forge, like an unknown anything here. | |
| 40 | expect(pastedLink(`${HERE}/?match=${ID}&map=de_moon`, HERE)?.map).toBe("de_forge"); | |
| 41 | expect(pastedLink(`${HERE}/?match=${ID}&map=<script>`, HERE)?.map).toBe("de_forge"); | |
| 42 | // And a name written badly is a typo, not a map: `gen_0ya` is `gen_ya`. | |
| 43 | expect(pastedLink(`${HERE}/?map=gen_0ya`, HERE)?.map).toBe("de_forge"); | |
| 44 | }); | |
| 45 | ||
| 46 | test("nothing but a URL of ours with something on it is a link", () => { | |
| 47 | for (const text of [ | |
| 48 | // Another origin, however much it looks like this one. | |
| 49 | `https://evil.example/?match=${ID}&map=${MAP}`, | |
| 50 | `https://cs.example.evil/?match=${ID}`, | |
| 51 | `http://cs.example/?match=${ID}`, // another scheme is another origin | |
| 52 | `https://cs.example:8443/?match=${ID}`, // and so is another port | |
| 53 | `//evil.example/?match=${ID}`, // protocol-relative, resolved and refused | |
| 54 | // A scheme that is not a page at all. `javascript:` has no origin to | |
| 55 | // compare, which is exactly why the scheme is checked as well. | |
| 56 | `javascript:alert(1)?match=${ID}`, | |
| 57 | "data:text/html,?match=deadbeef", | |
| 58 | // Ours, but asking for neither of the two things this field is about. | |
| 59 | HERE, | |
| 60 | `${HERE}/?utm=x`, | |
| 61 | // A bare id is not a link — it is the other thing the field takes, and the | |
| 62 | // caller asks `src/matchid.ts` about it. | |
| 63 | ID, | |
| 64 | "not a match id", | |
| 65 | "", | |
| 66 | // Not a string, and longer than any address bar. | |
| 67 | null, | |
| 68 | `${HERE}/?match=${"a".repeat(4096)}`, | |
| 69 | ]) | |
| 70 | expect(pastedLink(text, HERE)).toBeNull(); | |
| 71 | }); | |
| 72 | ||
| 73 | test("an origin that is not one refuses everything rather than throwing", () => { | |
| 74 | expect(pastedLink(`${HERE}/?match=${ID}`, "not an origin")).toBeNull(); | |
| 75 | expect(pastedLink(`${HERE}/?match=${ID}`, "")).toBeNull(); | |
| 76 | // A trailing slash on the app's own origin is still the app's own origin. | |
| 77 | expect(pastedLink(`${HERE}/?match=${ID}`, `${HERE}/`)?.match).toBe(ID); | |
| 78 | }); |
src/matchlink.ts added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | /** | |
| 2 | * What a link to this app names, read out of the link and never followed. | |
| 3 | * | |
| 4 | * A match id is a uuid and a map is a name, and the two travel together in one | |
| 5 | * URL — `/?match=<id>&map=<name>` — because the id seeds the spawns, the bots | |
| 6 | * and the round, and the map is a choice made beside it. That is what a person | |
| 7 | * copies: the whole link out of the address bar or out of a message, not the | |
| 8 | * eight-to-sixty-four characters in the middle of it. So the Match id field | |
| 9 | * takes a link too, and this is the one place that takes one apart. | |
| 10 | * | |
| 11 | * It is a parser and not a fetch. A URL is a string here: `match` and `map` are | |
| 12 | * the only two parameters read, they are revalidated through `src/matchid.ts` | |
| 13 | * and `src/mapname.ts` exactly as `src/server.ts` revalidates the query it was | |
| 14 | * sent, and everything else about the URL is dropped on the floor — the path, | |
| 15 | * the fragment, any other parameter. A URL on another origin, a `javascript:` | |
| 16 | * string, or a URL carrying neither parameter is not one of ours and is not a | |
| 17 | * link: `null` comes back, and the caller does with it what it already does | |
| 18 | * with an id that is not one, which is to say a sentence and play a fresh match. | |
| 19 | * | |
| 20 | * Both halves come back, because the point is that a link carries both. A link | |
| 21 | * with no `?map=` on it opens on de_forge — that is what following it does — | |
| 22 | * so that is what `map` says, and pasting a link gives the same match as | |
| 23 | * opening it. A *bare* id carries no map at all and never reaches here. | |
| 24 | * | |
| 25 | * It ships in the bundle beside the two modules it validates through, and | |
| 26 | * imports nothing else. | |
| 27 | */ | |
| 28 | ||
| 29 | import { matchFrom } from "./matchid.ts"; | |
| 30 | import { mapFrom, type MapChoice } from "./mapname.ts"; | |
| 31 | ||
| 32 | /** | |
| 33 | * What a link asked for: the match it names, whether it named one that is not | |
| 34 | * an id, and the map it will be played on. The same three facts `src/server.ts` | |
| 35 | * reads out of its own query string, because it is the same query string. | |
| 36 | */ | |
| 37 | export type Pasted = { | |
| 38 | /** The match, revalidated, or null when the link named none or named junk. */ | |
| 39 | match: string | null; | |
| 40 | /** Whether the link carried a `match` that is not a match id. */ | |
| 41 | badMatch: boolean; | |
| 42 | /** The map, revalidated: de_forge when the link named none or named junk. */ | |
| 43 | map: MapChoice; | |
| 44 | }; | |
| 45 | ||
| 46 | /** | |
| 47 | * The longest string worth parsing as a URL, and the `maxlength` the Match id | |
| 48 | * field is rendered with, so the two cannot disagree about what fits. A match | |
| 49 | * id is 64 characters and a map name is 12, so a link to this app is well under | |
| 50 | * a hundred; two kilobytes is room for any address bar and a bound on the work | |
| 51 | * one keystroke can ask for. | |
| 52 | */ | |
| 53 | export const LONGEST_PASTE = 2048; | |
| 54 | ||
| 55 | /** | |
| 56 | * The link this text is, or null when it is not one of ours. | |
| 57 | * | |
| 58 | * `origin` is this app's — `location.origin` in the page — and the check is on | |
| 59 | * the parsed origin rather than on the text, so `//evil.example/?match=…`, | |
| 60 | * `https://cs.example.evil/?match=…` and a different port are all refused the | |
| 61 | * same way. The scheme is checked as well as the origin: a `javascript:` URL | |
| 62 | * has no origin to compare, and comparing two of those would be a hole. | |
| 63 | * | |
| 64 | * A relative link — `/?match=…&map=…`, which is what the page's own hrefs say — | |
| 65 | * resolves against `origin` and is ours. | |
| 66 | */ | |
| 67 | export function pastedLink(text: unknown, origin: string): Pasted | null { | |
| 68 | if (typeof text !== "string" || text === "" || text.length > LONGEST_PASTE) return null; | |
| 69 | let here: string; | |
| 70 | let url: URL; | |
| 71 | try { | |
| 72 | here = new URL(origin).origin; | |
| 73 | url = new URL(text, origin); | |
| 74 | } catch { | |
| 75 | return null; | |
| 76 | } | |
| 77 | if (url.protocol !== "http:" && url.protocol !== "https:") return null; | |
| 78 | if (url.origin !== here) return null; | |
| 79 | const wanted = url.searchParams.get("match"); | |
| 80 | const wantedMap = url.searchParams.get("map"); | |
| 81 | // A URL of ours that asks for neither is not a link to a match; it is a | |
| 82 | // string in the wrong field, and it gets what any other one gets. | |
| 83 | if (wanted === null && wantedMap === null) return null; | |
| 84 | const match = matchFrom(wanted); | |
| 85 | return { match, badMatch: wanted !== null && match === null, map: mapFrom(wantedMap) }; | |
| 86 | } |
src/pages/game.test.ts +15
| @@ -18,6 +18,7 @@ import { join } from "node:path"; | ||
| 18 | 18 | import { IDLE_SECONDS, openDatabase, type Player, saveMatch, standings } from "../db.ts"; |
| 19 | 19 | import { dailyOn, NOT_A_LADDER, RANKED_BY, saidToday } from "../daily.ts"; |
| 20 | 20 | import { playKey } from "../matchid.ts"; |
| 21 | import { LONGEST_PASTE } from "../matchlink.ts"; | |
| 21 | 22 | import type { Result } from "../result.ts"; |
| 22 | 23 | import { CARD, type Card } from "../client/weights.ts"; |
| 23 | 24 | import { GAMES } from "../games.ts"; |
| @@ -185,6 +186,20 @@ test("a link that asked for one map by name opens on it, before Play and as a li | ||
| 185 | 186 | expect(bad).not.toMatch(/id="mapseed"[^>]*value=/); |
| 186 | 187 | }); |
| 187 | 188 | |
| 189 | test("the id's sentence promises what an id keeps, and says the link is what keeps the map", () => { | |
| 190 | // #215: the page used to say an id was "the whole match: the same map, …", | |
| 191 | // which the id has not been since the map became a control of its own. What | |
| 192 | // it says now has to be true of an id *and* say where the map comes from. | |
| 193 | const html = gamePage(db(), options); | |
| 194 | const said = html.slice(html.indexOf(`id="onid"`), html.indexOf(`id="onmap"`)).replace(/\s+/g, " "); | |
| 195 | expect(said).not.toContain("the whole match"); | |
| 196 | expect(said).toContain("the same spawns, the same bots and the same seed"); | |
| 197 | expect(said).toContain("a whole link from this page belongs here too"); | |
| 198 | expect(said).toContain("A bare id plays on whatever map is chosen above"); | |
| 199 | // And the field says so too: it is as long as a link and asks for one. | |
| 200 | expect(html).toContain(`id="matchid" maxlength="${LONGEST_PASTE}" placeholder="paste an id or a link`); | |
| 201 | }); | |
| 202 | ||
| 188 | 203 | test("the boast about the bots is the card, formatted the way the page formats numbers", () => { |
| 189 | 204 | const said = boast(gamePage(db(), options)); |
| 190 | 205 | expect(said).toContain(`trained with PPO against ${CARD.envSteps.toLocaleString("en")} ticks of this same simulation`); |
src/pages/game.ts +10 −6
| @@ -15,6 +15,7 @@ import { dayBoard, displayName, headshotShare, liveness, type Liveness, ratio, r | ||
| 15 | 15 | import { NOTHING_POSTED } from "../client/pending.ts"; |
| 16 | 16 | import { CARD, type Card } from "../client/weights.ts"; |
| 17 | 17 | import { mapFrom, mapLink, type MapChoice } from "../mapname.ts"; |
| 18 | import { LONGEST_PASTE } from "../matchlink.ts"; | |
| 18 | 19 | import type { Who } from "../who.ts"; |
| 19 | 20 | import { navFor, noscriptFor, othersHtml } from "./games.ts"; |
| 20 | 21 | import { STYLE as WEAPONS_STYLE, WEAPONS_SECTION } from "./weapons.ts"; |
| @@ -322,7 +323,7 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => ` | ||
| 322 | 323 | <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label> |
| 323 | 324 | </div> |
| 324 | 325 | <div class="row"> |
| 325 | <label>Match id<input id="matchid" maxlength="64" placeholder="paste one to play it again" | |
| 326 | <label>Match id<input id="matchid" maxlength="${LONGEST_PASTE}" placeholder="paste an id or a link to play it again" | |
| 326 | 327 | autocomplete="off" spellcheck="false"${asked.match === null ? "" : ` value="${escape(asked.match)}"`}></label> |
| 327 | 328 | <label>Map name<input id="mapseed" maxlength="12" placeholder="gen_14u5j6g" |
| 328 | 329 | autocomplete="off" spellcheck="false"${ |
| @@ -331,8 +332,10 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => ` | ||
| 331 | 332 | </div> |
| 332 | 333 | <p class="hint" id="onid">${asked.badMatch |
| 333 | 334 | ? escape(BAD_ID) |
| 334 | : `An id is the whole match: the same map, the same spawns, the same bots and the same seed. | |
| 335 | Paste one here — or open its link — to play it again; empty is a new match, as ever.`}</p> | |
| 335 | : `An id is the round: the same spawns, the same bots and the same seed. The map is the | |
| 336 | Map control's, not the id's, so a whole link from this page belongs here too — paste one | |
| 337 | and it fills in the id <i>and</i> the map, which is the match you were given. A bare id | |
| 338 | plays on whatever map is chosen above; empty is a new match, as ever.`}</p> | |
| 336 | 339 | <p class="hint" id="onmap">${asked.badMap |
| 337 | 340 | ? escape(BAD_MAP) |
| 338 | 341 | : `A generated map is carved from a seed and answers to a name: two bombsites, two spawns and |
| @@ -475,9 +478,10 @@ const thisMatch = (asked: Asked): string => ` <section id="thismatch"> | ||
| 475 | 478 | namedMap(asked.map) === null ? (asked.match === null ? "—" : "the map its id carves") : escape(asked.map) |
| 476 | 479 | }</a> |
| 477 | 480 | · <a class="share" id="share" href="${escape(shareHref(asked))}">Link to this match</a></p> |
| 478 | <p class="empty">Press Play and the id of the match you are playing appears here. Paste it into | |
| 479 | <b>Match id</b> above, or open the link, and you get that match again — the same layout, the same | |
| 480 | spawns, the same bots and the same seed. Every playing of it is its own row on the leaderboard. | |
| 481 | <p class="empty">Press Play and the id of the match you are playing appears here. Open the link, or | |
| 482 | paste the whole link into <b>Match id</b> above, and you get that match again — the same layout, | |
| 483 | the same spawns, the same bots and the same seed; the id on its own keeps everything but the map. | |
| 484 | Every playing of it is its own row on the leaderboard. | |
| 481 | 485 | The map's own name is a link too: it opens this layout and nothing else of the match, which is how |
| 482 | 486 | a map you liked is handed to somebody who has never played it.</p> |
| 483 | 487 | </section>`; |
src/server.test.ts +5 −3
| @@ -9,6 +9,7 @@ import { IDLE_SECONDS, openDatabase } from "./db.ts"; | ||
| 9 | 9 | import { measureExample } from "./cost.ts"; |
| 10 | 10 | import { GAMES } from "./games.ts"; |
| 11 | 11 | import { matchOf, playKey } from "./matchid.ts"; |
| 12 | import { LONGEST_PASTE } from "./matchlink.ts"; | |
| 12 | 13 | import { NOTHING_POSTED } from "./client/pending.ts"; |
| 13 | 14 | import { BAD_ID, BAD_MAP, UNDRAWN } from "./pages/game.ts"; |
| 14 | 15 | import { endpoints, escape } from "./pages/shell.ts"; |
| @@ -115,8 +116,9 @@ test("`/?match=` preselects a match to play again, and says so in the first pain | ||
| 115 | 116 | const id = "7f1c2a4e-1111-4000-8000-9b3d5e7a2c64"; |
| 116 | 117 | const body = await (await fetch(`${base}/?match=${id}&map=generated`)).text(); |
| 117 | 118 | // The field the client validates again is served holding it, the section |
| 118 | // under the stage names it, and the link beside it plays it again. | |
| 119 | expect(body).toContain(`id="matchid" maxlength="64"`); | |
| 119 | // under the stage names it, and the link beside it plays it again. It is as | |
| 120 | // long as a link, not as long as an id, because a whole link goes in it. | |
| 121 | expect(body).toContain(`id="matchid" maxlength="${LONGEST_PASTE}"`); | |
| 120 | 122 | expect(body).toContain(`value="${id}"`); |
| 121 | 123 | expect(body).toContain(`<code id="playing">${id}</code>`); |
| 122 | 124 | expect(body).toContain(`href="/?match=${id}&map=generated"`); |
| @@ -218,7 +220,7 @@ test("a page nobody linked to is the page it always was: no id, a fresh match, d | ||
| 218 | 220 | // person who followed no link gets is the markup they always got. |
| 219 | 221 | expect(body).not.toContain("selected"); |
| 220 | 222 | // The field is there and it is empty: an id is something a person supplies. |
| 221 | expect(body).toContain(`id="matchid" maxlength="64"`); | |
| 223 | expect(body).toContain(`id="matchid" maxlength="${LONGEST_PASTE}"`); | |
| 222 | 224 | expect(body).not.toMatch(/id="matchid"[^>]*value=/); |
| 223 | 225 | }); |
| 224 | 226 |
new above
offline