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

forked from trevato/cs

The shooter's entry sheds its second concept: posting a result becomes its own module #262

Merged opened this

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

Merged

torque/cs-engine:torque/251-the-shooter-s-entry-sheds-its-second-con → main 1 commit fork

  • Merged. torque/251-the-shooter-s-entry-sheds-its-second-con was merged into main as b929124.

Review

Sign in to review this change.
9 files changed +487 −231
README.md +6
@@ -680,6 +680,12 @@ never checked itself.
680680 pressable. It takes the element to draw into, the round and the player, and a
681681 call that says which item was pressed; it reaches for no document and buys
682682 nothing, so `main` spends the money in the one place that holds a world),
683 `report` (the round on its way to the leaderboard: a world turned into the
684 result the server takes, posted as the round is scored, and the one that did
685 not land kept for another attempt on the policy `pending` decides — the
686 doubling wait, the cap of four, and the sentence a player reads. It takes a
687 `fetch`-shaped call, a clock and the two things it has to say out loud, so
688 `main` lends it the network and the elements and holds no bytes itself),
683689 `bomb` (the objective: `giveBomb`, `dropBomb`, `updateBomb` and the
684690 `useProgress` a readout draws its bar from — carrying it, planting it on a
685691 site, cutting the wire with a kit or without one; it hands back what the
src/client/main.test.ts +14 −115
@@ -2,10 +2,12 @@
22 * The frame itself, booted against the stub page in `stage.test-helper.ts`:
33 * the ids it looks up, the match a click starts, how many fixed steps one frame
44 * is worth, what the buy overlay does to the input, what losing the pointer
5 * means, and what it posts to the leaderboard. The buy panel itself is not in
6 * here: `buy.test.ts` holds the shelf and the prices, and what is left below is
7 * the wiring — the keys that open and close it, the pointer it lets go, and the
8 * money a press costs the world. Nothing here is a browser and
5 * means, and what it posts to the leaderboard. Two of those concepts are not in
6 * here any more: `buy.test.ts` holds the shelf and the prices, and
7 * `report.test.ts` holds the round in the page's hand — the retry, the backoff
8 * and the cap. What is left of both below is the wiring — the keys that open
9 * and close the panel, the money a press costs the world, the frame that asks
10 * for a post and the page the answer lands in. Nothing here is a browser and
911 * nothing is timed by a real clock — the test decides when a frame happens and
1012 * how long it took.
1113 *
@@ -35,9 +37,7 @@ import { endRound, SHORT } from "./match.ts";
3537 import { LAYERS, materialTextures } from "./materials.ts";
3638 import { recorder } from "./paint.test-helper.ts";
3739 import { drawPreview, previewText } from "./preview.ts";
38 import {
39 FIRST_WAIT, LONGEST_WAIT, LOST, MAX_TRIES, REFUSED, RETRYING, SAVED, SENDING, waitAfter,
40 } from "./pending.ts";
40 import { FIRST_WAIT, LONGEST_WAIT, RETRYING, SAVED, SENDING } from "./pending.ts";
4141 import { headshotShare, resultOf, scoreLine, yourLine } from "./summary.ts";
4242 import { digest, snapshot, withoutEntropy } from "./replay.ts";
4343 import { WEAPONS } from "./weapons.ts";
@@ -739,6 +739,12 @@ test("a leaderboard that cannot be reached is not a reason to stop playing", asy
739739 });
740740
741741 // --- a result that did not land says so and is tried again -----------------
742 //
743 // The queue itself is not in here: `report.test.ts` holds what one answer does
744 // to the round in the page's hand — the backoff, the cap, the newest round
745 // winning — against a fetch and a clock of its own. What is below is the
746 // wiring: that the frame is what asks, and that what comes back lands in the
747 // page's live region and in the leaderboard beside it.
742748
743749 /**
744750 * Answer the next posts with these statuses, in order; `"throw"` is a fetch
@@ -764,7 +770,7 @@ const settled = (): Promise<void> => Bun.sleep(0);
764770 /** What the line by the leaderboard says right now. */
765771 const line = (stage: FakeStage): string | null => stage.by("saved").textContent;
766772
767 test("a post that threw is kept, tried again after its wait, and the line says which it is", async () => {
773 test("the frame is what retries: the wait passes, the same round goes again, the page says so", async () => {
768774 const stage = playing();
769775 answering(stage, "throw", 200);
770776 stage.step(FRAME);
@@ -794,113 +800,6 @@ test("a post that threw is kept, tried again after its wait, and the line says w
794800 expect(stage.posts.length).toBe(2);
795801 });
796802
797 test("a 429 is not answered with a flood: it waits, then the same round lands", async () => {
798 const stage = playing();
799 answering(stage, 429, 200);
800 stage.step(FRAME);
801 await settled();
802 expect(stage.posts.length).toBe(1);
803 expect(line(stage)).toBe(RETRYING);
804
805 // Thirty frames of a running match, and the bucket is left alone the whole
806 // time: one attempt in flight at most, and none at all before the wait.
807 for (let frame = 0; frame < 30; frame++) stage.step(FRAME);
808 expect(stage.posts.length).toBe(1);
809
810 stage.step(FIRST_WAIT);
811 await settled();
812 expect(stage.posts.length).toBe(2);
813 expect(line(stage)).toBe(SAVED);
814 });
815
816 test("a 422 is refused, not retried, and the player is told the round is not on the board", async () => {
817 const stage = playing();
818 answering(stage, 422);
819 stage.step(FRAME);
820 await settled();
821 expect(stage.posts.length).toBe(1);
822 expect(line(stage)).toBe(REFUSED);
823 expect(stage.by("stats").innerHTML).toBe("");
824
825 // A refusal is about this round and not about this moment: waiting changes
826 // nothing, so nothing waits and nothing is sent again.
827 stage.step(FIRST_WAIT);
828 stage.step(LONGEST_WAIT);
829 expect(stage.posts.length).toBe(1);
830 // And the match is exactly as it was: still running, still drawing.
831 expect(stage.by("menu").hidden).toBe(true);
832 expect(stage.draws.length).toBe(3);
833 });
834
835 test("the round retried is the newest one, and there is never more than one", async () => {
836 const stage = playing();
837 answering(stage, "throw", "throw", 200);
838 stage.step(FRAME);
839 await settled();
840 const world = worldOf(stage);
841 if (world === null) throw new Error("the first frame drew nothing");
842
843 // A round is scored while the first post is still unsent. It replaces it —
844 // a fresh round waits for no backoff — and the older snapshot is gone.
845 world.match.score.ct = 1;
846 stage.step(FRAME);
847 await settled();
848 expect(stage.posts.length).toBe(2);
849 expect(JSON.parse(stage.posts[1]?.body ?? "{}").rounds).toBe(1);
850
851 // The retry is that newer round and not the stale one, and there is one of it.
852 stage.step(FIRST_WAIT);
853 await settled();
854 expect(stage.posts.length).toBe(3);
855 expect(JSON.parse(stage.posts[2]?.body ?? "{}").rounds).toBe(1);
856 expect(line(stage)).toBe(SAVED);
857 stage.step(LONGEST_WAIT);
858 expect(stage.posts.length).toBe(3);
859 });
860
861 test("a match that ends offline is tried again to a cap, and then the page says it is gone", async () => {
862 const stage = playing();
863 answering(stage, "throw");
864 stage.step(FRAME);
865 await settled();
866 const world = worldOf(stage);
867 if (world === null) throw new Error("the first frame drew nothing");
868
869 // The last round of the match: the post says so, and nothing more is played
870 // after it — so the retries are all this result gets.
871 world.match.score.ct = 1;
872 world.match.phase = "match_over";
873 stage.step(FRAME);
874 await settled();
875 expect(stage.posts.length).toBe(2);
876 expect(JSON.parse(stage.posts[1]?.body ?? "{}").finished).toBe(true);
877 // The summary is up, and it does not pretend: the result is still in the
878 // page's hand, so the line under the score says it is not on the board.
879 expect(stage.by("summary").hidden).toBe(false);
880 expect(stage.by("sumsaved").textContent).toBe(SENDING);
881
882 // Three more attempts, each after twice the wait of the last, and the frames
883 // in between cost nothing. Four in all for this round, and then it stops.
884 for (let attempt = 1; attempt < MAX_TRIES; attempt++) {
885 stage.step(waitAfter(attempt));
886 await settled();
887 expect(stage.posts.length).toBe(attempt + 2);
888 expect(JSON.parse(stage.posts[attempt + 1]?.body ?? "{}").finished).toBe(true);
889 // Still in the page's hand until the last attempt is spent: the screen says
890 // the match is not on the board for every one of them.
891 expect(stage.by("sumsaved").textContent).toBe(attempt + 1 < MAX_TRIES ? SENDING : LOST);
892 }
893 expect(line(stage)).toBe(LOST);
894 // And when there is nothing left to try, the screen says the match is gone
895 // rather than leaving the sentence about sending it up for ever.
896 expect(stage.by("sumsaved").textContent).toBe(LOST);
897
898 // Capped: a tab left open all afternoon posts nothing more.
899 for (let frame = 0; frame < 10; frame++) stage.step(LONGEST_WAIT);
900 expect(stage.posts.length).toBe(MAX_TRIES + 1);
901 expect(line(stage)).toBe(LOST);
902 });
903
904803 test("F and the button put the stage on the whole screen, and take it back", () => {
905804 const stage = start(fakeStage());
906805 const full = stage.by("full");
src/client/main.ts +40 −108
@@ -36,15 +36,17 @@
3636 * choice — never per frame and never during a match — so what a player picks is
3737 * a place and not a number.
3838 *
39 * A round that did not land is not lost quietly. The result of every round is
40 * posted as it is scored, and a post that threw or came back anything but 200
41 * is kept as the one pending round and tried again — on the next round's post
42 * and from the frame loop once its backoff is up, at most one attempt in
43 * flight. `pending.ts` holds that policy and the sentence the page shows for it:
44 * saved, not saved yet and retrying, or not on the board and not being tried,
45 * which is what a 422 or a 409 means. Nothing about it blocks a frame, and a
46 * round that never lands before the tab closes is gone — the line says that
47 * rather than promising a queue.
39 * A round that did not land is not lost quietly, and none of that is in here
40 * either. `report.ts` turns the world into the result the server takes, posts
41 * it as the round is scored, and keeps the one that threw or came back anything
42 * but 200 for another attempt — asked from the frame loop once its backoff is
43 * up, at most one attempt in flight; `pending.ts` is the policy behind it and
44 * the sentence the page shows for it: saved, not saved yet and retrying, or not
45 * on the board and not being tried, which is what a 422 or a 409 means. What is
46 * here is the wiring — the fetch, the frame's clock, the live region by the
47 * leaderboard and the element a returned leaderboard is written into. Nothing
48 * about it blocks a frame, and a round that never lands before the tab closes
49 * is gone — the line says that rather than promising a queue.
4850 *
4951 * Booting does not freeze the page. This game's atlas is nine 128-pixel layers
5052 * and about 170 ms of arithmetic, so `boot` paints one layer per frame through
@@ -82,7 +84,7 @@ import { COMPETITIVE, SHORT, type Rules, type Team } from "./match.ts";
8284 import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts";
8385 import { meshFor } from "./models.ts";
8486 import { parsePacked } from "./packed.ts";
85 import { nextStep, NOTHING_POSTED, SENDING, type Answer } from "./pending.ts";
87 import { createReporter, type Reply } from "./report.ts";
8688 import { boardHtml, headline, resultOf, scoreLine, summarize, yourLine } from "./summary.ts";
8789 import { drawPreview, previewText } from "./preview.ts";
8890 import { buildScene, createScene, SKY, type Scene } from "./scene.ts";
@@ -208,11 +210,11 @@ export type Store = {
208210 };
209211
210212 /**
211 * What this file reads off a response to POST /api/match: whether it landed,
212 * the status it was refused with — a 429 is worth another attempt, a 422 is
213 * not — and the leaderboard a 200 carries.
213 * What an answer to a POST is, which is `report.ts`'s shape: the `Host` below
214 * still has to say what its `fetch` comes back with, and the plan endpoint
215 * reads the same three members off it.
214216 */
215 export type Reply = { readonly ok: boolean; readonly status: number; text(): Promise<string> };
217 export type { Reply };
216218
217219 /**
218220 * All this frame asks of a renderer: one draw call of one scene, and one mesh
@@ -625,102 +627,30 @@ export function boot(host: Host): Frame {
625627
626628 // --- results, sent as the match goes rather than only at the end
627629 /**
628 * The one round that has been played and not accepted, as the bytes that were
629 * or will be posted. One, never a queue: a newer round replaces an older one,
630 * so a retry can never put a stale snapshot over numbers the server already
631 * has. Null means everything played is on the board, or nothing more will be
632 * tried for what is not — `src/client/pending.ts` decides which, and the line
633 * by the leaderboard says which it was.
630 * What the summary says about the leaderboard, which is the reporter's answer
631 * and not this file's: the last thing said when everything played has been
632 * settled, and `SENDING` while anything has not. It never implies a match is
633 * on the board while bytes are still in the page's hand.
634634 */
635 let unsent: string | null = null;
636 /** At most one attempt is ever in flight. */
637 let sending = false;
638 /** Attempts made on `unsent`, the first included, and the earliest next one. */
639 let tries = 0;
640 let notBefore = 0;
641
642 /**
643 * The last thing said about a posted result. Kept because the screen a match
644 * ends on has to say where *this match* stands, and that is this sentence
645 * only while nothing is still waiting to be sent.
646 */
647 let said = NOTHING_POSTED;
648
649 /**
650 * What the summary says about the leaderboard: the last answer when
651 * everything played has been settled, and `SENDING` while anything has not —
652 * including a round that is being retried and one whose answer has not come
653 * back yet. It never implies a match is on the board while bytes are still in
654 * the frame's hand.
655 */
656 const sayMatch = (): void => {
657 sumSaved.textContent = unsent === null ? said : SENDING;
658 };
659
660 /** The line by the leaderboard, which is a live region: it is announced. */
661 const say = (line: string): void => {
662 said = line;
663 savedLine.textContent = line;
664 sayMatch();
665 };
666
667 /**
668 * Send the pending round, if there is one, nothing is in flight and its
669 * backoff has run out. Called on every frame — four comparisons and usually
670 * nothing — and from the post below, so a retry rides the frame the loop was
671 * already running rather than a timer of its own. Nothing here is awaited by
672 * the frame: the fetch settles whenever it settles.
673 */
674 function send(now: number): void {
675 if (unsent === null || sending || now < notBefore) return;
676 const body = unsent;
677 sending = true;
678 tries += 1;
679 void (async (): Promise<void> => {
680 let answer: Answer = { ok: false, status: null };
681 try {
682 const response = await host.fetch("/api/match", {
683 method: "POST",
684 headers: { "content-type": "application/json" },
685 body,
686 });
687 answer = { ok: response.ok, status: response.status };
688 // The leaderboard the server sent back, which is only ever sent on a 200.
689 if (response.ok) stats.innerHTML = await response.text();
690 } catch {
691 // A leaderboard that cannot be reached is not a reason to stop playing —
692 // it is a reason to try again and to say so, which is what settles here.
693 }
694 sending = false;
695 const step = nextStep(answer, tries);
696 // Only the round that was actually sent is settled: a newer one arrived
697 // while this was in flight is still pending, and is sent next.
698 if (!step.keep && unsent === body) unsent = null;
699 notBefore = step.keep ? win.performance.now() + step.wait : 0;
700 if (!step.keep) tries = 0;
701 say(step.line);
702 })();
635 function sayMatch(): void {
636 sumSaved.textContent = reporter.standing();
703637 }
704638
705639 /**
706 * The round just scored, as the server takes it. It becomes the one pending
707 * result — replacing whatever had not landed yet, which is older by
708 * definition — and is sent at once: a fresh round waits for no backoff,
709 * because a match scores a round every few minutes and the flood a backoff is
710 * for is a retry, not a player.
640 * The rounds this match has posted and where they stand. The bytes, the one
641 * pending round, the retry and its backoff are `report.ts`'s; what is here is
642 * the wiring — the network, the frame's clock, the live region by the
643 * leaderboard, and the element a 200's leaderboard is written into.
711644 */
712 function postResult(final: boolean): void {
713 if (world === null) return;
714 // The row is this playing of the match, not the match: two people who play
715 // one id, and one person who plays it twice, are three rows. The numbers
716 // themselves are `summary.ts`'s, which is also what the screen at the end
717 // of the match reads — so what a player is shown and what the server was
718 // told are one object built once.
719 unsent = JSON.stringify(resultOf(world, playId, nameField.value, final));
720 tries = 0;
721 notBefore = 0;
722 send(win.performance.now());
723 }
645 const reporter = createReporter({
646 fetch: (url, init) => host.fetch(url, init),
647 now: () => win.performance.now(),
648 say: (line) => {
649 savedLine.textContent = line;
650 sayMatch();
651 },
652 board: (html) => void (stats.innerHTML = html),
653 });
724654
725655 // --- which map, and what it is called -------------------------------------
726656
@@ -1074,9 +1004,11 @@ export function boot(host: Host): Frame {
10741004 }
10751005 // The round that has not landed, tried again on the frame its backoff runs
10761006 // out — including after the match is over, when this is the only frame left
1077 // that runs. It is four comparisons and no work at all in the common case.
1007 // that runs. The reporter is asked on every frame because this loop is the
1008 // only clock the page has; it is three comparisons and, in the common case,
1009 // no work at all.
10781010 const now = win.performance.now();
1079 send(now);
1011 reporter.send();
10801012 // The plan of the map the menu is offering, once the choice has stood still
10811013 // — and never while a match is running, where the frame belongs to the round
10821014 // and the menu is not on the screen to look at anyway.
@@ -1130,7 +1062,7 @@ export function boot(host: Host): Frame {
11301062 if (played !== posted || (over && !postedFinal)) {
11311063 posted = played;
11321064 postedFinal = over;
1133 postResult(over);
1065 reporter.post(world, playId, nameField.value, over);
11341066 }
11351067 if (world.match.phase === "match_over" && running) endMatch(world);
11361068
src/client/pending.test.ts +3 −2
@@ -1,7 +1,8 @@
11 /**
22 * 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.
3 * next one waits, and which sentence the page shows. `report.test.ts` proves
4 * the reporter obeys it against a scripted fetch and a clock it moves itself,
5 * and `main.test.ts` proves the frame is what asks; this proves the rules.
56 */
67
78 import { expect, test } from "bun:test";
src/client/pending.ts +7 −4
@@ -3,10 +3,13 @@
33 * round means, what the page says about it, and how long to wait before trying
44 * the same round again.
55 *
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.
6 * It is a decision and nothing else — plain data in, plain data out — so
7 * `report.ts` keeps the one pending result and the clock it is sent on, and
8 * this file keeps the policy it asks: which statuses are worth a second
9 * attempt, how fast they may be attempted, and which of the four sentences
10 * below is the true one. Two modules because they are two concepts: what an
11 * answer means is arithmetic on a status, and what to do about it is a round,
12 * a network and a clock.
1013 *
1114 * The four sentences are three states, in the words a player reads: the round
1215 * is on the board (`SAVED`), it is not on the board yet and the page is still
src/client/report.test.ts added +259
@@ -0,0 +1,259 @@
1 /**
2 * The result on its way, on its own: the bytes a round is posted as, what one
3 * attempt costs, and what happens to a round that did not land. Nothing here is
4 * a browser and nothing is timed by a real clock — the fetch is a script of
5 * statuses this file writes and the clock is a number this file moves, which is
6 * the whole point of the reporter taking both rather than reaching for them.
7 *
8 * `pending.test.ts` holds the rules themselves — which statuses are worth
9 * another attempt and how long the next one waits — and `main.test.ts` holds
10 * the wiring: that the frame posts a round as it is scored and that the page's
11 * live region says what came back. What is here is the queue in between.
12 *
13 * The world is a real one — `createWorld` on de_forge, ten players — with the
14 * figures written onto it, the way `summary.test.ts` builds one: what is under
15 * test is the sending and not the playing.
16 */
17
18 import { expect, test } from "bun:test";
19 import { DE_FORGE } from "./map.ts";
20 import { SHORT } from "./match.ts";
21 import {
22 FIRST_WAIT, LONGEST_WAIT, LOST, MAX_TRIES, NOTHING_POSTED, REFUSED, RETRYING, SAVED, SENDING, waitAfter,
23 } from "./pending.ts";
24 import { createReporter, MATCH_ENDPOINT, type Reporter } from "./report.ts";
25 import { parseResult, type Result } from "../result.ts";
26 import { createWorld, type World } from "./world.ts";
27
28 const ID = "7f1c2a4e-1111-4000-8000-9b3d5e7a2c64";
29 const BOARD = "<h2>Leaderboard</h2>";
30
31 /** A match nobody played, with numbers on it. */
32 function world(): World {
33 const made = createWorld(DE_FORGE, SHORT, "you", "ct", 5, 1234);
34 Object.assign(made.me, { kills: 9, deaths: 4, headshots: 3, damage: 1610.4 });
35 made.time = 613.7;
36 return made;
37 }
38
39 type Attempt = { url: string; method: string; headers: Record<string, string>; body: string };
40
41 type Harness = {
42 reporter: Reporter;
43 /** Every attempt the reporter made, in order, exactly as it made it. */
44 posts: Attempt[];
45 /** Every sentence it said by the leaderboard, and every board it handed back. */
46 lines: string[];
47 boards: string[];
48 /** The body of the n-th attempt, read back as the server would read it. */
49 body(n: number): Result;
50 /** Move the clock and give the reporter the frame it would have had. */
51 tick(ms: number): void;
52 };
53
54 /**
55 * A reporter wired to a script of answers: each entry answers one attempt, and
56 * the last one answers everything after it. `"throw"` is a fetch that never
57 * answered — offline, blocked, a connection that died.
58 */
59 function reporting(...script: (number | "throw")[]): Harness {
60 const posts: Attempt[] = [];
61 const lines: string[] = [];
62 const boards: string[] = [];
63 let at = 0;
64 let now = 0;
65 const reporter = createReporter({
66 fetch: (url, init) => {
67 posts.push({ url, ...init });
68 const status = script[Math.min(at, script.length - 1)] ?? 200;
69 at += 1;
70 if (status === "throw") return Promise.reject(new Error("offline"));
71 return Promise.resolve({ ok: status === 200, status, text: () => Promise.resolve(BOARD) });
72 },
73 now: () => now,
74 say: (line) => void lines.push(line),
75 board: (html) => void boards.push(html),
76 });
77 return {
78 reporter, posts, lines, boards,
79 body: (n) => JSON.parse(posts[n]?.body ?? "{}") as Result,
80 tick: (ms) => {
81 now += ms;
82 reporter.send();
83 },
84 };
85 }
86
87 /** Let the fetch behind the last attempt settle. Nothing here waits on a clock. */
88 const settled = (): Promise<void> => Bun.sleep(0);
89
90 test("a round is posted at once, as the bytes the server's gate accepts", async () => {
91 const run = reporting(200);
92 const match = world();
93 match.match.score.ct = 3;
94 match.match.score.t = 1;
95 expect(run.reporter.standing()).toBe(NOTHING_POSTED);
96
97 run.reporter.post(match, ID, "Vesper", false);
98 // A fresh round waits for no backoff: it is in flight before the next frame.
99 expect(run.posts.length).toBe(1);
100 expect(run.posts[0]?.url).toBe(MATCH_ENDPOINT);
101 expect(run.posts[0]?.method).toBe("POST");
102 expect(run.posts[0]?.headers["content-type"]).toBe("application/json");
103 const body = run.body(0);
104 expect(parseResult(body)).not.toBeNull();
105 expect(body.matchId).toBe(ID);
106 expect(body.name).toBe("Vesper");
107 // The map that was played is recorded with it, not a field somebody typed.
108 expect(body.map).toBe("de_forge");
109 expect(body.rounds).toBe(4);
110 expect(body.finished).toBe(false);
111 // Nothing has come back yet, so the match is not on the board yet.
112 expect(run.reporter.standing()).toBe(SENDING);
113
114 await settled();
115 expect(run.lines).toEqual([SAVED]);
116 // The leaderboard a 200 carries is handed back, once.
117 expect(run.boards).toEqual([BOARD]);
118 expect(run.reporter.standing()).toBe(SAVED);
119
120 // And it is done: there is nothing pending, so nothing is posted again.
121 run.tick(LONGEST_WAIT);
122 run.tick(LONGEST_WAIT);
123 expect(run.posts.length).toBe(1);
124 });
125
126 test("a fetch that fails once and then succeeds lands exactly one result", async () => {
127 const run = reporting("throw", 200);
128 run.reporter.post(world(), ID, "you", true);
129 await settled();
130 // Nothing was handed back, and the line admits it in plain words.
131 expect(run.boards).toEqual([]);
132 expect(run.lines).toEqual([RETRYING]);
133 expect(run.reporter.standing()).toBe(SENDING);
134
135 // Frames keep coming and nothing is retried until the wait is up: a retry in
136 // a game loop is exactly where a flood would be written by accident.
137 run.tick(FIRST_WAIT / 2);
138 run.tick(1);
139 expect(run.posts.length).toBe(1);
140
141 // The wait runs out and the same round goes again — once, byte for byte.
142 run.tick(FIRST_WAIT);
143 expect(run.posts.length).toBe(2);
144 expect(run.posts[1]?.body).toBe(run.posts[0]?.body);
145 await settled();
146 expect(run.lines).toEqual([RETRYING, SAVED]);
147 // One result on the board: two attempts, one of which the server ever saw.
148 expect(run.boards).toEqual([BOARD]);
149 expect(run.reporter.standing()).toBe(SAVED);
150 run.tick(LONGEST_WAIT);
151 expect(run.posts.length).toBe(2);
152 });
153
154 test("a 429 is not answered with a flood: it waits, then the same round lands", async () => {
155 const run = reporting(429, 200);
156 run.reporter.post(world(), ID, "you", false);
157 await settled();
158 expect(run.posts.length).toBe(1);
159 expect(run.lines).toEqual([RETRYING]);
160
161 // Thirty frames of a running match, and the bucket is left alone the whole
162 // time: one attempt in flight at most, and none at all before the wait.
163 for (let frame = 0; frame < 30; frame++) run.tick(1000 / 30);
164 expect(run.posts.length).toBe(1);
165
166 run.tick(FIRST_WAIT);
167 await settled();
168 expect(run.posts.length).toBe(2);
169 expect(run.lines[1]).toBe(SAVED);
170 });
171
172 test("a 422 is refused, not retried, and the player is told the round is not on the board", async () => {
173 const run = reporting(422);
174 run.reporter.post(world(), ID, "you", false);
175 await settled();
176 expect(run.posts.length).toBe(1);
177 expect(run.lines).toEqual([REFUSED]);
178 expect(run.boards).toEqual([]);
179
180 // A refusal is about this round and not about this moment: waiting changes
181 // nothing, so nothing waits and nothing is sent again.
182 run.tick(FIRST_WAIT);
183 run.tick(LONGEST_WAIT);
184 expect(run.posts.length).toBe(1);
185 // And it is not still in the page's hand: the refusal is the last word on it.
186 expect(run.reporter.standing()).toBe(REFUSED);
187 });
188
189 test("the round retried is the newest one, and there is never more than one", async () => {
190 const run = reporting("throw", "throw", 200);
191 const match = world();
192 run.reporter.post(match, ID, "you", false);
193 await settled();
194
195 // A round is scored while the first post is still unsent. It replaces it —
196 // a fresh round waits for no backoff — and the older snapshot is gone.
197 match.match.score.ct = 1;
198 run.reporter.post(match, ID, "you", false);
199 await settled();
200 expect(run.posts.length).toBe(2);
201 expect(run.body(1).rounds).toBe(1);
202
203 // The retry is that newer round and not the stale one, and there is one of it.
204 run.tick(FIRST_WAIT);
205 await settled();
206 expect(run.posts.length).toBe(3);
207 expect(run.body(2).rounds).toBe(1);
208 expect(run.reporter.standing()).toBe(SAVED);
209 run.tick(LONGEST_WAIT);
210 expect(run.posts.length).toBe(3);
211 });
212
213 test("a match that ends offline is tried again to a cap, and then it is gone", async () => {
214 const run = reporting("throw");
215 const match = world();
216 match.match.score.ct = 1;
217 run.reporter.post(match, ID, "you", true);
218 await settled();
219 expect(run.posts.length).toBe(1);
220 expect(run.body(0).finished).toBe(true);
221 // Still in the page's hand: the screen after the match may not say it landed.
222 expect(run.reporter.standing()).toBe(SENDING);
223
224 // Three more attempts, each after twice the wait of the last, and the frames
225 // in between cost nothing. Four in all for this round, and then it stops.
226 for (let attempt = 1; attempt < MAX_TRIES; attempt++) {
227 run.tick(waitAfter(attempt));
228 await settled();
229 expect(run.posts.length).toBe(attempt + 1);
230 expect(run.body(attempt).finished).toBe(true);
231 expect(run.reporter.standing()).toBe(attempt + 1 < MAX_TRIES ? SENDING : LOST);
232 }
233 expect(run.lines[run.lines.length - 1]).toBe(LOST);
234
235 // Capped: a tab left open all afternoon posts nothing more.
236 for (let frame = 0; frame < 10; frame++) run.tick(LONGEST_WAIT);
237 expect(run.posts.length).toBe(MAX_TRIES);
238 expect(run.reporter.standing()).toBe(LOST);
239 });
240
241 test("a round scored after one landed is its own attempt, on a fresh count", async () => {
242 const run = reporting(200, "throw", 200);
243 const match = world();
244 run.reporter.post(match, ID, "you", false);
245 await settled();
246 expect(run.reporter.standing()).toBe(SAVED);
247
248 // The next round throws. It gets the first wait and not the fourth: the count
249 // belongs to the round being sent, not to the match.
250 match.match.score.t = 1;
251 run.reporter.post(match, ID, "you", false);
252 await settled();
253 expect(run.lines).toEqual([SAVED, RETRYING]);
254 run.tick(FIRST_WAIT);
255 await settled();
256 expect(run.posts.length).toBe(3);
257 expect(run.body(2).rounds).toBe(1);
258 expect(run.reporter.standing()).toBe(SAVED);
259 });
src/client/report.ts added +146
@@ -0,0 +1,146 @@
1 /**
2 * The result on its way to the leaderboard: a round turned into the bytes the
3 * server takes, posted as it is scored, and kept for another attempt when it
4 * did not land.
5 *
6 * It holds the one thing a page can hold about a post — the round that has not
7 * been accepted yet, whether an attempt is in flight, how many have been made
8 * and the earliest the next one may go — and it asks `pending.ts` what every
9 * answer means. One pending round, never a queue: a newer round replaces an
10 * older one, so a retry can never put a stale snapshot over numbers the server
11 * already has, and nothing is kept across a visit.
12 *
13 * It is not a browser and it is not the frame. What it is given is a
14 * `fetch`-shaped call, a clock, and the two things it has to say out loud: the
15 * sentence by the leaderboard, and the leaderboard itself, which a 200 carries
16 * back. `main.ts` is the only file that knows which elements those are.
17 * Nothing here reaches for a document, a host or a global, and nothing it does
18 * is awaited by a frame: `send` starts an attempt and returns, and the fetch
19 * settles whenever it settles.
20 */
21
22 import { nextStep, NOTHING_POSTED, SENDING, type Answer } from "./pending.ts";
23 import { resultOf } from "./summary.ts";
24 import type { World } from "./world.ts";
25
26 /** Where a result goes. The same path `src/server.ts` gates results at. */
27 export const MATCH_ENDPOINT = "/api/match";
28
29 /**
30 * What this module reads off a response: whether it landed, the status it was
31 * refused with — a 429 is worth another attempt, a 422 is not — and the
32 * leaderboard a 200 carries.
33 */
34 export type Reply = { readonly ok: boolean; readonly status: number; text(): Promise<string> };
35
36 /** The one call this module makes. A browser's `fetch` is one. */
37 export type Posts = (
38 url: string,
39 init: { method: string; headers: Record<string, string>; body: string },
40 ) => Promise<Reply>;
41
42 /**
43 * What the page lends the reporter: the network, the clock the backoff is
44 * measured on — milliseconds, monotonic, `performance.now()` is one — and the
45 * two things that are said out loud. `say` is every answer, in the words
46 * `pending.ts` writes them; `board` is only ever a 200's body.
47 */
48 export type Wire = {
49 fetch: Posts;
50 now(): number;
51 say(line: string): void;
52 board(html: string): void;
53 };
54
55 /** What a page holds onto: the rounds it has posted and where they stand. */
56 export type Reporter = {
57 /**
58 * The round just scored, as the server takes it. It becomes the one pending
59 * result — replacing whatever had not landed yet, which is older by
60 * definition — and is sent at once: a fresh round waits for no backoff,
61 * because a match scores a round every few minutes and the flood a backoff is
62 * for is a retry, not a player.
63 */
64 post(world: World, playId: string, name: string, final: boolean): void;
65 /**
66 * Send the pending round, if there is one, nothing is in flight and its
67 * backoff has run out. Meant to be called on every frame — three comparisons
68 * and usually nothing.
69 */
70 send(): void;
71 /**
72 * Where the match stands, for the screen a match ends on: the last answer
73 * when everything played has been settled, and `SENDING` while anything has
74 * not — including a round being retried and one whose answer has not come
75 * back. It never implies a match is on the board while bytes are still here.
76 */
77 standing(): string;
78 };
79
80 export function createReporter(wire: Wire): Reporter {
81 /**
82 * The one round that has been played and not accepted, as the bytes that were
83 * or will be posted. Null means everything played is on the board, or nothing
84 * more will be tried for what is not — `pending.ts` decides which.
85 */
86 let unsent: string | null = null;
87 /** At most one attempt is ever in flight. */
88 let sending = false;
89 /** Attempts made on `unsent`, the first included, and the earliest next one. */
90 let tries = 0;
91 let notBefore = 0;
92 /**
93 * The last thing said about a posted result. Kept because the screen a match
94 * ends on has to say where *this match* stands, and that is this sentence
95 * only while nothing is still waiting to be sent.
96 */
97 let said = NOTHING_POSTED;
98
99 function send(): void {
100 if (unsent === null || sending || wire.now() < notBefore) return;
101 const body = unsent;
102 sending = true;
103 tries += 1;
104 void (async (): Promise<void> => {
105 let answer: Answer = { ok: false, status: null };
106 try {
107 const response = await wire.fetch(MATCH_ENDPOINT, {
108 method: "POST",
109 headers: { "content-type": "application/json" },
110 body,
111 });
112 answer = { ok: response.ok, status: response.status };
113 // The leaderboard the server sent back, which is only ever sent on a 200.
114 if (response.ok) wire.board(await response.text());
115 } catch {
116 // A leaderboard that cannot be reached is not a reason to stop playing —
117 // it is a reason to try again and to say so, which is what settles here.
118 }
119 sending = false;
120 const step = nextStep(answer, tries);
121 // Only the round that was actually sent is settled: a newer one that
122 // arrived while this was in flight is still pending, and is sent next.
123 if (!step.keep && unsent === body) unsent = null;
124 notBefore = step.keep ? wire.now() + step.wait : 0;
125 if (!step.keep) tries = 0;
126 said = step.line;
127 wire.say(step.line);
128 })();
129 }
130
131 return {
132 post(world, playId, name, final) {
133 // The row is this playing of the match, not the match: two people who
134 // play one id, and one person who plays it twice, are three rows. The
135 // numbers themselves are `summary.ts`'s, which is also what the screen at
136 // the end of the match reads — so what a player is shown and what the
137 // server was told are one object built once.
138 unsent = JSON.stringify(resultOf(world, playId, name, final));
139 tries = 0;
140 notBefore = 0;
141 send();
142 },
143 send,
144 standing: () => (unsent === null ? said : SENDING),
145 };
146 }
src/db.ts +2 −2
@@ -105,8 +105,8 @@ export const displayName = (row: { name: string; user_id: string }): string =>
105105 /**
106106 * How long a row may go without a post before it stops reading as live.
107107 *
108 * A match posts a result as every round is scored (`postResult` in
109 * `src/client/main.ts`), so the gap between two posts of a match somebody is
108 * A match posts a result as every round is scored (`post` in
109 * `src/client/report.ts`), so the gap between two posts of a match somebody is
110110 * playing has a known ceiling: the longest round the rules allow is freeze 12 s
111111 * + round 115 s + bomb 40 s + end delay 5 s = 172 s (`COMPETITIVE` in
112112 * `src/client/match.ts`), and a post that has to be retried adds at most another
src/games.ts +10
@@ -478,6 +478,16 @@ export type Game = {
478478 * set with 4,136 raw and 2,034 gzipped to spare, so no ceiling moved. Neither
479479 * other bundle changed a byte: nothing outside `src/client/` did. Measured
480480 * 2026-09-21.
481 * #251 moved the result posting out of the entry into `src/client/report.ts` —
482 * the world turned into the bytes the server takes, the one pending round, the
483 * retry and its backoff — and the app grew 117 raw / 83 gzipped bytes for it:
484 * 98,864 → 98,981 raw and 38,966 → 39,049 gzipped, measured on top of #250. It
485 * is a move and not a feature, and those bytes are the seam again: a module
486 * boundary the minifier will not inline across, the object the frame lends the
487 * reporter, and the endpoint now named rather than written inline. It fits the
488 * 103,000 / 41,000 #165 set with 4,019 raw and 1,951 gzipped to spare, so no
489 * ceiling moved. Neither other bundle changed a byte: the range and the example
490 * post nothing and ship none of this. Measured 2026-09-21.
481491 */
482492 export const GAMES: readonly Game[] = [
483493 {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs