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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/102-a-result-that-did-not-land-says-so-and-i → main 1 commit fork
11 files
changed +481
−80
README.md +12
| @@ -77,6 +77,18 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | ||
| 77 | 77 | (kills fit in the rounds, headshots fit in the kills, the clock fits in the |
| 78 | 78 | rounds) and rejects the rest. Treat the leaderboard as a scoreboard among |
| 79 | 79 | 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. | |
| 80 | 92 | |
| 81 | 93 | ## A match you can play again |
| 82 | 94 |
src/client/main.test.ts +163 −2
| @@ -30,6 +30,9 @@ import { boot, gameMeshes, NOT_AN_ID, type Frame } from "./main.ts"; | ||
| 30 | 30 | import { DE_FORGE } from "./map.ts"; |
| 31 | 31 | import { SHORT } from "./match.ts"; |
| 32 | 32 | import { LAYERS, materialTextures } from "./materials.ts"; |
| 33 | import { | |
| 34 | FIRST_WAIT, LONGEST_WAIT, LOST, MAX_TRIES, REFUSED, RETRYING, SAVED, waitAfter, | |
| 35 | } from "./pending.ts"; | |
| 33 | 36 | import { digest, snapshot, withoutEntropy } from "./replay.ts"; |
| 34 | 37 | import { WEAPONS } from "./weapons.ts"; |
| 35 | 38 | import { matchOf } from "../matchid.ts"; |
| @@ -458,13 +461,16 @@ test("one POST per round scored, in the shape the server accepts", async () => { | ||
| 458 | 461 | stage.step(FRAME); |
| 459 | 462 | expect(stage.posts.length).toBe(1); |
| 460 | 463 | |
| 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. | |
| 462 | 467 | const world = worldOf(stage); |
| 463 | 468 | if (world === null) throw new Error("the first frame drew nothing"); |
| 464 | 469 | world.match.score.ct = 1; |
| 465 | 470 | stage.step(FRAME); |
| 466 | 471 | stage.step(FRAME); |
| 467 | 472 | expect(stage.posts.length).toBe(2); |
| 473 | await settled(); | |
| 468 | 474 | const second = JSON.parse(stage.posts[1]?.body ?? "{}"); |
| 469 | 475 | expect(parseResult(second)).not.toBeNull(); |
| 470 | 476 | expect(second.rounds).toBe(1); |
| @@ -503,6 +509,160 @@ test("a leaderboard that cannot be reached is not a reason to stop playing", asy | ||
| 503 | 509 | expect(stage.by("menu").hidden).toBe(true); |
| 504 | 510 | }); |
| 505 | 511 | |
| 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 | ||
| 506 | 666 | test("F and the button put the stage on the whole screen, and take it back", () => { |
| 507 | 667 | const stage = start(fakeStage()); |
| 508 | 668 | 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 | ||
| 718 | 878 | expect(run(PASTED)).not.toBe(run(`${PASTED.slice(0, -1)}5`)); |
| 719 | 879 | }); |
| 720 | 880 | |
| 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 () => { | |
| 722 | 882 | const stage = pasted(PASTED); |
| 723 | 883 | stage.step(FRAME); |
| 884 | await settled(); | |
| 724 | 885 | const first = JSON.parse(stage.posts[0]?.body ?? "{}"); |
| 725 | 886 | // The row key is not the id: it is the id with this play's nonce on it, so |
| 726 | 887 | // the leaderboard cannot fold two games of one match into one row — and it |
src/client/main.ts +95 −17
| @@ -12,6 +12,16 @@ | ||
| 12 | 12 | * map and the round are both drawn from. What is stored is a key for this |
| 13 | 13 | * playing of it, so a rematch is a row of its own. |
| 14 | 14 | * |
| 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 | * | |
| 15 | 25 | * Booting does not freeze the page. This game's atlas is nine 128-pixel layers |
| 16 | 26 | * and about 170 ms of arithmetic, so `boot` paints one layer per frame through |
| 17 | 27 | * the engine's `painter` and builds the renderer on the frame the last row |
| @@ -43,6 +53,7 @@ import type { Level } from "./level.ts"; | ||
| 43 | 53 | import { COMPETITIVE, SHORT, type Rules } from "./match.ts"; |
| 44 | 54 | import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts"; |
| 45 | 55 | import { meshFor } from "./models.ts"; |
| 56 | import { nextStep, type Answer } from "./pending.ts"; | |
| 46 | 57 | import { buildScene, createScene, SKY, type Scene } from "./scene.ts"; |
| 47 | 58 | import { createSpectator, cycle, follow } from "./spectate.ts"; |
| 48 | 59 | import { buyable, WEAPONS } from "./weapons.ts"; |
| @@ -119,8 +130,12 @@ export type Store = { | ||
| 119 | 130 | setItem(key: string, value: string): void; |
| 120 | 131 | }; |
| 121 | 132 | |
| 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> }; | |
| 124 | 139 | |
| 125 | 140 | /** |
| 126 | 141 | * 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 { | ||
| 191 | 206 | const buyPanel = by("buy"); |
| 192 | 207 | const touchPanel = by("touch"); |
| 193 | 208 | 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"); | |
| 194 | 212 | const nameField = by<Field>("name"); |
| 195 | 213 | const lengthField = by<Field>("length"); |
| 196 | 214 | const sideField = by<Field>("side"); |
| @@ -421,7 +439,72 @@ export function boot(host: Host): Frame { | ||
| 421 | 439 | }; |
| 422 | 440 | |
| 423 | 441 | // --- 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 { | |
| 425 | 508 | if (world === null) return; |
| 426 | 509 | const me = world.me; |
| 427 | 510 | const body = { |
| @@ -439,19 +522,10 @@ export function boot(host: Host): Frame { | ||
| 439 | 522 | finished: final, |
| 440 | 523 | seconds: Math.round(world.time), |
| 441 | 524 | }; |
| 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()); | |
| 455 | 529 | } |
| 456 | 530 | |
| 457 | 531 | function start(): void { |
| @@ -569,6 +643,10 @@ export function boot(host: Host): Frame { | ||
| 569 | 643 | play.disabled = false; |
| 570 | 644 | return; |
| 571 | 645 | } |
| 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()); | |
| 572 | 650 | if (world === null || !running) return; |
| 573 | 651 | |
| 574 | 652 | const me = world.me; |
| @@ -598,7 +676,7 @@ export function boot(host: Host): Frame { | ||
| 598 | 676 | const played = world.match.score.t + world.match.score.ct; |
| 599 | 677 | if (played !== posted) { |
| 600 | 678 | posted = played; |
| 601 | void postResult(world.match.phase === "match_over"); | |
| 679 | postResult(world.match.phase === "match_over"); | |
| 602 | 680 | } |
| 603 | 681 | if (world.match.phase === "match_over" && running) { |
| 604 | 682 | 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 = { | ||
| 123 | 123 | hold(code: string, down: boolean): void; |
| 124 | 124 | /** Every POST /api/match, in order, and what the next one is answered with. */ |
| 125 | 125 | posts: Post[]; |
| 126 | answer(post: Post): Promise<{ ok: boolean; text(): Promise<string> }>; | |
| 126 | answer(post: Post): Promise<{ ok: boolean; status: number; text(): Promise<string> }>; | |
| 127 | 127 | draws: Drawn[]; |
| 128 | 128 | /** Every mesh swapped under a name after construction: a match on another map. */ |
| 129 | 129 | replaced: { name: string; mesh: Mesh }[]; |
| @@ -155,7 +155,7 @@ export type FakeStage = { | ||
| 155 | 155 | * contract between the page and this file, so `src/server.test.ts` checks the |
| 156 | 156 | * served page against this same list rather than a second copy of it. |
| 157 | 157 | */ |
| 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"]; | |
| 159 | 159 | |
| 160 | 160 | /** The ones the server renders with `hidden` on them: the page starts at the menu. */ |
| 161 | 161 | const HIDDEN = ["pause", "buy", "touch", "full"]; |
| @@ -277,7 +277,7 @@ export function fakeStage( | ||
| 277 | 277 | stub.hold(code, true); |
| 278 | 278 | stub.hold(code, false); |
| 279 | 279 | }, |
| 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>") }), | |
| 281 | 281 | install() { |
| 282 | 282 | const globals = globalThis as Record<string, unknown>; |
| 283 | 283 | // 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 = { | ||
| 295 | 295 | * of them drift apart. Neither other bundle moved a byte: nothing outside |
| 296 | 296 | * `src/client/`, `src/pages/game.ts` and the new module changed, and neither of |
| 297 | 297 | * 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. | |
| 298 | 306 | */ |
| 299 | 307 | export const GAMES: readonly Game[] = [ |
| 300 | 308 | { |
src/pages/game.ts +15
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | |
| 12 | 12 | import type { Database } from "bun:sqlite"; |
| 13 | 13 | import { displayName, headshotShare, ratio, recentMatches, standings } from "../db.ts"; |
| 14 | import { NOTHING_POSTED } from "../client/pending.ts"; | |
| 14 | 15 | import { CARD } from "../client/weights.ts"; |
| 15 | 16 | import type { Who } from "../who.ts"; |
| 16 | 17 | import { navFor, othersHtml } from "./games.ts"; |
| @@ -186,6 +187,19 @@ const thisMatch = (asked: Asked): string => ` <section id="thismatch"> | ||
| 186 | 187 | spawns, the same bots and the same seed. Every playing of it is its own row on the leaderboard.</p> |
| 187 | 188 | </section>`; |
| 188 | 189 | |
| 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 `` 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 | ||
| 189 | 203 | export function gamePage(db: Database, { who: visitor, asked = NOTHING_ASKED, ...options }: PageOptions): string { |
| 190 | 204 | return page({ |
| 191 | 205 | ...options, |
| @@ -221,6 +235,7 @@ export function gamePage(db: Database, { who: visitor, asked = NOTHING_ASKED, .. | ||
| 221 | 235 | sectionsHtml: `${thisMatch(asked)} |
| 222 | 236 | |
| 223 | 237 | <section id="stats">${statsHtml(db)}</section> |
| 238 | ${SAVED_LINE} | |
| 224 | 239 | |
| 225 | 240 | <section> |
| 226 | 241 | <h2>What this is</h2> |
src/server.test.ts +5
| @@ -5,6 +5,7 @@ import { join } from "node:path"; | ||
| 5 | 5 | import { bundle, STATIC_DIR } from "../scripts/build.ts"; |
| 6 | 6 | import { GAMES } from "./games.ts"; |
| 7 | 7 | import { matchOf, playKey } from "./matchid.ts"; |
| 8 | import { NOTHING_POSTED } from "./client/pending.ts"; | |
| 8 | 9 | import { BAD_ID } from "./pages/game.ts"; |
| 9 | 10 | import { escape } from "./pages/shell.ts"; |
| 10 | 11 | import { sign } from "./who.ts"; |
| @@ -65,6 +66,10 @@ test("the page carries every id and action the client reaches for", async () => | ||
| 65 | 66 | // last layer lands, and nothing on the page moves when it does. |
| 66 | 67 | expect(body).toContain(`id="play" disabled>Play</button>`); |
| 67 | 68 | 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>`); | |
| 68 | 73 | }); |
| 69 | 74 | |
| 70 | 75 | 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 filestatic/app.e24e7af2ef.js deleted −58
Diff not shown — a line 73,945 characters wide (limit 2,000). −58
View file
new above
offline