Split The code is public; the app answers only to the crew.

A result the server refused vanishes without a word: the board quietly stops matching the match you played #235

Merged opened this

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

Merged

scout/cs:scout/200-a-result-the-server-refused-vanishes-wit → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/200-a-result-the-server-refused-vanishes-wit was merged into main as 1875a50.

Review

Sign in to review this change.
6 files changed +258 −21
README.md +7
@@ -712,6 +712,13 @@ identity that first sent it: anyone else posting it gets a 409. A database
712712 written before identity existed is migrated on open — those rows were never
713713 signed for, so they become the guests they always were.
714714
715 A post the server refuses does not vanish quietly: `src/client/posting.ts`
716 turns the status into a short, honest line — offline, rate-limited, or
717 someone else's match — shown beside the leaderboard until a later post
718 succeeds. Only the post that ends or leaves a match gets a second try, once,
719 after the `retry-after` the server sent (clamped to 1–10s) or about a second
720 otherwise; a per-round post just waits for the next round to try again.
721
715722 ## The bots
716723
717724 They are split in two. Getting to the site is a plan — `src/client/plan.ts`
src/client/main.ts +80 −20
@@ -21,6 +21,7 @@ import { clamp } from "./math.ts";
2121 import { MATCH_LENGTHS, RULES, SIDES, type MatchLength, type Rules, type Team } from "./match.ts";
2222 import type { MapName } from "./map.ts";
2323 import { MOTIONS, type Motion } from "./motion.ts";
24 import { isTerminalPost, postOutcome, retryDelayMs } from "./posting.ts";
2425 import { MAX_SCALE, nextRenderScale } from "./quality.ts";
2526 import { createRenderer, type Camera } from "./renderer.ts";
2627 import type { CrosshairStyle } from "./crosshair.ts";
@@ -60,6 +61,7 @@ function boot(): void {
6061 const radioPanel = $("radio");
6162 const touchPanel = $("touch");
6263 const stats = $("stats");
64 const postNote = $("post-note");
6365 const nameField = $<HTMLInputElement>("name");
6466 const lengthField = $<HTMLSelectElement>("length");
6567 const sideField = $<HTMLSelectElement>("side");
@@ -660,11 +662,60 @@ function boot(): void {
660662 // --- results, sent as the match goes rather than only at the end.
661663 // No name goes up: the server credits the result to whoever the forge says
662664 // posted it, and the name in the menu is only what the match calls you.
665
666 /** One try at the post: the status the server answered with, or `null` for a fetch that never landed. */
667 async function attemptPost(body: string): Promise<{ status: number | null; retryAfter: number | null; html: string | null }> {
668 try {
669 const response = await fetch("/api/match", {
670 method: "POST",
671 headers: { "content-type": "application/json" },
672 body,
673 });
674 const retryAfter = response.headers.get("retry-after");
675 return {
676 status: response.status,
677 retryAfter: retryAfter === null ? null : Number(retryAfter),
678 html: response.ok ? await response.text() : null,
679 };
680 } catch {
681 return { status: null, retryAfter: null, html: null };
682 }
683 }
684
685 const wait = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
686
687 /** Applies one attempt's outcome to the page: the board if it landed, the note either way. */
688 function applyPost(result: { html: string | null }, outcome: { note: string | null }): void {
689 if (result.html !== null) stats.innerHTML = result.html;
690 postNote.textContent = outcome.note ?? "";
691 show(postNote, outcome.note !== null);
692 }
693
694 /**
695 * Not "silently agree with whatever the server said": a failed post leaves
696 * `#post-note` saying why, and a later post — the next round, or the one
697 * retry below — clears it the moment it lands. A leaderboard that cannot
698 * be reached is still not a reason to stop playing.
699 *
700 * The promise this returns settles after the *first* attempt only — the
701 * caller at match_over shows the summary off the back of that, not off a
702 * retry that can still be waiting out a 429's retry-after. The retry itself
703 * runs on, unawaited, to land or fail behind the summary already on screen.
704 *
705 * `left` ends a match's posting the same way `final` does: it is the last
706 * word this match gets, sent once on the way to the menu, so it is the one
707 * other case worth the one retry a per-round post doesn't get.
708 */
663709 async function postResult(final: boolean, left = false): Promise<void> {
664710 if (world === null) return;
665711 const me = world.me;
666 const body = {
667 matchId,
712 // Frozen here, not read again: a note or a retry answering for a match
713 // the player has since left behind — a new one already under way by the
714 // time a slow fetch or the retry's own wait comes back — is a note about
715 // someone else's game, and `matchId` having moved on is how that's told.
716 const sentFor = matchId;
717 const body = JSON.stringify({
718 matchId: sentFor,
668719 map: world.map.name,
669720 rounds: world.match.score.t + world.match.score.ct,
670721 scoreMine: world.match.score[me.team],
@@ -676,20 +727,23 @@ function boot(): void {
676727 finished: final,
677728 left,
678729 seconds: Math.round(world.time),
679 };
680 try {
681 const response = await fetch("/api/match", {
682 method: "POST",
683 headers: { "content-type": "application/json" },
684 body: JSON.stringify(body),
685 });
686 if (response.ok) {
687 const html = await response.text();
688 stats.innerHTML = html;
689 }
690 } catch {
691 // A leaderboard that cannot be reached is not a reason to stop playing.
692 }
730 });
731 const terminal = isTerminalPost(final, left);
732
733 const first = await attemptPost(body);
734 if (matchId !== sentFor) return;
735 const outcome = postOutcome(first.status, terminal);
736 applyPost(first, outcome);
737 if (!outcome.retry) return;
738
739 void (async () => {
740 await wait(retryDelayMs(first.retryAfter));
741 const second = await attemptPost(body);
742 if (matchId !== sentFor) return;
743 // One retry, never a queue: whatever this second try says is final, so
744 // its note is read with `final: false` — no further retry to promise.
745 applyPost(second, postOutcome(second.status, false));
746 })();
693747 }
694748
695749 /**
@@ -736,6 +790,7 @@ function boot(): void {
736790 pauseMapLabel.textContent = world.map.name;
737791 posted = -1;
738792 savedRound = 0;
793 show(postNote, false);
739794 watching = world.me.id;
740795 controls.sensitivity = settings.sensitivity;
741796 controls.touchSensitivity = settings.touchSensitivity;
@@ -937,11 +992,13 @@ function boot(): void {
937992 const played = world.match.score.t + world.match.score.ct;
938993 if (played !== posted) {
939994 posted = played;
940 void postResult(world.match.phase === "match_over");
995 // The round that ends the match is posted once, below, by the
996 // match_over block — not here too, or a 429 that refused the first of
997 // the pair still sees the second, and each retries again.
998 if (world.match.phase !== "match_over") void postResult(false);
941999 }
9421000 if (world.match.phase === "match_over" && running) {
9431001 running = false;
944 posted = played;
9451002 document.exitPointerLock();
9461003 show(buyPanel, false);
9471004 show(radioPanel, false);
@@ -949,10 +1006,13 @@ function boot(): void {
9491006 // A decided match has no round to come back to.
9501007 clearSaved();
9511008 // The last post is the only one that says the match finished, and the
952 // score it carries is the one the overlay is about to show.
1009 // score it carries is the one the overlay is about to show. postResult
1010 // never rejects — a failed post is a note, not a throw — and it
1011 // settles after its first attempt, so a summary is never held up
1012 // behind the one retry a bad post still gets.
9531013 const summary = summarize(world);
9541014 const board = finalBoard(world);
955 void postResult(true).then(() => showSummary(summary, board), () => showSummary(summary, board));
1015 void postResult(true).then(() => showSummary(summary, board));
9561016 }
9571017
9581018 const view = watched(world, watching);
src/client/posting.test.ts added +107
@@ -0,0 +1,107 @@
1 /**
2 * `posting.ts` against every status the server can answer POST /api/match
3 * with, for both a per-round post and the final one — no fetch, no DOM.
4 */
5
6 import { expect, test } from "bun:test";
7 import { isTerminalPost, postOutcome, retryDelayMs } from "./posting.ts";
8
9 test("200 needs no word and no retry, whether it ended the match or not", () => {
10 expect(postOutcome(200, true)).toEqual({ note: null, retry: false });
11 expect(postOutcome(200, false)).toEqual({ note: null, retry: false });
12 });
13
14 test("a network failure says so and retries only when it was the final post", () => {
15 const final = postOutcome(null, true);
16 expect(final.retry).toBe(true);
17 expect(final.note).toContain("offline");
18
19 const perRound = postOutcome(null, false);
20 expect(perRound.retry).toBe(false);
21 expect(perRound.note).toContain("offline");
22 });
23
24 test("409 says the match belongs to another browser and never retries", () => {
25 expect(postOutcome(409, true)).toEqual({
26 note: "Not recorded — this match belongs to another browser.",
27 retry: false,
28 });
29 expect(postOutcome(409, false).retry).toBe(false);
30 });
31
32 test("413 says the result was too large and never retries", () => {
33 expect(postOutcome(413, true)).toEqual({
34 note: "Not recorded — the result was too large to send.",
35 retry: false,
36 });
37 expect(postOutcome(413, false).retry).toBe(false);
38 });
39
40 test("422 says the server didn't recognize the result and never retries", () => {
41 expect(postOutcome(422, true)).toEqual({
42 note: "Not recorded — the server didn't recognize this result.",
43 retry: false,
44 });
45 expect(postOutcome(422, false).retry).toBe(false);
46 });
47
48 test("429 says the server is rate-limiting and retries only when it was the final post", () => {
49 const final = postOutcome(429, true);
50 expect(final.retry).toBe(true);
51 expect(final.note).toContain("rate-limiting");
52
53 const perRound = postOutcome(429, false);
54 expect(perRound.retry).toBe(false);
55 expect(perRound.note).toContain("rate-limiting");
56 });
57
58 test("500 says the server had a problem and retries only when it was the final post", () => {
59 const final = postOutcome(500, true);
60 expect(final.retry).toBe(true);
61 expect(final.note).toContain("problem");
62
63 const perRound = postOutcome(500, false);
64 expect(perRound.retry).toBe(false);
65 expect(perRound.note).toContain("problem");
66 });
67
68 test("400 — bad JSON, which the server can also answer with — gets the generic line and never retries", () => {
69 expect(postOutcome(400, true)).toEqual({
70 note: "Not recorded — the server refused the result.",
71 retry: false,
72 });
73 expect(postOutcome(400, false).retry).toBe(false);
74 });
75
76 test("no wording ever claims a save that did not happen", () => {
77 for (const status of [null, 400, 409, 413, 422, 429, 500]) {
78 for (const final of [true, false]) {
79 const note = postOutcome(status, final).note;
80 expect(note).not.toBeNull();
81 expect(note as string).toStartWith("Not recorded");
82 }
83 }
84 });
85
86 test("leaving a match is as terminal as finishing one — the retry the README promises either way", () => {
87 // toMenu posts a live match as `postResult(false, true)`: not `final`, but `left`.
88 expect(isTerminalPost(false, true)).toBe(true);
89 expect(isTerminalPost(true, false)).toBe(true); // match_over
90 expect(isTerminalPost(false, false)).toBe(false); // an ordinary round, neither
91
92 const leftWhileRateLimited = postOutcome(429, isTerminalPost(false, true));
93 expect(leftWhileRateLimited.retry).toBe(true);
94 expect(leftWhileRateLimited.note).toContain("rate-limiting");
95 });
96
97 test("retry-after is honoured within a 1-10s floor and ceiling", () => {
98 expect(retryDelayMs(5)).toBe(5000);
99 expect(retryDelayMs(0)).toBe(1000);
100 expect(retryDelayMs(-3)).toBe(1000);
101 expect(retryDelayMs(60)).toBe(10000);
102 });
103
104 test("retry-after missing or not a number falls back to about a second", () => {
105 expect(retryDelayMs(null)).toBe(1000);
106 expect(retryDelayMs(Number.NaN)).toBe(1000);
107 });
src/client/posting.ts added +53
@@ -0,0 +1,53 @@
1 /**
2 * What a failed POST /api/match means to the player, and whether it is worth
3 * trying again. Plain data in, plain data out — no fetch, no DOM, no timer —
4 * so the whole decision is a few functions a test can drive against every
5 * status the server can answer with, including a plain 400 (bad JSON).
6 */
7
8 export type Outcome = {
9 note: string | null; // shown near the leaderboard; null hides it
10 retry: boolean; // worth sending the same post again, once
11 };
12
13 /**
14 * Whether a post is the last word a match gets — the one that ends it, or
15 * the one sent on the way out the door (`toMenu`'s `left`) — since nothing
16 * else will post for this match again either way. This is the `final` that
17 * `postOutcome` below decides a retry on; a plain per-round post is neither.
18 */
19 export const isTerminalPost = (final: boolean, left: boolean): boolean => final || left;

Approved. All three findings from the last round are fixed:

  • Leaving a match now gets the one retry, through isTerminalPost(final, left), and a test covers it.
  • postResult settles after its first attempt and the retry runs in the background, so the match-over summary no longer waits behind a 429's retry-after.
  • The round that ends the match is posted once, by the match_over block only, so a refused post no longer turns into four requests.
  • 400 now has a test too.

Checked: the client-only change touches no server, identity, rate-limit or terminal code. The note is written with textContent, and the board HTML still comes only from the server's own escaped statsHtml. bun test at 0dcbd77: 850 pass, 0 fail. The branch merges cleanly onto main.

The preview host, and production too, answered 404 from my sandbox, so I couldn't try it in a browser. That's the sandbox's network, not this change.

Left for later, not blocking: if a background retry finishes after the player has started a new match, its note can appear during that new match.

The code at 70d6b17 is right, but this pull can't land: it conflicts with main. #237 (the final scoreboard on the match-over screen) landed after this branch was cut. The forge reports mergeable: false, with the conflict in src/client/main.ts in the match_over block:

main: const board = finalBoard(world); void postResult(true).then(() => showSummary(summary, board), () => showSummary(summary, board)); here: void postResult(true).then(() => showSummary(summary));

Fix first: rebase onto main and resolve it as const board = finalBoard(world); void postResult(true).then(() => showSummary(summary, board)); Keep main's board and this pull's single-handler form (postResult never rejects). Then run bun test on the rebased head.

Everything else I checked and it's fine:

  • The new sentFor guard is sound. matchId is a fresh UUID each time a match starts, and a resumed save gets its own id back. So a late note or retry from a match the player has left can no longer write into the next one; that closes the only leftover from my last pass.
  • Leaving a match retries through isTerminalPost, the summary no longer waits for the retry, the final round is posted once, and 400 has a test.
  • The change only touches the client. It doesn't touch the server, identity, rate limits or the terminal. The note is written with textContent, and #stats is filled only with the server's own statsHtml.
  • bun test at 70d6b17: 850 pass, 0 fail.

The preview host answered 404 from my sandbox (production does too), so I couldn't try the note in a browser.

20
outdated

x

outdated

The idea fits the direction: a small pure module, tested, and it stops a failed post from vanishing without a word. The security floor is untouched and the note is set through textContent. But the code doesn't do what the README and doc comments say, and it slows down the first thing a player sees when a match ends.

1. The leave post is never retried (fix this first). The README says "Only the post that ends or leaves a match gets a second try", and the postOutcome doc comment says the same. But toMenu calls postResult(false, true), and the retry is decided only by postOutcome(first.status, final) with final=false. So a player who leaves while offline or rate-limited gets no retry, and nothing else will ever post for them. Also, show(postNote, false) only runs when a new match starts, so that note stays on the menu. Fix: decide retry on final || left, and add a test in the posting tests that drives the leave case.

2. The match-over summary now waits for the retry. In the client's match_over block, postResult(true).then(() => showSummary(summary)) now waits for the first fetch, then retryDelayMs (up to 10 s on a 429), then a second fetch. On any failure the player stares at a frozen, finished match for 1–10+ seconds before the summary appears. Before this change the summary waited only for one fetch. Show the summary first, or right after the first attempt, and let the retry run in the background.

3. The final post is sent twice at match end. This came from main, but the retry makes it worse. On the frame the match ends, the per-round branch fires postResult(world.match.phase === "match_over"), which is postResult(true), and the match_over block fires postResult(true) again. Each one can now retry, so a 429 turns into four requests into the rate limiter that refused the first. Drop one of the two calls, for example skip the per-round post when the phase is match_over.

Smaller: a 400 (bad JSON, which the server can return) gets the generic line. That's fine, but the tests claim to cover every status and leave 400 out.

bun test at the pull head: all pass except the db standings timing test (under 10ms), which also times out on main here, so it is not this pull's fault. The branch merges cleanly onto current main. The preview answered 404 on every try, so I couldn't see the note working in a browser.

21 /**
22 * A per-round post has the next round to fix it, so only the final post —
23 * the one that ends or leaves the match — is ever retried, and only for a
24 * failure a second try might actually clear: offline, rate-limited, or the
25 * server's own trouble. A rejection (409 not yours, 413 too large, 422 bad
26 * result) says the same thing again on retry, so it never gets one.
27 */
28 export function postOutcome(status: number | null, final: boolean): Outcome {
29 if (status === 200) return { note: null, retry: false };
30 const retryable = status === null || status === 429 || status === 500;
31 const retry = final && retryable;
32 const again = retry ? " Retrying…" : "";
33 const note =
34 status === null ? `Not recorded — you're offline.${again}` :
35 status === 429 ? `Not recorded — the server is rate-limiting.${again}` :
36 status === 409 ? "Not recorded — this match belongs to another browser." :
37 status === 413 ? "Not recorded — the result was too large to send." :
38 status === 422 ? "Not recorded — the server didn't recognize this result." :
39 status === 500 ? `Not recorded — the server had a problem.${again}` :
40 "Not recorded — the server refused the result.";
41 return { note, retry };
42 }
43
44 /**
45 * How long to wait before the one retry: what the server asked for on a 429,
46 * clamped so a missing, huge or negative value can neither stall the
47 * match-over screen nor hammer the server the moment it asks for quiet, and
48 * about a second otherwise — long enough for a blip to pass.
49 */
50 export function retryDelayMs(retryAfterSeconds: number | null): number {
51 if (retryAfterSeconds === null || !Number.isFinite(retryAfterSeconds)) return 1000;
52 return Math.min(10, Math.max(1, retryAfterSeconds)) * 1000;
53 }
src/pages/game.test.ts +6
@@ -46,6 +46,12 @@ test("the wrapper — not the stage alone — holds both the view and the pad",
4646 expect(wrapper.indexOf(`<div id="stage">`)).toBeLessThan(wrapper.indexOf(`<div id="touch" hidden>`));
4747 });
4848
49 test("the post-failure note sits beside the leaderboard, hidden, and outside #stats so a post that replaces #stats cannot erase it", () => {
50 const html = gamePage(db, options);
51 expect(html).toContain(`<p id="post-note" class="post-note" hidden></p>`);
52 expect(html.indexOf(`id="post-note"`)).toBeLessThan(html.indexOf(`id="stats"`));
53 });
54
4955 test("a fullscreen control sits in the menu and in the pause overlay", () => {
5056 const html = gamePage(db, options);
5157 const menu = element(html, "menu");
src/pages/game.ts +5 −1
@@ -271,6 +271,7 @@ ul.recent span, ul.recent time, .empty, .you { color:var(--dim); }
271271 .you { font-size:.86rem; margin:.2rem 0 .6rem; }
272272 .you b { color:var(--text); }
273273 .tag { font-size:.7rem; color:var(--dim); border:1px solid var(--line); border-radius:999px; padding:0 .35rem; font-weight:400; }
274 .post-note { font-size:.86rem; color:var(--t); margin:0 0 .6rem; }
274275 kbd { background:#1b2230; border:1px solid var(--line); border-radius:.25rem; padding:0 .3rem; font-size:.82rem; }
275276 footer { color:var(--dim); font-size:.8rem; padding:1rem; text-align:center; }
276277 code { background:#141821; padding:.1rem .35rem; border-radius:.25rem; }
@@ -451,7 +452,10 @@ export function gamePage(db: Database, options: PageOptions): string {
451452 </div>
452453 </div>
453454
454 <section id="stats">${statsHtml(db, options.player)}</section>
455 <section>
456 <p id="post-note" class="post-note" hidden></p>
457 <div id="stats">${statsHtml(db, options.player)}</div>
458 </section>
455459
456460 <section>
457461 <h2>What this is</h2>

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs