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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/cs-engine:torque/215-a-pasted-link-plays-the-match-it-names-t → main 1 commit fork

  • Merged. torque/215-a-pasted-link-plays-the-match-it-names-t was merged into main as 06191b2.

Review

Sign in to review this change.
8 files changed +390 −21
README.md +26 −7
@@ -152,14 +152,20 @@ a plan and not an error.
152152
153153 ## A match you can play again
154154
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.
159161
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.
163169 - **Link to it.** `/?match=<id>&map=<de_forge|generated|gen_…>` preselects both.
164170 The page's own **Link to this match**, under the stage, is that URL for
165171 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
187193 reaches `generateLevel`. Anything else is a fresh match and a sentence saying
188194 so, never an error page and never a refusal to play.
189195
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
190206 ## A map you can keep
191207
192208 A generated map is carved from a number, and that number has a name: `gen_14u5j6g`,
@@ -1045,6 +1061,9 @@ never checked itself.
10451061 above, the `<match>--<nonce>` key one playing of a match is stored under, and
10461062 the match read back out of it — and the parser, the `?match=` query and the
10471063 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;
10481067 `src/who.ts` verifies the forge's signed identity headers, `src/limit.ts` is a
10491068 token bucket per caller (the clock is a parameter, so its test does not wait),
10501069 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
14061406 expect(stage.by("share").attributes.href).toBe(`/?match=${PASTED}&map=de_forge`);
14071407 });
14081408
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
14091517 // --- the round's plan -------------------------------------------------------
14101518
14111519 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";
7979 import { mapFrom, mapLink, nameOf, seedOf, type MapChoice } from "../mapname.ts";
8080 import { dailyOn, DAILY_LENGTH, saidToday } from "../daily.ts";
8181 import { matchFrom, playKey } from "../matchid.ts";
82 import { pastedLink } from "../matchlink.ts";
8283 import { describe, localPlan, parsePlan, type RoundPlan } from "../plan/plan.ts";
8384 import type { Situation } from "../plan/situation.ts";
8485 import { DE_FORGE } from "./map.ts";
@@ -773,6 +774,55 @@ export function boot(host: Host): Frame {
773774 return typed !== "" && seedOf(typed) === null;
774775 };
775776
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
776826 /** The match the next Play will be, which is what "generated" draws its map from. */
777827 const nextMatch = (): string => matchFrom(idField.value.trim()) ?? (drawn ??= host.randomUUID());
778828
@@ -830,8 +880,16 @@ export function boot(host: Host): Frame {
830880 planAlt.textContent = previewText(level);
831881 }
832882
833 for (const field of [mapField, seedField, idField])
883 for (const field of [mapField, seedField])
834884 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 });
835893
836894 /**
837895 * 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 {
896954 * a person can type into it and neither side trusts the other. Anything
897955 * that is not an id is a fresh match and a sentence saying so; an empty
898956 * 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.
900959 *
901960 * The id and the row key are not the same string. The id is the match — the
902961 * 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 {
905964 * leaderboard rather than on top of it, and the key still reads back as the
906965 * match it was a playing of. A fresh match is its own key, as before.
907966 */
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();
911968 // The id the menu already named a map for, if it drew one, so the map that
912969 // was promised is the map that is played. A fresh one otherwise, and a
913970 // fresh one for the nonce on the row key when an id was pasted.
src/pages/game.test.ts +15
@@ -18,6 +18,7 @@ import { join } from "node:path";
1818 import { IDLE_SECONDS, openDatabase, type Player, saveMatch, standings } from "../db.ts";
1919 import { dailyOn, NOT_A_LADDER, RANKED_BY, saidToday } from "../daily.ts";
2020 import { playKey } from "../matchid.ts";
21 import { LONGEST_PASTE } from "../matchlink.ts";
2122 import type { Result } from "../result.ts";
2223 import { CARD, type Card } from "../client/weights.ts";
2324 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
185186 expect(bad).not.toMatch(/id="mapseed"[^>]*value=/);
186187 });
187188
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
188203 test("the boast about the bots is the card, formatted the way the page formats numbers", () => {
189204 const said = boast(gamePage(db(), options));
190205 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
1515 import { NOTHING_POSTED } from "../client/pending.ts";
1616 import { CARD, type Card } from "../client/weights.ts";
1717 import { mapFrom, mapLink, type MapChoice } from "../mapname.ts";
18 import { LONGEST_PASTE } from "../matchlink.ts";
1819 import type { Who } from "../who.ts";
1920 import { navFor, noscriptFor, othersHtml } from "./games.ts";
2021 import { STYLE as WEAPONS_STYLE, WEAPONS_SECTION } from "./weapons.ts";
@@ -322,7 +323,7 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => `
322323 <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label>
323324 </div>
324325 <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"
326327 autocomplete="off" spellcheck="false"${asked.match === null ? "" : ` value="${escape(asked.match)}"`}></label>
327328 <label>Map name<input id="mapseed" maxlength="12" placeholder="gen_14u5j6g"
328329 autocomplete="off" spellcheck="false"${
@@ -331,8 +332,10 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => `
331332 </div>
332333 <p class="hint" id="onid">${asked.badMatch
333334 ? 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>
336339 <p class="hint" id="onmap">${asked.badMap
337340 ? escape(BAD_MAP)
338341 : `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">
475478 namedMap(asked.map) === null ? (asked.match === null ? "—" : "the map its id carves") : escape(asked.map)
476479 }</a>
477480 · <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.
481485 The map's own name is a link too: it opens this layout and nothing else of the match, which is how
482486 a map you liked is handed to somebody who has never played it.</p>
483487 </section>`;
src/server.test.ts +5 −3
@@ -9,6 +9,7 @@ import { IDLE_SECONDS, openDatabase } from "./db.ts";
99 import { measureExample } from "./cost.ts";
1010 import { GAMES } from "./games.ts";
1111 import { matchOf, playKey } from "./matchid.ts";
12 import { LONGEST_PASTE } from "./matchlink.ts";
1213 import { NOTHING_POSTED } from "./client/pending.ts";
1314 import { BAD_ID, BAD_MAP, UNDRAWN } from "./pages/game.ts";
1415 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
115116 const id = "7f1c2a4e-1111-4000-8000-9b3d5e7a2c64";
116117 const body = await (await fetch(`${base}/?match=${id}&map=generated`)).text();
117118 // 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}"`);
120122 expect(body).toContain(`value="${id}"`);
121123 expect(body).toContain(`<code id="playing">${id}</code>`);
122124 expect(body).toContain(`href="/?match=${id}&amp;map=generated"`);
@@ -218,7 +220,7 @@ test("a page nobody linked to is the page it always was: no id, a fresh match, d
218220 // person who followed no link gets is the markup they always got.
219221 expect(body).not.toContain("selected");
220222 // 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}"`);
222224 expect(body).not.toMatch(/id="matchid"[^>]*value=/);
223225 });
224226

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs