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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/251-the-shooter-s-entry-sheds-its-second-con → main 1 commit fork
9 files
changed +487
−231
README.md +6
| @@ -680,6 +680,12 @@ never checked itself. | ||
| 680 | 680 | pressable. It takes the element to draw into, the round and the player, and a |
| 681 | 681 | call that says which item was pressed; it reaches for no document and buys |
| 682 | 682 | 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), | |
| 683 | 689 | `bomb` (the objective: `giveBomb`, `dropBomb`, `updateBomb` and the |
| 684 | 690 | `useProgress` a readout draws its bar from — carrying it, planting it on a |
| 685 | 691 | 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 @@ | ||
| 2 | 2 | * The frame itself, booted against the stub page in `stage.test-helper.ts`: |
| 3 | 3 | * the ids it looks up, the match a click starts, how many fixed steps one frame |
| 4 | 4 | * 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 | |
| 9 | 11 | * nothing is timed by a real clock — the test decides when a frame happens and |
| 10 | 12 | * how long it took. |
| 11 | 13 | * |
| @@ -35,9 +37,7 @@ import { endRound, SHORT } from "./match.ts"; | ||
| 35 | 37 | import { LAYERS, materialTextures } from "./materials.ts"; |
| 36 | 38 | import { recorder } from "./paint.test-helper.ts"; |
| 37 | 39 | 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"; | |
| 41 | 41 | import { headshotShare, resultOf, scoreLine, yourLine } from "./summary.ts"; |
| 42 | 42 | import { digest, snapshot, withoutEntropy } from "./replay.ts"; |
| 43 | 43 | import { WEAPONS } from "./weapons.ts"; |
| @@ -739,6 +739,12 @@ test("a leaderboard that cannot be reached is not a reason to stop playing", asy | ||
| 739 | 739 | }); |
| 740 | 740 | |
| 741 | 741 | // --- 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. | |
| 742 | 748 | |
| 743 | 749 | /** |
| 744 | 750 | * Answer the next posts with these statuses, in order; `"throw"` is a fetch |
| @@ -764,7 +770,7 @@ const settled = (): Promise<void> => Bun.sleep(0); | ||
| 764 | 770 | /** What the line by the leaderboard says right now. */ |
| 765 | 771 | const line = (stage: FakeStage): string | null => stage.by("saved").textContent; |
| 766 | 772 | |
| 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 () => { | |
| 768 | 774 | const stage = playing(); |
| 769 | 775 | answering(stage, "throw", 200); |
| 770 | 776 | stage.step(FRAME); |
| @@ -794,113 +800,6 @@ test("a post that threw is kept, tried again after its wait, and the line says w | ||
| 794 | 800 | expect(stage.posts.length).toBe(2); |
| 795 | 801 | }); |
| 796 | 802 | |
| 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 | ||
| 904 | 803 | test("F and the button put the stage on the whole screen, and take it back", () => { |
| 905 | 804 | const stage = start(fakeStage()); |
| 906 | 805 | const full = stage.by("full"); |
src/client/main.ts +40 −108
| @@ -36,15 +36,17 @@ | ||
| 36 | 36 | * choice — never per frame and never during a match — so what a player picks is |
| 37 | 37 | * a place and not a number. |
| 38 | 38 | * |
| 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. | |
| 48 | 50 | * |
| 49 | 51 | * Booting does not freeze the page. This game's atlas is nine 128-pixel layers |
| 50 | 52 | * 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"; | ||
| 82 | 84 | import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts"; |
| 83 | 85 | import { meshFor } from "./models.ts"; |
| 84 | 86 | import { parsePacked } from "./packed.ts"; |
| 85 | import { nextStep, NOTHING_POSTED, SENDING, type Answer } from "./pending.ts"; | |
| 87 | import { createReporter, type Reply } from "./report.ts"; | |
| 86 | 88 | import { boardHtml, headline, resultOf, scoreLine, summarize, yourLine } from "./summary.ts"; |
| 87 | 89 | import { drawPreview, previewText } from "./preview.ts"; |
| 88 | 90 | import { buildScene, createScene, SKY, type Scene } from "./scene.ts"; |
| @@ -208,11 +210,11 @@ export type Store = { | ||
| 208 | 210 | }; |
| 209 | 211 | |
| 210 | 212 | /** |
| 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. | |
| 214 | 216 | */ |
| 215 | export type Reply = { readonly ok: boolean; readonly status: number; text(): Promise<string> }; | |
| 217 | export type { Reply }; | |
| 216 | 218 | |
| 217 | 219 | /** |
| 218 | 220 | * 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 { | ||
| 625 | 627 | |
| 626 | 628 | // --- results, sent as the match goes rather than only at the end |
| 627 | 629 | /** |
| 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. | |
| 634 | 634 | */ |
| 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(); | |
| 703 | 637 | } |
| 704 | 638 | |
| 705 | 639 | /** |
| 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. | |
| 711 | 644 | */ |
| 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 | }); | |
| 724 | 654 | |
| 725 | 655 | // --- which map, and what it is called ------------------------------------- |
| 726 | 656 | |
| @@ -1074,9 +1004,11 @@ export function boot(host: Host): Frame { | ||
| 1074 | 1004 | } |
| 1075 | 1005 | // The round that has not landed, tried again on the frame its backoff runs |
| 1076 | 1006 | // 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. | |
| 1078 | 1010 | const now = win.performance.now(); |
| 1079 | send(now); | |
| 1011 | reporter.send(); | |
| 1080 | 1012 | // The plan of the map the menu is offering, once the choice has stood still |
| 1081 | 1013 | // — and never while a match is running, where the frame belongs to the round |
| 1082 | 1014 | // and the menu is not on the screen to look at anyway. |
| @@ -1130,7 +1062,7 @@ export function boot(host: Host): Frame { | ||
| 1130 | 1062 | if (played !== posted || (over && !postedFinal)) { |
| 1131 | 1063 | posted = played; |
| 1132 | 1064 | postedFinal = over; |
| 1133 | postResult(over); | |
| 1065 | reporter.post(world, playId, nameField.value, over); | |
| 1134 | 1066 | } |
| 1135 | 1067 | if (world.match.phase === "match_over" && running) endMatch(world); |
| 1136 | 1068 |
src/client/pending.test.ts +3 −2
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | 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. | |
| 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. | |
| 5 | 6 | */ |
| 6 | 7 | |
| 7 | 8 | import { expect, test } from "bun:test"; |
src/client/pending.ts +7 −4
| @@ -3,10 +3,13 @@ | ||
| 3 | 3 | * round means, what the page says about it, and how long to wait before trying |
| 4 | 4 | * the same round again. |
| 5 | 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. | |
| 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. | |
| 10 | 13 | * |
| 11 | 14 | * The four sentences are three states, in the words a player reads: the round |
| 12 | 15 | * 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 => | ||
| 105 | 105 | /** |
| 106 | 106 | * How long a row may go without a post before it stops reading as live. |
| 107 | 107 | * |
| 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 | |
| 110 | 110 | * playing has a known ceiling: the longest round the rules allow is freeze 12 s |
| 111 | 111 | * + round 115 s + bomb 40 s + end delay 5 s = 172 s (`COMPETITIVE` in |
| 112 | 112 | * `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 = { | ||
| 478 | 478 | * set with 4,136 raw and 2,034 gzipped to spare, so no ceiling moved. Neither |
| 479 | 479 | * other bundle changed a byte: nothing outside `src/client/` did. Measured |
| 480 | 480 | * 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. | |
| 481 | 491 | */ |
| 482 | 492 | export const GAMES: readonly Game[] = [ |
| 483 | 493 | { |
new above
offline