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

forked from trevato/cs

A match ends with a scoreboard, not a menu: the final score, your line and what was posted #232

Merged opened this

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

Merged

vesper/cs-engine:vesper/144-a-match-ends-with-a-scoreboard-not-a-men → main 1 commit fork

  • Merged. vesper/144-a-match-ends-with-a-scoreboard-not-a-men was merged into main as 642743d.

Review

Sign in to review this change.
13 files changed +721 −110
README.md +10
@@ -98,6 +98,16 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
9898 line is a live region, so it is announced and not only drawn. Nothing is kept
9999 across a visit: a round that never reaches the server before you close the
100100 tab is gone, which is what the page says rather than promising otherwise.
101 - **A match ends on a scoreboard.** The last round scored does not hand you the
102 menu back: the run stops and the page shows what you just played — who won,
103 the final score, your own line (kills, deaths, damage, headshot share and the
104 rounds you won) and both teams' rows in the order the <kbd>Tab</kbd>
105 scoreboard draws them. The numbers on that screen are the same object that
106 was posted to the leaderboard — `src/client/summary.ts` builds it once — so
107 the screen and the row cannot disagree, and the line under it says whether
108 the result reached the board, is still on its way, or is not going to. **Play
109 again** starts another match on the settings still in the menu; **Back to the
110 page** puts the menu back and hands the screen over if the stage had it.
101111
102112 ## A match you can play again
103113
src/client/hud.ts +8 −5
@@ -29,6 +29,7 @@ import { CS_MOVE } from "./movement.ts";
2929 import { BLUE, CROSSHAIR, CT_COLOR, GREY, RED, T_COLOR, teamColor, WHITE } from "./palette.ts";
3030 import { inaccuracy, WEAPONS } from "./weapons.ts";
3131 import { useProgress } from "./bomb.ts";
32 import { scoreLines } from "./summary.ts";
3233 import { currentWeapon, type Player, type World } from "./world.ts";
3334
3435 /**
@@ -286,18 +287,20 @@ function drawScoreboard(ctx: Paint, ui: Overlay, world: World): void {
286287 // The headings sit on the row they head, so there is no lead over a value.
287288 ui.columns(FIGURES, NOTHING, { x: right, y: y + rowH, lead: 0 }, stride);
288289
289 const order = [...world.players].sort((a, b) => b.kills - a.kills || b.damage - a.damage);
290 order.forEach((p, row) => {
290 // The rows, and the order they are in, are `summary.ts`'s: the screen at the
291 // end of the match draws the same ten lines out of the same function, so what
292 // you read holding Tab and what you read afterwards cannot be in two orders.
293 scoreLines(world).forEach((p, row) => {
291294 const ty = y + rowH * (row + 2.4);
292295 // A dead player's whole row is dimmed — the name and the figures both — so
293296 // the fade is set around them rather than passed to each line.
294297 const dim = p.alive ? 1 : 0.45;
295 ui.text(`${p.team === "t" ? "T" : "CT"} ${p.name}${p.bot ? "" : " (you)"}`, x + 16, ty, {
296 align: "left", fill: p === world.me ? "#ffffff" : teamColor(p.team), alpha: dim,
298 ui.text(`${p.team === "t" ? "T" : "CT"} ${p.name}${p.you ? " (you)" : ""}`, x + 16, ty, {
299 align: "left", fill: p.you ? "#ffffff" : teamColor(p.team), alpha: dim,
297300 });
298301 ctx.globalAlpha = dim;
299302 ui.columns(NOTHING, [
300 String(p.kills), String(p.deaths), String(p.headshots), String(Math.round(p.damage)), `$${p.money}`,
303 String(p.kills), String(p.deaths), String(p.headshots), String(p.damage), `$${p.money}`,
301304 ], { x: right, y: ty }, stride);
302305 ctx.globalAlpha = 1;
303306 });
src/client/main.test.ts +151 −10
@@ -28,17 +28,18 @@ import { generateLevel, nameOf, seedFrom, seedOf } from "./generate.ts";
2828 import type { Level } from "./level.ts";
2929 import { boot, COPIED, gameMeshes, NOT_AN_ID, NOT_A_MAP, NOT_COPIED, type Frame } from "./main.ts";
3030 import { DE_FORGE } from "./map.ts";
31 import { SHORT } from "./match.ts";
31 import { endRound, SHORT } from "./match.ts";
3232 import { LAYERS, materialTextures } from "./materials.ts";
3333 import { recorder } from "./paint.test-helper.ts";
3434 import { drawPreview, previewText } from "./preview.ts";
3535 import {
36 FIRST_WAIT, LONGEST_WAIT, LOST, MAX_TRIES, REFUSED, RETRYING, SAVED, waitAfter,
36 FIRST_WAIT, LONGEST_WAIT, LOST, MAX_TRIES, REFUSED, RETRYING, SAVED, SENDING, waitAfter,
3737 } from "./pending.ts";
38 import { headshotShare, resultOf, scoreLine, yourLine } from "./summary.ts";
3839 import { digest, snapshot, withoutEntropy } from "./replay.ts";
3940 import { WEAPONS } from "./weapons.ts";
4041 import { matchOf } from "../matchid.ts";
41 import { parseResult } from "../result.ts";
42 import { parseResult, type Result } from "../result.ts";
4243 import { fakeStage, ORIGIN, type FakeStage } from "./stage.test-helper.ts";
4344 import { createWorld, type World } from "./world.ts";
4445
@@ -466,18 +467,48 @@ test("leaving the match puts the menu back and stops the frame", () => {
466467 expect(stage.draws.length).toBe(1);
467468 });
468469
469 test("match_over stops the run, shows the menu and focuses play", () => {
470 // --- a match ends on a scoreboard ------------------------------------------
471
472 /**
473 * Play the match out. The last round is scored through the match's own rules —
474 * `endRound` is what an elimination or a defuse calls — and the frames after it
475 * carry the end delay, which is the clock that turns a won round into
476 * `match_over`. Nothing here writes a phase: the frame under test sees exactly
477 * what a played-out match leaves.
478 *
479 * A quarter of a second a frame is the loop's own cap (eight 64 Hz steps), so
480 * five seconds of end delay is forty frames rather than a hundred and fifty.
481 */
482 function finish(stage: FakeStage, winner: "t" | "ct"): World {
483 const world = worldOf(stage);
484 if (world === null) throw new Error("no match is running");
485 world.match.score[winner] = world.match.rules.winScore - 1;
486 endRound(world.match, winner, "elimination");
487 for (let frame = 0; frame < 60 && world.match.phase !== "match_over"; frame++) stage.step(250);
488 if (world.match.phase !== "match_over") throw new Error("the match never ended");
489 return world;
490 }
491
492 /** A match played to its end, with something on the player's own line to read. */
493 function ended(winner: "t" | "ct" = "ct"): { stage: FakeStage; world: World } {
470494 const stage = playing();
471495 stage.lock(true);
472496 stage.step(FRAME);
473497 const world = worldOf(stage);
474498 if (world === null) throw new Error("the first frame drew nothing");
475 world.match.phase = "match_over";
476 world.match.matchWinner = "ct";
477 stage.step(FRAME);
499 Object.assign(world.me, { kills: 7, deaths: 3, headshots: 2, damage: 1234.6 });
500 return { stage, world: finish(stage, winner) };
501 }
478502
479 expect(stage.by("menu").hidden).toBe(false);
480 expect(stage.by("play").focused).toBe(1);
503 test("a match ends on the summary, not the menu: the run stops and the way forward has the focus", () => {
504 const { stage } = ended();
505 expect(stage.by("summary").hidden).toBe(false);
506 expect(stage.by("menu").hidden).toBe(true);
507 expect(stage.by("pause").hidden).toBe(true);
508 expect(stage.by("touch").hidden).toBe(true);
509 // Play again is one press of Enter from wherever the pointer was, and the
510 // pointer itself is given back: the match is over, so the mouse is the page's.
511 expect(stage.by("again").focused).toBe(1);
481512 expect(stage.document.pointerLockElement).toBe(null);
482513 // The run stopped: the frames keep coming and nothing more is drawn.
483514 const drawn = stage.draws.length;
@@ -486,6 +517,107 @@ test("match_over stops the run, shows the menu and focuses play", () => {
486517 expect(stage.draws.length).toBe(drawn);
487518 });
488519
520 /**
521 * Let everything in flight settle and the frames send whatever is still
522 * waiting, then hand back the last body that went to the server. A post is one
523 * attempt at a time and a test's frames are all one synchronous block, so the
524 * result of the last round leaves on the frame after the one before it lands.
525 */
526 async function lastPost(stage: FakeStage): Promise<Result> {
527 for (let attempt = 0; attempt < 4; attempt++) {
528 await settled();
529 stage.step(FRAME);
530 }
531 await settled();
532 return JSON.parse(stage.posts[stage.posts.length - 1]?.body ?? "{}");
533 }
534
535 test("the summary is the final score, your own line and both teams' rows", async () => {
536 const { stage, world } = ended("ct");
537 // Who won and by how much, in the words `summary.ts` writes them, against the
538 // world the match actually ended in.
539 const posted = resultOf(world, "irrelevant", "you", true);
540 expect(stage.by("sumtitle").textContent).toBe("Counter-Terrorists win");
541 expect(stage.by("sumscore").textContent).toContain(`${posted.scoreMine}–${posted.scoreTheirs}`);
542 expect(stage.by("sumscore").textContent).toContain("you won");
543 expect(stage.by("sumscore").textContent).toContain("Counter-Terrorists");
544
545 // Your own line, and it is the numbers the server was posted — the same
546 // object built once, so the screen and the leaderboard cannot disagree.
547 const body = await lastPost(stage);
548 expect(body.finished).toBe(true);
549 expect(parseResult(body)).not.toBeNull();
550 const mine = stage.by("sumyou").textContent ?? "";
551 expect(mine).toContain(`${body.kills} kills`);
552 expect(mine).toContain(`${body.deaths} deaths`);
553 expect(mine).toContain(`${body.damage} damage`);
554 expect(mine).toContain(`${headshotShare(body.headshots, body.kills)} headshots`);
555 expect(mine).toContain(`${body.scoreMine} rounds won`);
556 // Rounded once, in `summary.ts`, so the page and the row carry one number.
557 expect(body.damage).toBe(1235);
558 expect(stage.by("sumyou").textContent).toBe(yourLine({
559 result: resultOf(world, "irrelevant", "you", true), team: world.me.team,
560 winner: world.match.matchWinner, won: true, lines: [],
561 }));
562
563 // And everybody's rows: ten players, both sides, yours marked, in the order
564 // the HUD's board draws them — most kills first, which here is you.
565 const board = stage.by("sumboard").innerHTML;
566 for (const player of world.players) expect(board).toContain(player.name);
567 expect(board).toContain("<b>(you)</b>");
568 const rows = board.slice(board.indexOf("<tbody>")).split("<tr").slice(1);
569 expect(rows.length).toBe(world.players.length);
570 expect(rows[0]).toContain("(you)");
571 expect(board).toContain(`>CT</span>`);
572 expect(board).toContain(`>T</span>`);
573 });
574
575 test("a match lost says so, and the side that won is the side that won", () => {
576 const { stage } = ended("t");
577 expect(stage.by("sumtitle").textContent).toBe("Terrorists win");
578 expect(stage.by("sumscore").textContent).toContain("you lost");
579 });
580
581 test("play again is another match on the same settings, and back to the page is the menu", () => {
582 const { stage } = ended();
583 stage.by("length").value = "long";
584 stage.by("again").fire("click");
585 // A match, not a menu: the summary is gone, the pointer is asked for again,
586 // and it is a new match of its own — a fresh id, so a fresh row.
587 expect(stage.by("summary").hidden).toBe(true);
588 expect(stage.by("menu").hidden).toBe(true);
589 expect(worldOf(stage)?.match.phase).not.toBe("match_over");
590 expect(stage.ids.length).toBe(2);
591 // The settings are the menu's, untouched and unread until Play again asks:
592 // the field says thirteen rounds, so the new match is a thirteen-round one.
593 expect(worldOf(stage)?.match.rules.winScore).toBe(13);
594
595 // And the other way out is the page: the menu is back and nothing is running.
596 const over = finish(stage, "ct");
597 expect(over.match.phase).toBe("match_over");
598 stage.by("leave").fire("click");
599 expect(stage.by("summary").hidden).toBe(true);
600 expect(stage.by("menu").hidden).toBe(false);
601 expect(stage.by("play").focused).toBe(1);
602 const drawn = stage.draws.length;
603 stage.step(FRAME);
604 expect(stage.draws.length).toBe(drawn);
605 });
606
607 test("the summary says whether the match reached the leaderboard, and says it once it has", async () => {
608 const { stage } = ended();
609 // Posted and not yet answered: the screen says it is on its way, never that
610 // it landed. `settled` lets the fetch the last frame made come back.
611 expect(stage.by("sumsaved").textContent).toBe(SENDING);
612 // The round before it settles first — one attempt is ever in flight — and the
613 // line still says the match is not on the board, because it is not.
614 await settled();
615 expect(stage.by("sumsaved").textContent).toBe(SENDING);
616 stage.step(FRAME);
617 await settled();
618 expect(stage.by("sumsaved").textContent).toBe(SAVED);
619 });
620
489621 test("dying moves the camera behind a teammate, and fire moves it behind the next", () => {
490622 const stage = playing();
491623 stage.lock(true);
@@ -732,7 +864,10 @@ test("a match that ends offline is tried again to a cap, and then the page says
732864 await settled();
733865 expect(stage.posts.length).toBe(2);
734866 expect(JSON.parse(stage.posts[1]?.body ?? "{}").finished).toBe(true);
735 expect(stage.by("menu").hidden).toBe(false);
867 // The summary is up, and it does not pretend: the result is still in the
868 // page's hand, so the line under the score says it is not on the board.
869 expect(stage.by("summary").hidden).toBe(false);
870 expect(stage.by("sumsaved").textContent).toBe(SENDING);
736871
737872 // Three more attempts, each after twice the wait of the last, and the frames
738873 // in between cost nothing. Four in all for this round, and then it stops.
@@ -741,8 +876,14 @@ test("a match that ends offline is tried again to a cap, and then the page says
741876 await settled();
742877 expect(stage.posts.length).toBe(attempt + 2);
743878 expect(JSON.parse(stage.posts[attempt + 1]?.body ?? "{}").finished).toBe(true);
879 // Still in the page's hand until the last attempt is spent: the screen says
880 // the match is not on the board for every one of them.
881 expect(stage.by("sumsaved").textContent).toBe(attempt + 1 < MAX_TRIES ? SENDING : LOST);
744882 }
745883 expect(line(stage)).toBe(LOST);
884 // And when there is nothing left to try, the screen says the match is gone
885 // rather than leaving the sentence about sending it up for ever.
886 expect(stage.by("sumsaved").textContent).toBe(LOST);
746887
747888 // Capped: a tab left open all afternoon posts nothing more.
748889 for (let frame = 0; frame < 10; frame++) stage.step(LONGEST_WAIT);
src/client/main.ts +110 −31
@@ -3,8 +3,18 @@
33 * and the engine's controls say which actions are held; `binding` turns those
44 * names into the `Input` the world takes. `scene.ts` then turns the world into
55 * the frame's data and the engine's renderer draws that — built here, from this
6 * game's atlas and this game's meshes — and the HUD is painted on top. The page's overlays — menu, buy, pause — are plain DOM, so they are
7 * reachable with a keyboard and big enough for a thumb.
6 * game's atlas and this game's meshes — and the HUD is painted on top. The page's overlays — menu, buy, pause and the summary a match ends on — are
7 * plain DOM, so they are reachable with a keyboard and big enough for a thumb.
8 *
9 * A match ends on a scoreboard and not on a menu. The frame that sees
10 * `match_over` stops the run and, out of the world's final state before
11 * anything is let go of, writes the summary: who won, the score, your own line
12 * — the same numbers `summary.ts` built the last post out of, so the screen and
13 * the leaderboard cannot disagree — and both teams' rows in the order the HUD's
14 * Tab board draws them. Under it is what the leaderboard knows: a result still
15 * on its way says so rather than implying it landed. Play again is `start`
16 * itself with the menu's fields untouched, and Back to the page is the same
17 * going-back the pause panel's Leave match is.
818 *
919 * A match is its id. The menu's field takes one — pasted, or put there by
1020 * `/?match=<id>` — and `start` below validates it through `src/matchid.ts`, the
@@ -67,7 +77,8 @@ import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
6777 import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts";
6878 import { meshFor } from "./models.ts";
6979 import { parsePacked } from "./packed.ts";
70 import { nextStep, type Answer } from "./pending.ts";
80 import { nextStep, NOTHING_POSTED, SENDING, type Answer } from "./pending.ts";
81 import { boardHtml, headline, resultOf, scoreLine, summarize, yourLine } from "./summary.ts";
7182 import { drawPreview, previewText } from "./preview.ts";
7283 import { buildScene, createScene, SKY, type Scene } from "./scene.ts";
7384 import { usePolicy } from "./bots.ts";
@@ -331,6 +342,16 @@ export function boot(host: Host): Frame {
331342 const resumeButton = by("resume");
332343 // The line in the pause panel that says what Escape just did on this browser.
333344 const escHint = by("esc");
345 // The screen a match ends on: the heading, the score, your own line, both
346 // teams' rows, whether it landed on the leaderboard, and the two ways out.
347 const summaryPanel = by("summary");
348 const sumTitle = by("sumtitle");
349 const sumScore = by("sumscore");
350 const sumYou = by("sumyou");
351 const sumBoard = by("sumboard");
352 const sumSaved = by("sumsaved");
353 const againButton = by("again");
354 const leaveButton = by("leave");
334355
335356 const maybeBuild = host.createRenderer(sceneCanvas);
336357 if (maybeBuild === null) {
@@ -452,6 +473,8 @@ export function boot(host: Host): Frame {
452473 let lastSound = 0;
453474 let frameAverage = 16.7;
454475 let posted = -1;
476 /** Whether the post that said this match is finished has been made. */
477 let postedFinal = false;
455478
456479 const show = (element: Panel, on: boolean): void => {
457480 element.hidden = !on;
@@ -612,9 +635,29 @@ export function boot(host: Host): Frame {
612635 let tries = 0;
613636 let notBefore = 0;
614637
638 /**
639 * The last thing said about a posted result. Kept because the screen a match
640 * ends on has to say where *this match* stands, and that is this sentence
641 * only while nothing is still waiting to be sent.
642 */
643 let said = NOTHING_POSTED;
644
645 /**
646 * What the summary says about the leaderboard: the last answer when
647 * everything played has been settled, and `SENDING` while anything has not —
648 * including a round that is being retried and one whose answer has not come
649 * back yet. It never implies a match is on the board while bytes are still in
650 * the frame's hand.
651 */
652 const sayMatch = (): void => {
653 sumSaved.textContent = unsent === null ? said : SENDING;
654 };
655
615656 /** The line by the leaderboard, which is a live region: it is announced. */
616657 const say = (line: string): void => {
658 said = line;
617659 savedLine.textContent = line;
660 sayMatch();
618661 };
619662
620663 /**
@@ -664,23 +707,12 @@ export function boot(host: Host): Frame {
664707 */
665708 function postResult(final: boolean): void {
666709 if (world === null) return;
667 const me = world.me;
668 const body = {
669 // The row is this playing of the match, not the match: two people who
670 // play one id, and one person who plays it twice, are three rows.
671 matchId: playId,
672 name: nameField.value,
673 rounds: world.match.score.t + world.match.score.ct,
674 scoreMine: world.match.score[me.team],
675 scoreTheirs: world.match.score[me.team === "t" ? "ct" : "t"],
676 kills: me.kills,
677 deaths: me.deaths,
678 headshots: me.headshots,
679 damage: Math.round(me.damage),
680 finished: final,
681 seconds: Math.round(world.time),
682 };
683 unsent = JSON.stringify(body);
710 // The row is this playing of the match, not the match: two people who play
711 // one id, and one person who plays it twice, are three rows. The numbers
712 // themselves are `summary.ts`'s, which is also what the screen at the end
713 // of the match reads — so what a player is shown and what the server was
714 // told are one object built once.
715 unsent = JSON.stringify(resultOf(world, playId, nameField.value, final));
684716 tries = 0;
685717 notBefore = 0;
686718 send(win.performance.now());
@@ -852,12 +884,16 @@ export function boot(host: Host): Frame {
852884 share.setAttribute("href", `/?match=${matchId}&map=${choice}`);
853885 world = createWorld(level, rules, name, team, 5, seed);
854886 posted = -1;
887 postedFinal = false;
855888 loop.reset();
856889 controls.sensitivity = clamp(Number(sensField.value) || 2.2, 0.2, 12);
857890 running = true;
858891 lastSound = 0;
859892 show(menu, false);
860893 show(pause, false);
894 // Whatever the last match ended on: Play again is a match, not a match with
895 // the one before it still on the screen.
896 show(summaryPanel, false);
861897 show(touchPanel, win.matchMedia("(pointer: coarse)").matches);
862898 audio.resume();
863899 void sceneCanvas.requestPointerLock();
@@ -872,17 +908,60 @@ export function boot(host: Host): Frame {
872908 restoreFull();
873909 void sceneCanvas.requestPointerLock();
874910 });
875 by("quit").addEventListener("click", () => {
911 /**
912 * Back to the page: the menu, nothing of the match left running, and the
913 * screen given back — there is no page to be back on while the stage has it.
914 * It is what Leave match does from the pause panel and what Back to the page
915 * does from the summary, because they are one going-back and not two.
916 */
917 function toMenu(): void {
876918 running = false;
877919 world = null;
920 wantsFull = false;
921 full.exit();
878922 show(pause, false);
923 show(summaryPanel, false);
879924 show(menu, true);
880925 show(touchPanel, false);
881926 // The menu is back, so the line before Play is about the next match again —
882927 // which on `generated` is a map nobody has drawn yet. The one just played
883928 // is still named under the stage, and its name is still a link.
884929 nameNextMap();
885 });
930 play.focus();
931 }
932 by("quit").addEventListener("click", toMenu);
933 leaveButton.addEventListener("click", toMenu);
934 // The way forward, which is the match again on the settings that are still in
935 // the menu's fields: `start` is the one place a match begins, so Play again
936 // and Play are the same press with the same controls behind them.
937 againButton.addEventListener("click", start);
938
939 /**
940 * The match is over: stop the run and put what happened on the screen, built
941 * from the world's final state *before* anything is let go of. It is the
942 * page's own DOM rather than the canvas — the loop has stopped and the last
943 * frame is frozen behind it — and it says the same three things the server
944 * was told: the score, your line, and everybody's rows.
945 *
946 * The pointer goes back, the pause panel goes away (a match can end while it
947 * is up), and the focus lands on Play again, so the way forward is one press
948 * of Enter from wherever the pointer happens to be.
949 */
950 function endMatch(current: World): void {
951 running = false;
952 doc.exitPointerLock();
953 const summary = summarize(current, resultOf(current, playId, nameField.value, true));
954 sumTitle.textContent = headline(summary);
955 sumScore.textContent = scoreLine(summary);
956 sumYou.textContent = yourLine(summary);
957 sumBoard.innerHTML = boardHtml(summary);
958 sayMatch();
959 show(pause, false);
960 show(buyPanel, false);
961 show(touchPanel, false);
962 show(summaryPanel, true);
963 againButton.focus();
964 }
886965 const saved = host.storage.getItem("cs.name");
887966 if (saved !== null && nameField.value === "") nameField.value = saved;
888967
@@ -965,17 +1044,17 @@ export function boot(host: Host): Frame {
9651044 // The buy window closes itself when the round goes live.
9661045 if (!buyPanel.hidden && !canBuy(world, me)) openBuy(false);
9671046 const played = world.match.score.t + world.match.score.ct;
968 if (played !== posted) {
1047 const over = world.match.phase === "match_over";
1048 // A round is posted as it is scored, and the end of the match is posted
1049 // again although no round was scored with it: the last round of a match is
1050 // won five seconds before the match is over, so a post keyed on the score
1051 // alone would leave every finished match on the board saying "in progress".
1052 if (played !== posted || (over && !postedFinal)) {
9691053 posted = played;
970 postResult(world.match.phase === "match_over");
971 }
972 if (world.match.phase === "match_over" && running) {
973 running = false;
974 doc.exitPointerLock();
975 show(menu, true);
976 nameNextMap();
977 play.focus();
1054 postedFinal = over;
1055 postResult(over);
9781056 }
1057 if (world.match.phase === "match_over" && running) endMatch(world);
9791058
9801059 renderer.draw(buildScene(world, camera, frameScene, view), elapsed);
9811060
src/client/pending.ts +9
@@ -38,6 +38,15 @@ export const LOST = "Not saved — the leaderboard could not be reached, so this
3838 /** What the page says before anything has been posted. Rendered by the server. */
3939 export const NOTHING_POSTED = "Rounds are saved to the leaderboard as they are played.";
4040
41 /**
42 * And what the screen after a match says while its last result is still on its
43 * way: it is not on the board yet, and saying anything else would be a promise
44 * the network has not kept. One of the four sentences above replaces it the
45 * moment that post is answered — including `LOST`, which is the honest end of a
46 * match played offline.
47 */
48 export const SENDING = "Not on the leaderboard yet — this match is still being sent.";
49
4150 /** How long the first retry waits; each further one waits twice as long. */
4251 export const FIRST_WAIT = 2000;
4352
src/client/stage.test-helper.ts +2 −2
@@ -191,10 +191,10 @@ export type FakeStage = {
191191 * contract between the page and this file, so `src/server.test.ts` checks the
192192 * served page against this same list rather than a second copy of it.
193193 */
194 export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "willplay", "plan", "planalt", "copymap", "play", "prep", "resume", "quit", "esc", "pauseid", "playing", "mapname", "share", "fps", "full"];
194 export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "willplay", "plan", "planalt", "copymap", "play", "prep", "resume", "quit", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full"];
195195
196196 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
197 const HIDDEN = ["pause", "buy", "touch", "full"];
197 const HIDDEN = ["pause", "summary", "buy", "touch", "full"];
198198
199199 /** The `[data-action]` buttons the shell renders for this game. */
200200 export const PAGE_ACTIONS = ["duck", "jump", "use", "reload", "buy", "fire"];
src/client/summary.test.ts added +115
@@ -0,0 +1,115 @@
1 /**
2 * The end of a match, as data and as markup: the order the rows come out in,
3 * the numbers the server is posted, the sentences over them and the escaping
4 * every name goes through on its way into the page.
5 *
6 * The world here is a real one — `createWorld` on de_forge, ten players — with
7 * the figures written onto it, because what is under test is the reading and
8 * not the playing.
9 */
10
11 import { expect, test } from "bun:test";
12 import { headshotShare as leaderboardShare } from "../db.ts";
13 import { parseResult } from "../result.ts";
14 import { endRound, SHORT } from "./match.ts";
15 import { DE_FORGE } from "./map.ts";
16 import {
17 boardHtml, headline, headshotShare, resultOf, scoreLine, scoreLines, summarize, yourLine,
18 } from "./summary.ts";
19 import { createWorld, type World } from "./world.ts";
20
21 const ID = "7f1c2a4e-1111-4000-8000-9b3d5e7a2c64";
22
23 /** A match nobody played, with numbers on it. */
24 function world(name = "you"): World {
25 const made = createWorld(DE_FORGE, SHORT, name, "ct", 5, 1234);
26 Object.assign(made.me, { kills: 9, deaths: 4, headshots: 3, damage: 1610.4, money: 3200 });
27 made.players[3]!.kills = 5;
28 made.players[5]!.kills = 5;
29 made.players[5]!.damage = 900;
30 made.time = 613.7;
31 return made;
32 }
33
34 test("the rows are everyone, most kills first, damage breaking the tie", () => {
35 const match = world();
36 const lines = scoreLines(match);
37 expect(lines.length).toBe(match.players.length);
38 expect(lines[0]?.you).toBe(true);
39 expect(lines[0]?.kills).toBe(9);
40 // Two bots on five kills: the one that did more damage is the one above.
41 expect(lines[1]?.name).toBe(match.players[5]!.name);
42 expect(lines[2]?.name).toBe(match.players[3]!.name);
43 // Exactly one row is yours, and the sides are both there.
44 expect(lines.filter((line) => line.you).length).toBe(1);
45 expect(new Set(lines.map((line) => line.team))).toEqual(new Set(["t", "ct"]));
46 // Damage is rounded here and nowhere else, the way it is posted.
47 expect(lines[0]?.damage).toBe(1610);
48 // The world's own array is left in the order the world keeps it.
49 expect(match.players[0]).toBe(match.me);
50 });
51
52 test("what is posted is what is shown: one object, and the server accepts it", () => {
53 const match = world();
54 match.match.score.ct = 5;
55 match.match.score.t = 2;
56 const result = resultOf(match, ID, "Vesper", true);
57 expect(parseResult(result)).not.toBeNull();
58 expect(result).toEqual({
59 matchId: ID, name: "Vesper", rounds: 7, scoreMine: 5, scoreTheirs: 2,
60 kills: 9, deaths: 4, headshots: 3, damage: 1610, finished: true, seconds: 614,
61 });
62 // The score is read off the side the player is on now, which is what half
63 // time swapping both the teams and the score means.
64 match.me.team = "t";
65 expect(resultOf(match, ID, "Vesper", false).scoreMine).toBe(2);
66 expect(resultOf(match, ID, "Vesper", false).finished).toBe(false);
67 });
68
69 test("the sentences say who won, by how much, and what you did", () => {
70 const match = world();
71 match.match.score.ct = 5;
72 match.match.score.t = 3;
73 endRound(match.match, "ct", "elimination");
74 const summary = summarize(match, resultOf(match, ID, "you", true));
75 expect(summary.won).toBe(true);
76 expect(headline(summary)).toBe("Counter-Terrorists win");
77 expect(scoreLine(summary)).toBe("6–3 — you won as Counter-Terrorists over 9 rounds.");
78 expect(yourLine(summary)).toBe("You: 9 kills · 4 deaths · 1610 damage · 33% headshots · 6 rounds won.");
79
80 // The other side of the same match, and a match that ended with no winner at
81 // all — which is not a thing the rules produce, but the screen is written for
82 // the state it is handed and not for the state it hopes for.
83 match.me.team = "t";
84 const lost = summarize(match, resultOf(match, ID, "you", true));
85 expect(headline(lost)).toBe("Counter-Terrorists win");
86 expect(scoreLine(lost)).toBe("3–6 — you lost as Terrorists over 9 rounds.");
87 match.match.matchWinner = null;
88 expect(headline(summarize(match, resultOf(match, ID, "you", true)))).toBe("Match over");
89 });
90
91 test("one round and one round won are singular", () => {
92 const match = world();
93 match.match.score.ct = 1;
94 Object.assign(match.me, { kills: 1, headshots: 1 });
95 const summary = summarize(match, resultOf(match, ID, "you", true));
96 expect(scoreLine(summary)).toContain("over 1 round.");
97 expect(yourLine(summary)).toContain("1 round won.");
98 expect(yourLine(summary)).toContain("100% headshots");
99 });
100
101 test("the headshot share is the leaderboard's own arithmetic", () => {
102 for (const [headshots, kills] of [[0, 0], [0, 7], [3, 9], [1, 3], [7, 7]] as [number, number][])
103 expect(headshotShare(headshots, kills)).toBe(leaderboardShare(headshots, kills));
104 });
105
106 test("a name is escaped on its way into the table, and every row is there", () => {
107 const match = world(`<img src=x onerror="alert('x')">`);
108 const html = boardHtml(summarize(match, resultOf(match, ID, "you", true)));
109 expect(html).not.toContain("<img");
110 expect(html).toContain("&lt;img src=x onerror=&quot;alert(&#39;x&#39;)&quot;&gt;");
111 expect(html.match(/<tr/g)?.length).toBe(match.players.length + 1);
112 expect(html).toContain("<b>(you)</b>");
113 // The figures are the row's, in the order the heading names them.
114 expect(html).toContain(`<td class="n">9</td><td class="n">4</td><td class="n">3</td><td class="n">1610</td>`);
115 });
src/client/summary.ts added +171
@@ -0,0 +1,171 @@
1 /**
2 * The match that just ended, in words: the final score, which side won, your
3 * own line, and everyone's rows in the order the in-game scoreboard draws them.
4 *
5 * It is plain data in and plain data out — a `World` goes in, a `Summary` and a
6 * little markup come out — so the frame in `main.ts` only has to put the
7 * strings on the page. Two things are deliberately shared rather than written
8 * twice:
9 *
10 * - `resultOf` is the one place a match is turned into numbers. The frame posts
11 * exactly that object to `/api/match` and the screen below it reads the same
12 * one, so what a player is shown and what the leaderboard was told cannot
13 * disagree — they are the same nine fields, built once.
14 * - `scoreLines` is the order and the figures the HUD's Tab scoreboard already
15 * draws (`drawScoreboard` in `hud.ts` calls it), so the rows a player reads
16 * after the match are the rows they read during it: sorted by kills, then by
17 * damage, with a dead player's row marked.
18 *
19 * What the after-match table leaves out is money, which the HUD's board keeps:
20 * it is a number about the next round's buy, and there is no next round. That
21 * makes the table four figures wide and it fits a 360px screen without a
22 * sideways scroll.
23 *
24 * `escapeHtml` is this file's own because a browser bundle may not import the
25 * server's `src/pages/shell.ts`, and a player's name arrives from a text field:
26 * every name below goes through it. The numbers do not need it — they are
27 * numbers — but they go through the same door anyway, because a rule with an
28 * exception is a rule somebody forgets.
29 */
30
31 import type { Result } from "../result.ts";
32 import type { Team } from "./match.ts";
33 import type { World } from "./world.ts";
34
35 /** One player's row, as both the HUD's scoreboard and the summary table read it. */
36 export type Line = {
37 name: string;
38 team: Team;
39 /** Whether this row is the person playing, which is the one marked `(you)`. */
40 you: boolean;
41 /** Alive right now: the HUD dims the rest. */
42 alive: boolean;
43 kills: number;
44 deaths: number;
45 headshots: number;
46 /** Rounded, because it is rounded everywhere it is shown and everywhere it is posted. */
47 damage: number;
48 money: number;
49 };
50
51 /**
52 * Everyone in the match, in the order a scoreboard shows them: most kills
53 * first, and damage breaks a tie. A copy is made because `world.players` is the
54 * simulation's own array and nothing here may reorder it.
55 */
56 export const scoreLines = (world: World): Line[] =>
57 world.players
58 .map((p) => ({
59 name: p.name,
60 team: p.team,
61 you: p === world.me,
62 alive: p.alive,
63 kills: p.kills,
64 deaths: p.deaths,
65 headshots: p.headshots,
66 damage: Math.round(p.damage),
67 money: p.money,
68 }))
69 .sort((a, b) => b.kills - a.kills || b.damage - a.damage);
70
71 /**
72 * What this playing of the match is, as the server takes it: the row key, the
73 * name the page will be listed under, the score from this player's side and
74 * their own figures. `final` says whether the match is over — a round scored
75 * mid-match posts the same shape with it false.
76 *
77 * The scores are read off the team the player is on *now*, which is the point:
78 * sides swap at half time and `src/client/match.ts` swaps the score with them,
79 * so `score[me.team]` is this person's score in either half.
80 */
81 export function resultOf(world: World, matchId: string, name: string, final: boolean): Result {
82 const me = world.me;
83 return {
84 matchId,
85 name,
86 rounds: world.match.score.t + world.match.score.ct,
87 scoreMine: world.match.score[me.team],
88 scoreTheirs: world.match.score[me.team === "t" ? "ct" : "t"],
89 kills: me.kills,
90 deaths: me.deaths,
91 headshots: me.headshots,
92 damage: Math.round(me.damage),
93 finished: final,
94 seconds: Math.round(world.time),
95 };
96 }
97
98 /** The finished match, as the screen after it reads it. */
99 export type Summary = {
100 /** The numbers, exactly as they were posted. */
101 result: Result;
102 /** The side the player finished the match on. */
103 team: Team;
104 /** Who won it, or null for a match that stopped without one. */
105 winner: Team | null;
106 won: boolean;
107 lines: Line[];
108 };
109
110 export const summarize = (world: World, result: Result): Summary => ({
111 result,
112 team: world.me.team,
113 winner: world.match.matchWinner,
114 won: world.match.matchWinner === world.me.team,
115 lines: scoreLines(world),
116 });
117
118 /** What a side is called in a sentence. The HUD has room for two letters; this has room for words. */
119 export const SIDES: Record<Team, string> = { t: "Terrorists", ct: "Counter-Terrorists" };
120
121 /** The heading over the summary: who won, in words, and what a draw would say. */
122 export const headline = (summary: Summary): string =>
123 summary.winner === null ? "Match over" : `${SIDES[summary.winner]} win`;
124
125 /** `13–7 — you won as Counter-Terrorists over 20 rounds.` */
126 export function scoreLine(summary: Summary): string {
127 const { scoreMine, scoreTheirs, rounds } = summary.result;
128 return `${scoreMine}–${scoreTheirs} — you ${summary.won ? "won" : "lost"} as ` +
129 `${SIDES[summary.team]} over ${rounds} round${rounds === 1 ? "" : "s"}.`;
130 }
131
132 /**
133 * The headshot share, as a percentage of kills — the same arithmetic
134 * `headshotShare` in `src/db.ts` does for the leaderboard's rows, written here
135 * because a browser bundle cannot import a module that opens a database.
136 * `summary.test.ts` holds the two against each other so they cannot drift.
137 */
138 export const headshotShare = (headshots: number, kills: number): string =>
139 kills === 0 ? "—" : `${Math.round((headshots / kills) * 100)}%`;
140
141 /** Your own line, called out: the five numbers the leaderboard was told about you. */
142 export function yourLine(summary: Summary): string {
143 const { kills, deaths, damage, headshots, scoreMine } = summary.result;
144 return `You: ${kills} kills · ${deaths} deaths · ${damage} damage · ` +
145 `${headshotShare(headshots, kills)} headshots · ${scoreMine} round${scoreMine === 1 ? "" : "s"} won.`;
146 }
147
148 const escapeHtml = (s: string): string =>
149 s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
150
151 /** One figure cell, right-aligned like every other number in the table. */
152 const cell = (value: number): string => `<td class="n">${escapeHtml(String(value))}</td>`;
153
154 /**
155 * Both teams' rows, as a table. The side is a letter in the player's own colour
156 * — the same two the HUD and the radar draw a body in — and the row for the
157 * person playing is marked, because a table of ten names has to answer "which
158 * one was me" at a glance.
159 */
160 export function boardHtml(summary: Summary): string {
161 const rows = summary.lines
162 .map((line) => {
163 const side = `<span class="${line.team}">${line.team === "t" ? "T" : "CT"}</span>`;
164 const who = `${escapeHtml(line.name)}${line.you ? " <b>(you)</b>" : ""}`;
165 return `<tr${line.you ? ` class="you"` : ""}><td>${side} ${who}</td>` +
166 cell(line.kills) + cell(line.deaths) + cell(line.headshots) + cell(line.damage) + `</tr>`;
167 })
168 .join("");
169 return `<table><thead><tr><th>player</th><th class="n">kills</th><th class="n">deaths</th>` +
170 `<th class="n">hs</th><th class="n">damage</th></tr></thead><tbody>${rows}</tbody></table>`;
171 }
src/games.ts +13
@@ -402,6 +402,19 @@ export type Game = {
402402 * byte: range 34,810 raw / 14,569 gzipped and example 21,115 / 9,189, the same
403403 * numbers #216 measured, and both declare no assets at all. Measured
404404 * 2026-09-21 with #216 merged in.
405 * #144 gave the shooter an end: `src/client/summary.ts`, which turns the world
406 * a match finished in into the numbers that are posted and the words and rows
407 * that are read, plus the overlay the page renders for it and the frame that
408 * fills it in. The app grew 2,051 raw / 803 gzipped bytes for it — 89,174 →
409 * 91,225 raw and 35,324 → 36,127 gzipped — and most of that is strings a
410 * minifier cannot shorten: the two sides' names, the sentences over the score,
411 * the table's headings and the one line about whether the result reached the
412 * leaderboard. The HUD's Tab board lost its own sort and its own row
413 * formatting to that module, which is why the feature costs about two
414 * kilobytes rather than three. It fits the 98,000 / 39,000 #214 set with 6,775
415 * raw and 2,873 gzipped to spare, so no ceiling moved. Neither other bundle
416 * changed a byte: nothing outside `src/client/` and `src/pages/game.ts` did.
417 * Measured 2026-09-21.
405418 */
406419 export const GAMES: readonly Game[] = [
407420 {
src/pages/game.test.ts +21
@@ -75,6 +75,27 @@ test("the leaderboard and the menu are in the first paint, not fetched", () => {
7575 expect(html).toContain(`<button type="button" class="primary" id="play" disabled>Play</button>`);
7676 });
7777
78 test("the screen a match ends on is in the page, hidden, with the menu's own sentence on it", () => {
79 const html = gamePage(db(), options);
80 // An overlay like the menu and the pause, rendered hidden: the first paint is
81 // already true and `src/client/main.ts` only has to fill it in and show it.
82 expect(html).toContain(`<div class="overlay" id="summary" hidden>`);
83 // The way forward and the way back, both buttons, both reachable by keyboard.
84 expect(html).toContain(`<button type="button" class="primary" id="again">Play again</button>`);
85 expect(html).toContain(`<button type="button" id="leave">Back to the page</button>`);
86 for (const id of ["sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved"])
87 expect(html).toContain(`id="${id}"`);
88
89 // Under which name it was all recorded: the menu's sentence, rendered twice
90 // and written once, so the two screens cannot say different things. A guest
91 // is told they are listed as one; a signed-in player is told their name.
92 const guest = "the leaderboard lists your score as <b>a guest's</b>";
93 expect(html.match(new RegExp(guest.replace(/[$()*+.?[\\\]^{|}]/g, "\\$&"), "g"))?.length).toBe(2);
94 const signed = gamePage(db(), { ...options, who: { role: "user", id: "u-1", name: "kestrel" } });
95 expect(signed.match(/Signed in as <b>kestrel<\/b>/g)?.length).toBe(2);
96 expect(signed).not.toContain("as <b>a guest's</b>");
97 });
98
7899 test("a link that asked for a match is what the page opens with, and junk is a sentence", () => {
79100 const id = "9f8a7b6c5d4e3f21";
80101 const asked = { match: id, badMatch: false, map: "generated", badMap: false } as const;
src/pages/game.ts +53 −4
@@ -105,6 +105,20 @@ a.share:hover, a.share:focus-visible { background:#27303f; text-decoration:under
105105 the space it takes is the same before it is drawn as after. */
106106 #plan { flex:none; width:6rem; height:6rem; background:#0f131a; border:1px solid var(--line);
107107 border-radius:.35rem; }
108 /* The screen a match ends on. The table is four figures wide so it fits a
109 360px phone without a sideways scroll, the numbers are right-aligned like
110 every table of numbers on this page, and your own row is the one that is not
111 dim — the same question the HUD's white name answers while you are playing. */
112 #sumboard { width:100%; max-width:34rem; }
113 #summary table { font-size:.82rem; }
114 #summary th, #summary td { padding:.25rem .35rem; }
115 #summary th.n, #summary td.n { text-align:right; }
116 #summary tbody td { color:var(--dim); }
117 #summary tr.you td { color:var(--text); }
118 #summary .t { color:var(--t); font-weight:600; }
119 #summary .ct { color:var(--ct); font-weight:600; }
120 #sumscore { font-size:1.1rem; color:var(--text); }
121 #sumyou { color:var(--text); }
108122 #willtext { flex:1 1 13rem; min-width:0; max-width:22rem; text-align:left; }
109123 #willtext p { margin:.1rem 0; }
110124 /* Two lines' worth, whether it is holding the sentence the server rendered or
@@ -143,6 +157,41 @@ export const shareHref = (asked: Asked): string =>
143157 */
144158 const SIGN_IN = "/__forge/signin?to=%2F";
145159
160 /**
161 * Under which name what you play is recorded. One sentence, rendered twice: in
162 * the menu before the match and on the summary after it, because the two cannot
163 * be allowed to say different things about the same row. A signed-in player's
164 * name is the forge's and the field only shows it; a guest picks theirs and the
165 * page says plainly that it is listed as a guest's.
166 */
167 const recordedAs = (visitor: Who): string =>
168 visitor.role === "anon"
169 ? `You are playing as a guest, so the leaderboard lists your score as <b>a guest's</b>.
170 <a href="${SIGN_IN}" rel="noreferrer">Sign in to the forge</a> to play under your own name.`
171 : `Signed in as <b>${escape(visitor.name)}</b>. Your results are recorded under that name.`;
172
173 /**
174 * The screen a match ends on: the final score, which side won, your own line
175 * and everybody's rows — the page's own DOM, because it is read rather than
176 * played, and the same overlay the menu, the pause and the buy panel are.
177 *
178 * Everything in it that a match decides is written by `src/client/main.ts` on
179 * the frame the match ends, out of `src/client/summary.ts`: the heading, the
180 * score, your line, the table and the sentence about whether the result landed
181 * on the leaderboard. What is rendered here is what does not change — the two
182 * buttons, which are the whole of what there is to do next, and the sentence
183 * about the name it is all recorded under, which is the menu's own.
184 */
185 const summary = (visitor: Who): string => `
186 <h2 id="sumtitle">Match over</h2>
187 <p id="sumscore"></p>
188 <p id="sumyou"></p>
189 <div id="sumboard"></div>
190 <div class="row"><button type="button" class="primary" id="again">Play again</button><button type="button" id="leave">Back to the page</button></div>
191 <p class="hint" id="sumsaved"></p>
192 <p class="hint">${recordedAs(visitor)}</p>
193 `;
194
146195 /**
147196 * The menu, which says under which name the result will be recorded. A signed
148197 * in player's name is the forge's and the field only shows it; a guest picks
@@ -166,10 +215,7 @@ const menu = (visitor: Who, asked: Asked): string => `
166215 <p>Five against five on <b>de_forge</b>, on a map generated for the match, or on any generated
167216 map you can name. Bomb rounds, CS movement and economy, bots that push the sites, and a radar
168217 in the corner so a map nobody has ever walked is still a place you can find your way around.</p>
169 <p class="hint">${visitor.role === "anon"
170 ? `You are playing as a guest, so the leaderboard lists your score as <b>a guest's</b>.
171 <a href="${SIGN_IN}" rel="noreferrer">Sign in to the forge</a> to play under your own name.`
172 : `Signed in as <b>${escape(visitor.name)}</b>. Your results are recorded under that name.`}</p>
218 <p class="hint">${recordedAs(visitor)}</p>
173219 <div class="row">
174220 <label>Name<input id="name" maxlength="24" placeholder="you" autocomplete="nickname"${
175221 visitor.role === "anon" ? "" : ` value="${escape(visitor.name)}" readonly`
@@ -338,6 +384,9 @@ export function gamePage(
338384 overlays: [
339385 { id: "menu", html: menu(visitor, asked) },
340386 { id: "pause", html: PAUSE, hidden: true },
387 // After the pause, so a match that ends while it is up is read over it
388 // rather than under it. `src/client/main.ts` hides the pause anyway.
389 { id: "summary", html: summary(visitor), hidden: true },
341390 { id: "buy", html: "", hidden: true },
342391 ],
343392 }),
static/app.d0901a691f.js added +58

Diff not shown — a line 71,841 characters wide (limit 2,000). +58

View file
static/app.df3a822521.js deleted −58

Diff not shown — a line 69,802 characters wide (limit 2,000). −58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs