Public: anyone can read this; the crew can change it.

forked from trevato/cs

A result that did not land says so and is tried again, instead of vanishing #203

Merged opened this

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

Merged

torque/cs-engine:torque/102-a-result-that-did-not-land-says-so-and-i → main 1 commit fork

  • Merged. torque/102-a-result-that-did-not-land-says-so-and-i was merged into main as 4da93cd.

Review

Sign in to review this change.
11 files changed +481 −80
README.md +12
@@ -77,6 +77,18 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
7777 (kills fit in the rounds, headshots fit in the kills, the clock fits in the
7878 rounds) and rejects the rest. Treat the leaderboard as a scoreboard among
7979 friends, not as a ranking.
80 - **A result that did not land says so.** The page posts the result of every
81 round as it is scored, and the line under the leaderboard says which of three
82 things happened to the last one: it is on the board, it is not on the board
83 *yet* and is being tried again, or it is not on the board and nothing more
84 will be tried. A post that threw or came back 429 or 5xx is kept as the one
85 pending round — never a queue, and always the newest numbers — and retried
86 from the frame loop with a doubling wait, four attempts at most, one in
87 flight. A 422, 409, 400, 403 or 413 is the server refusing the round itself,
88 so it is not retried and the line says the round is not on the board. The
89 line is a live region, so it is announced and not only drawn. Nothing is kept
90 across a visit: a round that never reaches the server before you close the
91 tab is gone, which is what the page says rather than promising otherwise.
8092
8193 ## A match you can play again
8294
src/client/main.test.ts +163 −2
@@ -30,6 +30,9 @@ import { boot, gameMeshes, NOT_AN_ID, type Frame } from "./main.ts";
3030 import { DE_FORGE } from "./map.ts";
3131 import { SHORT } from "./match.ts";
3232 import { LAYERS, materialTextures } from "./materials.ts";
33 import {
34 FIRST_WAIT, LONGEST_WAIT, LOST, MAX_TRIES, REFUSED, RETRYING, SAVED, waitAfter,
35 } from "./pending.ts";
3336 import { digest, snapshot, withoutEntropy } from "./replay.ts";
3437 import { WEAPONS } from "./weapons.ts";
3538 import { matchOf } from "../matchid.ts";
@@ -458,13 +461,16 @@ test("one POST per round scored, in the shape the server accepts", async () => {
458461 stage.step(FRAME);
459462 expect(stage.posts.length).toBe(1);
460463
461 // A round goes on the board: exactly one more.
464 // A round goes on the board: exactly one more. The post before it is let
465 // settle first, because there is at most one attempt in flight — in a browser
466 // the answer is milliseconds away and the next round is minutes.
462467 const world = worldOf(stage);
463468 if (world === null) throw new Error("the first frame drew nothing");
464469 world.match.score.ct = 1;
465470 stage.step(FRAME);
466471 stage.step(FRAME);
467472 expect(stage.posts.length).toBe(2);
473 await settled();
468474 const second = JSON.parse(stage.posts[1]?.body ?? "{}");
469475 expect(parseResult(second)).not.toBeNull();
470476 expect(second.rounds).toBe(1);
@@ -503,6 +509,160 @@ test("a leaderboard that cannot be reached is not a reason to stop playing", asy
503509 expect(stage.by("menu").hidden).toBe(true);
504510 });
505511
512 // --- a result that did not land says so and is tried again -----------------
513
514 /**
515 * Answer the next posts with these statuses, in order; `"throw"` is a fetch
516 * that never answered, and the last entry answers everything after it.
517 */
518 function answering(stage: FakeStage, ...script: (number | "throw")[]): void {
519 let at = 0;
520 stage.answer = () => {
521 const status = script[Math.min(at, script.length - 1)];
522 at += 1;
523 if (status === "throw" || status === undefined) return Promise.reject(new Error("offline"));
524 return Promise.resolve({
525 ok: status === 200,
526 status,
527 text: () => Promise.resolve("<h2>Leaderboard</h2>"),
528 });
529 };
530 }
531
532 /** Let the fetch behind the last post settle. Nothing here waits on a frame. */
533 const settled = (): Promise<void> => Bun.sleep(0);
534
535 /** What the line by the leaderboard says right now. */
536 const line = (stage: FakeStage): string | null => stage.by("saved").textContent;
537
538 test("a post that threw is kept, tried again after its wait, and the line says which it is", async () => {
539 const stage = playing();
540 answering(stage, "throw", 200);
541 stage.step(FRAME);
542 expect(stage.posts.length).toBe(1);
543 await settled();
544 // Nothing was written into the page, and the line admits it in plain words.
545 expect(stage.by("stats").innerHTML).toBe("");
546 expect(line(stage)).toBe(RETRYING);
547
548 // Frames keep coming and nothing is retried until the wait is up: a retry in
549 // a game loop is exactly where a flood would be written by accident.
550 stage.step(FRAME);
551 stage.step(FRAME);
552 expect(stage.posts.length).toBe(1);
553
554 // The wait runs out and the same round goes again — once, byte for byte.
555 stage.step(FIRST_WAIT);
556 expect(stage.posts.length).toBe(2);
557 expect(stage.posts[1]?.body).toBe(stage.posts[0]?.body);
558 await settled();
559 expect(line(stage)).toBe(SAVED);
560 expect(stage.by("stats").innerHTML).toBe("<h2>Leaderboard</h2>");
561
562 // And it is done: there is nothing pending, so nothing is posted again.
563 stage.step(LONGEST_WAIT);
564 stage.step(LONGEST_WAIT);
565 expect(stage.posts.length).toBe(2);
566 });
567
568 test("a 429 is not answered with a flood: it waits, then the same round lands", async () => {
569 const stage = playing();
570 answering(stage, 429, 200);
571 stage.step(FRAME);
572 await settled();
573 expect(stage.posts.length).toBe(1);
574 expect(line(stage)).toBe(RETRYING);
575
576 // Thirty frames of a running match, and the bucket is left alone the whole
577 // time: one attempt in flight at most, and none at all before the wait.
578 for (let frame = 0; frame < 30; frame++) stage.step(FRAME);
579 expect(stage.posts.length).toBe(1);
580
581 stage.step(FIRST_WAIT);
582 await settled();
583 expect(stage.posts.length).toBe(2);
584 expect(line(stage)).toBe(SAVED);
585 });
586
587 test("a 422 is refused, not retried, and the player is told the round is not on the board", async () => {
588 const stage = playing();
589 answering(stage, 422);
590 stage.step(FRAME);
591 await settled();
592 expect(stage.posts.length).toBe(1);
593 expect(line(stage)).toBe(REFUSED);
594 expect(stage.by("stats").innerHTML).toBe("");
595
596 // A refusal is about this round and not about this moment: waiting changes
597 // nothing, so nothing waits and nothing is sent again.
598 stage.step(FIRST_WAIT);
599 stage.step(LONGEST_WAIT);
600 expect(stage.posts.length).toBe(1);
601 // And the match is exactly as it was: still running, still drawing.
602 expect(stage.by("menu").hidden).toBe(true);
603 expect(stage.draws.length).toBe(3);
604 });
605
606 test("the round retried is the newest one, and there is never more than one", async () => {
607 const stage = playing();
608 answering(stage, "throw", "throw", 200);
609 stage.step(FRAME);
610 await settled();
611 const world = worldOf(stage);
612 if (world === null) throw new Error("the first frame drew nothing");
613
614 // A round is scored while the first post is still unsent. It replaces it —
615 // a fresh round waits for no backoff — and the older snapshot is gone.
616 world.match.score.ct = 1;
617 stage.step(FRAME);
618 await settled();
619 expect(stage.posts.length).toBe(2);
620 expect(JSON.parse(stage.posts[1]?.body ?? "{}").rounds).toBe(1);
621
622 // The retry is that newer round and not the stale one, and there is one of it.
623 stage.step(FIRST_WAIT);
624 await settled();
625 expect(stage.posts.length).toBe(3);
626 expect(JSON.parse(stage.posts[2]?.body ?? "{}").rounds).toBe(1);
627 expect(line(stage)).toBe(SAVED);
628 stage.step(LONGEST_WAIT);
629 expect(stage.posts.length).toBe(3);
630 });
631
632 test("a match that ends offline is tried again to a cap, and then the page says it is gone", async () => {
633 const stage = playing();
634 answering(stage, "throw");
635 stage.step(FRAME);
636 await settled();
637 const world = worldOf(stage);
638 if (world === null) throw new Error("the first frame drew nothing");
639
640 // The last round of the match: the post says so, and nothing more is played
641 // after it — so the retries are all this result gets.
642 world.match.score.ct = 1;
643 world.match.phase = "match_over";
644 stage.step(FRAME);
645 await settled();
646 expect(stage.posts.length).toBe(2);
647 expect(JSON.parse(stage.posts[1]?.body ?? "{}").finished).toBe(true);
648 expect(stage.by("menu").hidden).toBe(false);
649
650 // Three more attempts, each after twice the wait of the last, and the frames
651 // in between cost nothing. Four in all for this round, and then it stops.
652 for (let attempt = 1; attempt < MAX_TRIES; attempt++) {
653 stage.step(waitAfter(attempt));
654 await settled();
655 expect(stage.posts.length).toBe(attempt + 2);
656 expect(JSON.parse(stage.posts[attempt + 1]?.body ?? "{}").finished).toBe(true);
657 }
658 expect(line(stage)).toBe(LOST);
659
660 // Capped: a tab left open all afternoon posts nothing more.
661 for (let frame = 0; frame < 10; frame++) stage.step(LONGEST_WAIT);
662 expect(stage.posts.length).toBe(MAX_TRIES + 1);
663 expect(line(stage)).toBe(LOST);
664 });
665
506666 test("F and the button put the stage on the whole screen, and take it back", () => {
507667 const stage = start(fakeStage());
508668 const full = stage.by("full");
@@ -718,9 +878,10 @@ test("two worlds from one id fold to the same digest, and another id folds elsew
718878 expect(run(PASTED)).not.toBe(run(`${PASTED.slice(0, -1)}5`));
719879 });
720880
721 test("a rematch is a new row: the id is the match, the posted key is this playing of it", () => {
881 test("a rematch is a new row: the id is the match, the posted key is this playing of it", async () => {
722882 const stage = pasted(PASTED);
723883 stage.step(FRAME);
884 await settled();
724885 const first = JSON.parse(stage.posts[0]?.body ?? "{}");
725886 // The row key is not the id: it is the id with this play's nonce on it, so
726887 // the leaderboard cannot fold two games of one match into one row — and it
src/client/main.ts +95 −17
@@ -12,6 +12,16 @@
1212 * map and the round are both drawn from. What is stored is a key for this
1313 * playing of it, so a rematch is a row of its own.
1414 *
15 * A round that did not land is not lost quietly. The result of every round is
16 * posted as it is scored, and a post that threw or came back anything but 200
17 * is kept as the one pending round and tried again — on the next round's post
18 * and from the frame loop once its backoff is up, at most one attempt in
19 * flight. `pending.ts` holds that policy and the sentence the page shows for it:
20 * saved, not saved yet and retrying, or not on the board and not being tried,
21 * which is what a 422 or a 409 means. Nothing about it blocks a frame, and a
22 * round that never lands before the tab closes is gone — the line says that
23 * rather than promising a queue.
24 *
1525 * Booting does not freeze the page. This game's atlas is nine 128-pixel layers
1626 * and about 170 ms of arithmetic, so `boot` paints one layer per frame through
1727 * the engine's `painter` and builds the renderer on the frame the last row
@@ -43,6 +53,7 @@ import type { Level } from "./level.ts";
4353 import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
4454 import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts";
4555 import { meshFor } from "./models.ts";
56 import { nextStep, type Answer } from "./pending.ts";
4657 import { buildScene, createScene, SKY, type Scene } from "./scene.ts";
4758 import { createSpectator, cycle, follow } from "./spectate.ts";
4859 import { buyable, WEAPONS } from "./weapons.ts";
@@ -119,8 +130,12 @@ export type Store = {
119130 setItem(key: string, value: string): void;
120131 };
121132
122 /** The two things this file reads off a response to POST /api/match. */
123 export type Reply = { readonly ok: boolean; text(): Promise<string> };
133 /**
134 * What this file reads off a response to POST /api/match: whether it landed,
135 * the status it was refused with — a 429 is worth another attempt, a 422 is
136 * not — and the leaderboard a 200 carries.
137 */
138 export type Reply = { readonly ok: boolean; readonly status: number; text(): Promise<string> };
124139
125140 /**
126141 * All this frame asks of a renderer: one draw call of one scene, and one mesh
@@ -191,6 +206,9 @@ export function boot(host: Host): Frame {
191206 const buyPanel = by("buy");
192207 const touchPanel = by("touch");
193208 const stats = by("stats");
209 // The one line about whether what you played is on that board. It is a live
210 // region in the page, so what is written here is announced and not only drawn.
211 const savedLine = by("saved");
194212 const nameField = by<Field>("name");
195213 const lengthField = by<Field>("length");
196214 const sideField = by<Field>("side");
@@ -421,7 +439,72 @@ export function boot(host: Host): Frame {
421439 };
422440
423441 // --- results, sent as the match goes rather than only at the end
424 async function postResult(final: boolean): Promise<void> {
442 /**
443 * The one round that has been played and not accepted, as the bytes that were
444 * or will be posted. One, never a queue: a newer round replaces an older one,
445 * so a retry can never put a stale snapshot over numbers the server already
446 * has. Null means everything played is on the board, or nothing more will be
447 * tried for what is not — `src/client/pending.ts` decides which, and the line
448 * by the leaderboard says which it was.
449 */
450 let unsent: string | null = null;
451 /** At most one attempt is ever in flight. */
452 let sending = false;
453 /** Attempts made on `unsent`, the first included, and the earliest next one. */
454 let tries = 0;
455 let notBefore = 0;
456
457 /** The line by the leaderboard, which is a live region: it is announced. */
458 const say = (line: string): void => {
459 savedLine.textContent = line;
460 };
461
462 /**
463 * Send the pending round, if there is one, nothing is in flight and its
464 * backoff has run out. Called on every frame — four comparisons and usually
465 * nothing — and from the post below, so a retry rides the frame the loop was
466 * already running rather than a timer of its own. Nothing here is awaited by
467 * the frame: the fetch settles whenever it settles.
468 */
469 function send(now: number): void {
470 if (unsent === null || sending || now < notBefore) return;
471 const body = unsent;
472 sending = true;
473 tries += 1;
474 void (async (): Promise<void> => {
475 let answer: Answer = { ok: false, status: null };
476 try {
477 const response = await host.fetch("/api/match", {
478 method: "POST",
479 headers: { "content-type": "application/json" },
480 body,
481 });
482 answer = { ok: response.ok, status: response.status };
483 // The leaderboard the server sent back, which is only ever sent on a 200.
484 if (response.ok) stats.innerHTML = await response.text();
485 } catch {
486 // A leaderboard that cannot be reached is not a reason to stop playing —
487 // it is a reason to try again and to say so, which is what settles here.
488 }
489 sending = false;
490 const step = nextStep(answer, tries);
491 // Only the round that was actually sent is settled: a newer one arrived
492 // while this was in flight is still pending, and is sent next.
493 if (!step.keep && unsent === body) unsent = null;
494 notBefore = step.keep ? win.performance.now() + step.wait : 0;
495 if (!step.keep) tries = 0;
496 say(step.line);
497 })();
498 }
499
500 /**
501 * The round just scored, as the server takes it. It becomes the one pending
502 * result — replacing whatever had not landed yet, which is older by
503 * definition — and is sent at once: a fresh round waits for no backoff,
504 * because a match scores a round every few minutes and the flood a backoff is
505 * for is a retry, not a player.
506 */
507 function postResult(final: boolean): void {
425508 if (world === null) return;
426509 const me = world.me;
427510 const body = {
@@ -439,19 +522,10 @@ export function boot(host: Host): Frame {
439522 finished: final,
440523 seconds: Math.round(world.time),
441524 };
442 try {
443 const response = await host.fetch("/api/match", {
444 method: "POST",
445 headers: { "content-type": "application/json" },
446 body: JSON.stringify(body),
447 });
448 if (response.ok) {
449 const html = await response.text();
450 stats.innerHTML = html;
451 }
452 } catch {
453 // A leaderboard that cannot be reached is not a reason to stop playing.
454 }
525 unsent = JSON.stringify(body);
526 tries = 0;
527 notBefore = 0;
528 send(win.performance.now());
455529 }
456530
457531 function start(): void {
@@ -569,6 +643,10 @@ export function boot(host: Host): Frame {
569643 play.disabled = false;
570644 return;
571645 }
646 // The round that has not landed, tried again on the frame its backoff runs
647 // out — including after the match is over, when this is the only frame left
648 // that runs. It is four comparisons and no work at all in the common case.
649 send(win.performance.now());
572650 if (world === null || !running) return;
573651
574652 const me = world.me;
@@ -598,7 +676,7 @@ export function boot(host: Host): Frame {
598676 const played = world.match.score.t + world.match.score.ct;
599677 if (played !== posted) {
600678 posted = played;
601 void postResult(world.match.phase === "match_over");
679 postResult(world.match.phase === "match_over");
602680 }
603681 if (world.match.phase === "match_over" && running) {
604682 running = false;
src/client/pending.test.ts added +44
@@ -0,0 +1,44 @@
1 /**
2 * The policy on its own: which answers are worth another attempt, how long the
3 * next one waits, and which sentence the page shows. `main.test.ts` proves the
4 * frame obeys it against the stub host; this proves the rules themselves.
5 */
6
7 import { expect, test } from "bun:test";
8 import {
9 FIRST_WAIT, LONGEST_WAIT, LOST, MAX_TRIES, nextStep, refuses, RETRYING, REFUSED, SAVED, waitAfter,
10 } from "./pending.ts";
11
12 test("a status the server chose about this round is refused; one about this moment is not", () => {
13 // The round itself: the same bytes posted again cannot change any of these.
14 for (const status of [400, 403, 409, 413, 422]) expect(refuses(status)).toBe(true);
15 // This moment: the bucket refills, the server comes back, the network returns.
16 for (const status of [429, 500, 502, 503]) expect(refuses(status)).toBe(false);
17 // A fetch that never answered has no status at all, and is worth trying again.
18 expect(refuses(null)).toBe(false);
19 });
20
21 test("the wait doubles and is capped, so a 429 is never answered with a flood", () => {
22 expect(waitAfter(1)).toBe(FIRST_WAIT);
23 expect(waitAfter(2)).toBe(FIRST_WAIT * 2);
24 expect(waitAfter(3)).toBe(FIRST_WAIT * 4);
25 expect(waitAfter(30)).toBe(LONGEST_WAIT);
26 });
27
28 test("a round that landed is done, and the page says so", () => {
29 expect(nextStep({ ok: true, status: 200 }, 1)).toEqual({ keep: false, line: SAVED, wait: 0 });
30 });
31
32 test("a refusal is not retried: the player is told the round is not on the board", () => {
33 expect(nextStep({ ok: false, status: 422 }, 1)).toEqual({ keep: false, line: REFUSED, wait: 0 });
34 expect(nextStep({ ok: false, status: 409 }, 1)).toEqual({ keep: false, line: REFUSED, wait: 0 });
35 });
36
37 test("a 429 and a fetch that threw are kept, with a growing wait, up to the cap", () => {
38 expect(nextStep({ ok: false, status: 429 }, 1)).toEqual({ keep: true, line: RETRYING, wait: FIRST_WAIT });
39 expect(nextStep({ ok: false, status: null }, 2)).toEqual({ keep: true, line: RETRYING, wait: FIRST_WAIT * 2 });
40 // The last attempt allowed is not kept: four tries, and then the page admits
41 // the round is gone rather than retrying for as long as the tab is open.
42 expect(nextStep({ ok: false, status: 500 }, MAX_TRIES - 1).keep).toBe(true);
43 expect(nextStep({ ok: false, status: 500 }, MAX_TRIES)).toEqual({ keep: false, line: LOST, wait: 0 });
44 });
src/client/pending.ts added +78
@@ -0,0 +1,78 @@
1 /**
2 * The result that has not landed yet: what the server's answer to a posted
3 * round means, what the page says about it, and how long to wait before trying
4 * the same round again.
5 *
6 * It is a decision and nothing else — plain data in, plain data out — so the
7 * frame in `main.ts` keeps the one pending result and the clock, and this file
8 * keeps the policy: which statuses are worth a second attempt, how fast they
9 * may be attempted, and which of the four sentences below is the true one.
10 *
11 * The four sentences are three states, in the words a player reads: the round
12 * is on the board (`SAVED`), it is not on the board yet and the page is still
13 * trying (`RETRYING`), or it is not on the board and nothing more will be tried
14 * — because the server refused the round itself (`REFUSED`) or because it could
15 * not be reached in `MAX_TRIES` attempts (`LOST`). Nothing is kept across a
16 * visit: a round that could not be sent before the tab closes is gone, and the
17 * page says so rather than promising a queue it does not have.
18 */
19
20 /** What one attempt came back with. `status` is null when the fetch never answered. */
21 export type Answer = { ok: boolean; status: number | null };
22
23 /** What to do about that answer: keep the round for another attempt, or not. */
24 export type Next = {
25 /** Whether this round is still pending. False means nothing more is tried. */
26 keep: boolean;
27 /** The line the page shows by the leaderboard. */
28 line: string;
29 /** Milliseconds to wait before the next attempt. Zero when there is none. */
30 wait: number;
31 };
32
33 export const SAVED = "Round saved to the leaderboard.";
34 export const RETRYING = "Not saved yet — the leaderboard did not answer. Trying again.";
35 export const REFUSED = "The server refused this round: it is not on the leaderboard.";
36 export const LOST = "Not saved — the leaderboard could not be reached, so this round is not on the board.";
37
38 /** What the page says before anything has been posted. Rendered by the server. */
39 export const NOTHING_POSTED = "Rounds are saved to the leaderboard as they are played.";
40
41 /** How long the first retry waits; each further one waits twice as long. */
42 export const FIRST_WAIT = 2000;
43
44 /** However many times it doubles, no retry is ever further off than this. */
45 export const LONGEST_WAIT = 30_000;
46
47 /**
48 * How many attempts one round gets, the first one included. A retry loop inside
49 * a game loop is how a flood is written by accident, so the count is capped and
50 * the wait grows: four attempts over fourteen seconds, and then the page admits
51 * the round is gone.
52 */
53 export const MAX_TRIES = 4;
54
55 /**
56 * Whether the server said no to this round rather than to this moment. A 429 is
57 * this moment — the bucket refills — and so is a 5xx and a fetch that never
58 * answered. A 422 (not a result), a 409 (somebody else's row), a 400, a 403 or
59 * a 413 are the round itself: posting the same bytes again cannot change any of
60 * them, so nothing does.
61 */
62 export const refuses = (status: number | null): boolean =>
63 status !== null && status >= 400 && status < 500 && status !== 429;
64
65 /** How long the `tries`-th attempt waits before the next: 2s, 4s, 8s, capped. */
66 export const waitAfter = (tries: number): number =>
67 Math.min(FIRST_WAIT * 2 ** Math.max(0, tries - 1), LONGEST_WAIT);
68
69 /**
70 * What one answer means, given how many attempts this round has now had — the
71 * one that just answered included.
72 */
73 export function nextStep(answer: Answer, tries: number): Next {
74 if (answer.ok) return { keep: false, line: SAVED, wait: 0 };
75 if (refuses(answer.status)) return { keep: false, line: REFUSED, wait: 0 };
76 if (tries >= MAX_TRIES) return { keep: false, line: LOST, wait: 0 };
77 return { keep: true, line: RETRYING, wait: waitAfter(tries) };
78 }
src/client/stage.test-helper.ts +3 −3
@@ -123,7 +123,7 @@ export type FakeStage = {
123123 hold(code: string, down: boolean): void;
124124 /** Every POST /api/match, in order, and what the next one is answered with. */
125125 posts: Post[];
126 answer(post: Post): Promise<{ ok: boolean; text(): Promise<string> }>;
126 answer(post: Post): Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
127127 draws: Drawn[];
128128 /** Every mesh swapped under a name after construction: a match on another map. */
129129 replaced: { name: string; mesh: Mesh }[];
@@ -155,7 +155,7 @@ export type FakeStage = {
155155 * contract between the page and this file, so `src/server.test.ts` checks the
156156 * served page against this same list rather than a second copy of it.
157157 */
158 export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "name", "length", "side", "sens", "map", "matchid", "onid", "onmap", "play", "prep", "resume", "quit", "esc", "pauseid", "playing", "mapname", "share", "fps", "full"];
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"];
159159
160160 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
161161 const HIDDEN = ["pause", "buy", "touch", "full"];
@@ -277,7 +277,7 @@ export function fakeStage(
277277 stub.hold(code, true);
278278 stub.hold(code, false);
279279 },
280 answer: () => Promise.resolve({ ok: true, text: () => Promise.resolve("<h2>Leaderboard</h2>") }),
280 answer: () => Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve("<h2>Leaderboard</h2>") }),
281281 install() {
282282 const globals = globalThis as Record<string, unknown>;
283283 // Saved one key at a time, and whether it was there at all: `fetch` is
src/games.ts +8
@@ -295,6 +295,14 @@ export type Game = {
295295 * of them drift apart. Neither other bundle moved a byte: nothing outside
296296 * `src/client/`, `src/pages/game.ts` and the new module changed, and neither of
297297 * the other two games imports any of them. Measured 2026-09-20.
298 * #102 made a result that did not land say so and be tried again —
299 * `src/client/pending.ts`, which is the policy and the four sentences, and the
300 * one pending round the frame keeps beside it — and the app grew 875 raw / 601
301 * gzipped bytes for it: 92,839 → 93,714 raw and 41,938 → 42,539 gzipped. Most
302 * of that is the sentences themselves, which a minifier cannot shorten. It fits
303 * the 96,000 / 43,500 #185 raised with 2,286 raw and 961 gzipped bytes to
304 * spare, so no ceiling moved. Neither other bundle changed a byte: the range
305 * posts nothing and ships none of this. Measured 2026-09-20.
298306 */
299307 export const GAMES: readonly Game[] = [
300308 {
src/pages/game.ts +15
@@ -11,6 +11,7 @@
1111
1212 import type { Database } from "bun:sqlite";
1313 import { displayName, headshotShare, ratio, recentMatches, standings } from "../db.ts";
14 import { NOTHING_POSTED } from "../client/pending.ts";
1415 import { CARD } from "../client/weights.ts";
1516 import type { Who } from "../who.ts";
1617 import { navFor, othersHtml } from "./games.ts";
@@ -186,6 +187,19 @@ const thisMatch = (asked: Asked): string => ` <section id="thismatch">
186187 spawns, the same bots and the same seed. Every playing of it is its own row on the leaderboard.</p>
187188 </section>`;
188189
190 /**
191 * The line under the leaderboard: whether the round that was just played is on
192 * it. `src/client/main.ts` rewrites it after every post — saved, not saved yet
193 * and retrying, or not on the board and not being tried again — and it is a
194 * live region, so what it says is announced rather than only drawn.
195 *
196 * It sits beside `#stats` rather than inside it because a result that lands
197 * replaces that whole section with the leaderboard the server sent back. It is
198 * rendered with the sentence that is true before anything has been posted, so
199 * the first paint is honest and nothing moves when it is filled in.
200 */
201 const SAVED_LINE = `<p class="hint" id="saved" role="status" aria-live="polite">${escape(NOTHING_POSTED)}</p>`;
202
189203 export function gamePage(db: Database, { who: visitor, asked = NOTHING_ASKED, ...options }: PageOptions): string {
190204 return page({
191205 ...options,
@@ -221,6 +235,7 @@ export function gamePage(db: Database, { who: visitor, asked = NOTHING_ASKED, ..
221235 sectionsHtml: `${thisMatch(asked)}
222236
223237 <section id="stats">${statsHtml(db)}</section>
238 ${SAVED_LINE}
224239
225240 <section>
226241 <h2>What this is</h2>
src/server.test.ts +5
@@ -5,6 +5,7 @@ import { join } from "node:path";
55 import { bundle, STATIC_DIR } from "../scripts/build.ts";
66 import { GAMES } from "./games.ts";
77 import { matchOf, playKey } from "./matchid.ts";
8 import { NOTHING_POSTED } from "./client/pending.ts";
89 import { BAD_ID } from "./pages/game.ts";
910 import { escape } from "./pages/shell.ts";
1011 import { sign } from "./who.ts";
@@ -65,6 +66,10 @@ test("the page carries every id and action the client reaches for", async () =>
6566 // last layer lands, and nothing on the page moves when it does.
6667 expect(body).toContain(`id="play" disabled>Play</button>`);
6768 expect(body).toContain(`id="prep">Preparing textures…</p>`);
69 // The line about whether a round landed is served as a live region holding
70 // the sentence that is true before anything has been posted: src/client/main.ts
71 // rewrites it after every post, and an announced line cannot be one it adds.
72 expect(body).toContain(`id="saved" role="status" aria-live="polite">${NOTHING_POSTED}</p>`);
6873 });
6974
7075 test("`/?match=` preselects a match to play again, and says so in the first paint", async () => {
static/app.0a342a74a2.js added +58

Diff not shown — a line 74,812 characters wide (limit 2,000). +58

View file
static/app.e24e7af2ef.js deleted −58

Diff not shown — a line 73,945 characters wide (limit 2,000). −58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs