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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/144-a-match-ends-with-a-scoreboard-not-a-men → main 1 commit fork
13 files
changed +721
−110
README.md +10
| @@ -98,6 +98,16 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface | ||
| 98 | 98 | line is a live region, so it is announced and not only drawn. Nothing is kept |
| 99 | 99 | across a visit: a round that never reaches the server before you close the |
| 100 | 100 | 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. | |
| 101 | 111 | |
| 102 | 112 | ## A match you can play again |
| 103 | 113 |
src/client/hud.ts +8 −5
| @@ -29,6 +29,7 @@ import { CS_MOVE } from "./movement.ts"; | ||
| 29 | 29 | import { BLUE, CROSSHAIR, CT_COLOR, GREY, RED, T_COLOR, teamColor, WHITE } from "./palette.ts"; |
| 30 | 30 | import { inaccuracy, WEAPONS } from "./weapons.ts"; |
| 31 | 31 | import { useProgress } from "./bomb.ts"; |
| 32 | import { scoreLines } from "./summary.ts"; | |
| 32 | 33 | import { currentWeapon, type Player, type World } from "./world.ts"; |
| 33 | 34 | |
| 34 | 35 | /** |
| @@ -286,18 +287,20 @@ function drawScoreboard(ctx: Paint, ui: Overlay, world: World): void { | ||
| 286 | 287 | // The headings sit on the row they head, so there is no lead over a value. |
| 287 | 288 | ui.columns(FIGURES, NOTHING, { x: right, y: y + rowH, lead: 0 }, stride); |
| 288 | 289 | |
| 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) => { | |
| 291 | 294 | const ty = y + rowH * (row + 2.4); |
| 292 | 295 | // A dead player's whole row is dimmed — the name and the figures both — so |
| 293 | 296 | // the fade is set around them rather than passed to each line. |
| 294 | 297 | 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, | |
| 297 | 300 | }); |
| 298 | 301 | ctx.globalAlpha = dim; |
| 299 | 302 | 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}`, | |
| 301 | 304 | ], { x: right, y: ty }, stride); |
| 302 | 305 | ctx.globalAlpha = 1; |
| 303 | 306 | }); |
src/client/main.test.ts +151 −10
| @@ -28,17 +28,18 @@ import { generateLevel, nameOf, seedFrom, seedOf } from "./generate.ts"; | ||
| 28 | 28 | import type { Level } from "./level.ts"; |
| 29 | 29 | import { boot, COPIED, gameMeshes, NOT_AN_ID, NOT_A_MAP, NOT_COPIED, type Frame } from "./main.ts"; |
| 30 | 30 | import { DE_FORGE } from "./map.ts"; |
| 31 | import { SHORT } from "./match.ts"; | |
| 31 | import { endRound, SHORT } from "./match.ts"; | |
| 32 | 32 | import { LAYERS, materialTextures } from "./materials.ts"; |
| 33 | 33 | import { recorder } from "./paint.test-helper.ts"; |
| 34 | 34 | import { drawPreview, previewText } from "./preview.ts"; |
| 35 | 35 | 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, | |
| 37 | 37 | } from "./pending.ts"; |
| 38 | import { headshotShare, resultOf, scoreLine, yourLine } from "./summary.ts"; | |
| 38 | 39 | import { digest, snapshot, withoutEntropy } from "./replay.ts"; |
| 39 | 40 | import { WEAPONS } from "./weapons.ts"; |
| 40 | 41 | import { matchOf } from "../matchid.ts"; |
| 41 | import { parseResult } from "../result.ts"; | |
| 42 | import { parseResult, type Result } from "../result.ts"; | |
| 42 | 43 | import { fakeStage, ORIGIN, type FakeStage } from "./stage.test-helper.ts"; |
| 43 | 44 | import { createWorld, type World } from "./world.ts"; |
| 44 | 45 | |
| @@ -466,18 +467,48 @@ test("leaving the match puts the menu back and stops the frame", () => { | ||
| 466 | 467 | expect(stage.draws.length).toBe(1); |
| 467 | 468 | }); |
| 468 | 469 | |
| 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 } { | |
| 470 | 494 | const stage = playing(); |
| 471 | 495 | stage.lock(true); |
| 472 | 496 | stage.step(FRAME); |
| 473 | 497 | const world = worldOf(stage); |
| 474 | 498 | 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 | } | |
| 478 | 502 | |
| 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); | |
| 481 | 512 | expect(stage.document.pointerLockElement).toBe(null); |
| 482 | 513 | // The run stopped: the frames keep coming and nothing more is drawn. |
| 483 | 514 | const drawn = stage.draws.length; |
| @@ -486,6 +517,107 @@ test("match_over stops the run, shows the menu and focuses play", () => { | ||
| 486 | 517 | expect(stage.draws.length).toBe(drawn); |
| 487 | 518 | }); |
| 488 | 519 | |
| 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 | ||
| 489 | 621 | test("dying moves the camera behind a teammate, and fire moves it behind the next", () => { |
| 490 | 622 | const stage = playing(); |
| 491 | 623 | stage.lock(true); |
| @@ -732,7 +864,10 @@ test("a match that ends offline is tried again to a cap, and then the page says | ||
| 732 | 864 | await settled(); |
| 733 | 865 | expect(stage.posts.length).toBe(2); |
| 734 | 866 | 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); | |
| 736 | 871 | |
| 737 | 872 | // Three more attempts, each after twice the wait of the last, and the frames |
| 738 | 873 | // 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 | ||
| 741 | 876 | await settled(); |
| 742 | 877 | expect(stage.posts.length).toBe(attempt + 2); |
| 743 | 878 | 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); | |
| 744 | 882 | } |
| 745 | 883 | 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); | |
| 746 | 887 | |
| 747 | 888 | // Capped: a tab left open all afternoon posts nothing more. |
| 748 | 889 | for (let frame = 0; frame < 10; frame++) stage.step(LONGEST_WAIT); |
src/client/main.ts +110 −31
| @@ -3,8 +3,18 @@ | ||
| 3 | 3 | * and the engine's controls say which actions are held; `binding` turns those |
| 4 | 4 | * names into the `Input` the world takes. `scene.ts` then turns the world into |
| 5 | 5 | * 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. | |
| 8 | 18 | * |
| 9 | 19 | * A match is its id. The menu's field takes one — pasted, or put there by |
| 10 | 20 | * `/?match=<id>` — and `start` below validates it through `src/matchid.ts`, the |
| @@ -67,7 +77,8 @@ import { COMPETITIVE, SHORT, type Rules } from "./match.ts"; | ||
| 67 | 77 | import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts"; |
| 68 | 78 | import { meshFor } from "./models.ts"; |
| 69 | 79 | 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"; | |
| 71 | 82 | import { drawPreview, previewText } from "./preview.ts"; |
| 72 | 83 | import { buildScene, createScene, SKY, type Scene } from "./scene.ts"; |
| 73 | 84 | import { usePolicy } from "./bots.ts"; |
| @@ -331,6 +342,16 @@ export function boot(host: Host): Frame { | ||
| 331 | 342 | const resumeButton = by("resume"); |
| 332 | 343 | // The line in the pause panel that says what Escape just did on this browser. |
| 333 | 344 | 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"); | |
| 334 | 355 | |
| 335 | 356 | const maybeBuild = host.createRenderer(sceneCanvas); |
| 336 | 357 | if (maybeBuild === null) { |
| @@ -452,6 +473,8 @@ export function boot(host: Host): Frame { | ||
| 452 | 473 | let lastSound = 0; |
| 453 | 474 | let frameAverage = 16.7; |
| 454 | 475 | let posted = -1; |
| 476 | /** Whether the post that said this match is finished has been made. */ | |
| 477 | let postedFinal = false; | |
| 455 | 478 | |
| 456 | 479 | const show = (element: Panel, on: boolean): void => { |
| 457 | 480 | element.hidden = !on; |
| @@ -612,9 +635,29 @@ export function boot(host: Host): Frame { | ||
| 612 | 635 | let tries = 0; |
| 613 | 636 | let notBefore = 0; |
| 614 | 637 | |
| 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 | ||
| 615 | 656 | /** The line by the leaderboard, which is a live region: it is announced. */ |
| 616 | 657 | const say = (line: string): void => { |
| 658 | said = line; | |
| 617 | 659 | savedLine.textContent = line; |
| 660 | sayMatch(); | |
| 618 | 661 | }; |
| 619 | 662 | |
| 620 | 663 | /** |
| @@ -664,23 +707,12 @@ export function boot(host: Host): Frame { | ||
| 664 | 707 | */ |
| 665 | 708 | function postResult(final: boolean): void { |
| 666 | 709 | 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)); | |
| 684 | 716 | tries = 0; |
| 685 | 717 | notBefore = 0; |
| 686 | 718 | send(win.performance.now()); |
| @@ -852,12 +884,16 @@ export function boot(host: Host): Frame { | ||
| 852 | 884 | share.setAttribute("href", `/?match=${matchId}&map=${choice}`); |
| 853 | 885 | world = createWorld(level, rules, name, team, 5, seed); |
| 854 | 886 | posted = -1; |
| 887 | postedFinal = false; | |
| 855 | 888 | loop.reset(); |
| 856 | 889 | controls.sensitivity = clamp(Number(sensField.value) || 2.2, 0.2, 12); |
| 857 | 890 | running = true; |
| 858 | 891 | lastSound = 0; |
| 859 | 892 | show(menu, false); |
| 860 | 893 | 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); | |
| 861 | 897 | show(touchPanel, win.matchMedia("(pointer: coarse)").matches); |
| 862 | 898 | audio.resume(); |
| 863 | 899 | void sceneCanvas.requestPointerLock(); |
| @@ -872,17 +908,60 @@ export function boot(host: Host): Frame { | ||
| 872 | 908 | restoreFull(); |
| 873 | 909 | void sceneCanvas.requestPointerLock(); |
| 874 | 910 | }); |
| 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 { | |
| 876 | 918 | running = false; |
| 877 | 919 | world = null; |
| 920 | wantsFull = false; | |
| 921 | full.exit(); | |
| 878 | 922 | show(pause, false); |
| 923 | show(summaryPanel, false); | |
| 879 | 924 | show(menu, true); |
| 880 | 925 | show(touchPanel, false); |
| 881 | 926 | // The menu is back, so the line before Play is about the next match again — |
| 882 | 927 | // which on `generated` is a map nobody has drawn yet. The one just played |
| 883 | 928 | // is still named under the stage, and its name is still a link. |
| 884 | 929 | 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 | } | |
| 886 | 965 | const saved = host.storage.getItem("cs.name"); |
| 887 | 966 | if (saved !== null && nameField.value === "") nameField.value = saved; |
| 888 | 967 | |
| @@ -965,17 +1044,17 @@ export function boot(host: Host): Frame { | ||
| 965 | 1044 | // The buy window closes itself when the round goes live. |
| 966 | 1045 | if (!buyPanel.hidden && !canBuy(world, me)) openBuy(false); |
| 967 | 1046 | 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)) { | |
| 969 | 1053 | 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); | |
| 978 | 1056 | } |
| 1057 | if (world.match.phase === "match_over" && running) endMatch(world); | |
| 979 | 1058 | |
| 980 | 1059 | renderer.draw(buildScene(world, camera, frameScene, view), elapsed); |
| 981 | 1060 |
src/client/pending.ts +9
| @@ -38,6 +38,15 @@ export const LOST = "Not saved — the leaderboard could not be reached, so this | ||
| 38 | 38 | /** What the page says before anything has been posted. Rendered by the server. */ |
| 39 | 39 | export const NOTHING_POSTED = "Rounds are saved to the leaderboard as they are played."; |
| 40 | 40 | |
| 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 | ||
| 41 | 50 | /** How long the first retry waits; each further one waits twice as long. */ |
| 42 | 51 | export const FIRST_WAIT = 2000; |
| 43 | 52 |
src/client/stage.test-helper.ts +2 −2
| @@ -191,10 +191,10 @@ export type FakeStage = { | ||
| 191 | 191 | * contract between the page and this file, so `src/server.test.ts` checks the |
| 192 | 192 | * served page against this same list rather than a second copy of it. |
| 193 | 193 | */ |
| 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"]; | |
| 195 | 195 | |
| 196 | 196 | /** 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"]; | |
| 198 | 198 | |
| 199 | 199 | /** The `[data-action]` buttons the shell renders for this game. */ |
| 200 | 200 | 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("<img src=x onerror="alert('x')">"); | |
| 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 = { | ||
| 402 | 402 | * byte: range 34,810 raw / 14,569 gzipped and example 21,115 / 9,189, the same |
| 403 | 403 | * numbers #216 measured, and both declare no assets at all. Measured |
| 404 | 404 | * 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. | |
| 405 | 418 | */ |
| 406 | 419 | export const GAMES: readonly Game[] = [ |
| 407 | 420 | { |
src/pages/game.test.ts +21
| @@ -75,6 +75,27 @@ test("the leaderboard and the menu are in the first paint, not fetched", () => { | ||
| 75 | 75 | expect(html).toContain(`<button type="button" class="primary" id="play" disabled>Play</button>`); |
| 76 | 76 | }); |
| 77 | 77 | |
| 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 | ||
| 78 | 99 | test("a link that asked for a match is what the page opens with, and junk is a sentence", () => { |
| 79 | 100 | const id = "9f8a7b6c5d4e3f21"; |
| 80 | 101 | 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 | ||
| 105 | 105 | the space it takes is the same before it is drawn as after. */ |
| 106 | 106 | #plan { flex:none; width:6rem; height:6rem; background:#0f131a; border:1px solid var(--line); |
| 107 | 107 | 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); } | |
| 108 | 122 | #willtext { flex:1 1 13rem; min-width:0; max-width:22rem; text-align:left; } |
| 109 | 123 | #willtext p { margin:.1rem 0; } |
| 110 | 124 | /* Two lines' worth, whether it is holding the sentence the server rendered or |
| @@ -143,6 +157,41 @@ export const shareHref = (asked: Asked): string => | ||
| 143 | 157 | */ |
| 144 | 158 | const SIGN_IN = "/__forge/signin?to=%2F"; |
| 145 | 159 | |
| 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 | ||
| 146 | 195 | /** |
| 147 | 196 | * The menu, which says under which name the result will be recorded. A signed |
| 148 | 197 | * 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 => ` | ||
| 166 | 215 | <p>Five against five on <b>de_forge</b>, on a map generated for the match, or on any generated |
| 167 | 216 | map you can name. Bomb rounds, CS movement and economy, bots that push the sites, and a radar |
| 168 | 217 | 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> | |
| 173 | 219 | <div class="row"> |
| 174 | 220 | <label>Name<input id="name" maxlength="24" placeholder="you" autocomplete="nickname"${ |
| 175 | 221 | visitor.role === "anon" ? "" : ` value="${escape(visitor.name)}" readonly` |
| @@ -338,6 +384,9 @@ export function gamePage( | ||
| 338 | 384 | overlays: [ |
| 339 | 385 | { id: "menu", html: menu(visitor, asked) }, |
| 340 | 386 | { 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 }, | |
| 341 | 390 | { id: "buy", html: "", hidden: true }, |
| 342 | 391 | ], |
| 343 | 392 | }), |
static/app.d0901a691f.js added +58
Diff not shown — a line 71,841 characters wide (limit 2,000). +58
View filestatic/app.df3a822521.js deleted −58
Diff not shown — a line 69,802 characters wide (limit 2,000). −58
View file
new above
offline