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
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/200-a-result-the-server-refused-vanishes-wit → main 4 commits fork 2 threads · 2 unresolved
README.md +7
| @@ -712,6 +712,13 @@ identity that first sent it: anyone else posting it gets a 409. A database | ||
| 712 | 712 | written before identity existed is migrated on open — those rows were never |
| 713 | 713 | signed for, so they become the guests they always were. |
| 714 | 714 | |
| 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 | ||
| 715 | 722 | ## The bots |
| 716 | 723 | |
| 717 | 724 | 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"; | ||
| 21 | 21 | import { MATCH_LENGTHS, RULES, SIDES, type MatchLength, type Rules, type Team } from "./match.ts"; |
| 22 | 22 | import type { MapName } from "./map.ts"; |
| 23 | 23 | import { MOTIONS, type Motion } from "./motion.ts"; |
| 24 | import { isTerminalPost, postOutcome, retryDelayMs } from "./posting.ts"; | |
| 24 | 25 | import { MAX_SCALE, nextRenderScale } from "./quality.ts"; |
| 25 | 26 | import { createRenderer, type Camera } from "./renderer.ts"; |
| 26 | 27 | import type { CrosshairStyle } from "./crosshair.ts"; |
| @@ -60,6 +61,7 @@ function boot(): void { | ||
| 60 | 61 | const radioPanel = $("radio"); |
| 61 | 62 | const touchPanel = $("touch"); |
| 62 | 63 | const stats = $("stats"); |
| 64 | const postNote = $("post-note"); | |
| 63 | 65 | const nameField = $<HTMLInputElement>("name"); |
| 64 | 66 | const lengthField = $<HTMLSelectElement>("length"); |
| 65 | 67 | const sideField = $<HTMLSelectElement>("side"); |
| @@ -660,11 +662,60 @@ function boot(): void { | ||
| 660 | 662 | // --- results, sent as the match goes rather than only at the end. |
| 661 | 663 | // No name goes up: the server credits the result to whoever the forge says |
| 662 | 664 | // 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 | */ | |
| 663 | 709 | async function postResult(final: boolean, left = false): Promise<void> { |
| 664 | 710 | if (world === null) return; |
| 665 | 711 | 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, | |
| 668 | 719 | map: world.map.name, |
| 669 | 720 | rounds: world.match.score.t + world.match.score.ct, |
| 670 | 721 | scoreMine: world.match.score[me.team], |
| @@ -676,20 +727,23 @@ function boot(): void { | ||
| 676 | 727 | finished: final, |
| 677 | 728 | left, |
| 678 | 729 | 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 | })(); | |
| 693 | 747 | } |
| 694 | 748 | |
| 695 | 749 | /** |
| @@ -736,6 +790,7 @@ function boot(): void { | ||
| 736 | 790 | pauseMapLabel.textContent = world.map.name; |
| 737 | 791 | posted = -1; |
| 738 | 792 | savedRound = 0; |
| 793 | show(postNote, false); | |
| 739 | 794 | watching = world.me.id; |
| 740 | 795 | controls.sensitivity = settings.sensitivity; |
| 741 | 796 | controls.touchSensitivity = settings.touchSensitivity; |
| @@ -937,11 +992,13 @@ function boot(): void { | ||
| 937 | 992 | const played = world.match.score.t + world.match.score.ct; |
| 938 | 993 | if (played !== posted) { |
| 939 | 994 | 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); | |
| 941 | 999 | } |
| 942 | 1000 | if (world.match.phase === "match_over" && running) { |
| 943 | 1001 | running = false; |
| 944 | posted = played; | |
| 945 | 1002 | document.exitPointerLock(); |
| 946 | 1003 | show(buyPanel, false); |
| 947 | 1004 | show(radioPanel, false); |
| @@ -949,10 +1006,13 @@ function boot(): void { | ||
| 949 | 1006 | // A decided match has no round to come back to. |
| 950 | 1007 | clearSaved(); |
| 951 | 1008 | // 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. | |
| 953 | 1013 | const summary = summarize(world); |
| 954 | 1014 | const board = finalBoard(world); |
| 955 | void postResult(true).then(() => showSummary(summary, board), () => showSummary(summary, board)); | |
| 1015 | void postResult(true).then(() => showSummary(summary, board)); | |
| 956 | 1016 | } |
| 957 | 1017 | |
| 958 | 1018 | 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; | |
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 Everything else I checked and it's fine:
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 2. The match-over summary now waits for the retry. In the client's match_over block, 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 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", | ||
| 46 | 46 | expect(wrapper.indexOf(`<div id="stage">`)).toBeLessThan(wrapper.indexOf(`<div id="touch" hidden>`)); |
| 47 | 47 | }); |
| 48 | 48 | |
| 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 | ||
| 49 | 55 | test("a fullscreen control sits in the menu and in the pause overlay", () => { |
| 50 | 56 | const html = gamePage(db, options); |
| 51 | 57 | 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); } | ||
| 271 | 271 | .you { font-size:.86rem; margin:.2rem 0 .6rem; } |
| 272 | 272 | .you b { color:var(--text); } |
| 273 | 273 | .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; } | |
| 274 | 275 | kbd { background:#1b2230; border:1px solid var(--line); border-radius:.25rem; padding:0 .3rem; font-size:.82rem; } |
| 275 | 276 | footer { color:var(--dim); font-size:.8rem; padding:1rem; text-align:center; } |
| 276 | 277 | code { background:#141821; padding:.1rem .35rem; border-radius:.25rem; } |
| @@ -451,7 +452,10 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 451 | 452 | </div> |
| 452 | 453 | </div> |
| 453 | 454 | |
| 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> | |
| 455 | 459 | |
| 456 | 460 | <section> |
| 457 | 461 | <h2>What this is</h2> |
Approved. All three findings from the last round are fixed:
isTerminalPost(final, left), and a test covers it.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.