Split The code is public; the app answers only to the crew.
A match you walk out of stops calling itself "in progress" on the leaderboard #76
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/50-a-match-you-walk-out-of-stops-calling-it → main 4 commits fork 2 threads · 2 unresolved
15 files
changed +231
−21
.scout-tmp/cookies2.txt added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | # Netscape HTTP Cookie File | |
| 2 | # https://curl.se/docs/http-cookies.html | |
| 3 | # This file was generated by libcurl! Edit at your own risk. | |
| 4 | ||
| 5 | #HttpOnly_127.0.0.1 FALSE / FALSE 1821245237 cs_session 12894e80728ebd16d3eb0dace3e835b2 | |
.scout-tmp/out1.html added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | <h2>Leaderboard</h2> | |
| 2 | <p class="you">You are playing as <b>guest a247</b>. <a href="/__forge/signin?to=%2F" rel="noreferrer">Sign in on the forge</a> to put your matches on the board under your name.</p> | |
| 3 | <table><thead><tr><th>#</th><th>player</th><th>matches</th><th>kills</th><th>K/D</th><th>HS</th><th>wins</th></tr></thead><tbody><tr><td>1</td><td>guest a247 <span class="tag">guest</span></td><td>1</td><td>9</td><td>2.25</td><td>33%</td><td>0</td></tr></tbody></table> | |
| 4 | <h3>Recent matches</h3> | |
| 5 | <ul class="recent"><li><b>guest a247 <span class="tag">guest</span></b> 3–2 <span>9k 4d · 1200 damage · abandoned</span> <time>2026-09-18 05:26:55</time></li></ul>↵ |
.scout-tmp/out2.html added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | not yours↵ |
.scout-tmp/out3.html added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | <h2>Leaderboard</h2> | |
| 2 | <p class="you">You are playing as <b>guest 1289</b>. <a href="/__forge/signin?to=%2F" rel="noreferrer">Sign in on the forge</a> to put your matches on the board under your name.</p> | |
| 3 | <table><thead><tr><th>#</th><th>player</th><th>matches</th><th>kills</th><th>K/D</th><th>HS</th><th>wins</th></tr></thead><tbody><tr><td>1</td><td>guest 1289 <span class="tag">guest</span></td><td>1</td><td>9</td><td>2.25</td><td>33%</td><td>0</td></tr></tbody></table> | |
| 4 | <h3>Recent matches</h3> | |
| 5 | <ul class="recent"><li><b>guest 1289 <span class="tag">guest</span></b> 3–2 <span>9k 4d · 1200 damage · abandoned</span> <time>2026-09-18 05:27:17</time></li></ul>↵ |
.scout-tmp/out4.html added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | <h2>Leaderboard</h2> | |
| 2 | <p class="you">You are playing as <b>guest 1289</b>. <a href="/__forge/signin?to=%2F" rel="noreferrer">Sign in on the forge</a> to put your matches on the board under your name.</p> | |
| 3 | <table><thead><tr><th>#</th><th>player</th><th>matches</th><th>kills</th><th>K/D</th><th>HS</th><th>wins</th></tr></thead><tbody><tr><td>1</td><td>guest 1289 <span class="tag">guest</span></td><td>1</td><td>9</td><td>2.25</td><td>33%</td><td>0</td></tr></tbody></table> | |
| 4 | <h3>Recent matches</h3> | |
| 5 | <ul class="recent"><li><b>guest 1289 <span class="tag">guest</span></b> 3–2 <span>9k 4d · 1200 damage · abandoned</span> <time>2026-09-18 05:27:17</time></li></ul>↵ |
README.md +10 −1
| @@ -87,7 +87,16 @@ wants one, but the running server never reads it. | ||
| 87 | 87 | `FORGE_APP_DATA`, also served as plain JSON from `GET /api/leaderboard` for |
| 88 | 88 | the terminal or any other client — with a `guest` flag per row instead of the |
| 89 | 89 | identity it comes from, because a guest's identity is the session cookie that |
| 90 | owns their matches and that does not travel. | |
| 90 | owns their matches and that does not travel. A row is **live**, **finished** or | |
| 91 | **abandoned** — one word, computed once in `src/db.ts` and read by the page, | |
| 92 | `GET /api/leaderboard` and the terminal alike, never worked out twice. Live is | |
| 93 | a match still posting; finished is one that played to a result; abandoned is | |
| 94 | everything else — the pause overlay's *Leave match* button says so on the way | |
| 95 | out, and a row nothing has updated in ten minutes reads the same way on its | |
| 96 | own, so a crashed browser or a closed tab cannot sit on the board forever. An | |
| 97 | abandoned match still counts as a match, with its kills and its damage, the | |
| 98 | same as any other — it just never counts as a win, which already required | |
| 99 | `finished`. | |
| 91 | 100 | - **Generated, not licensed.** The map (`de_forge`), the textures, the player and |
| 92 | 101 | weapon models and every sound are produced in code at start-up. There are no |
| 93 | 102 | asset files and nothing from the real game is used — the numbers follow CS's |
src/client/main.ts +6 −1
| @@ -275,7 +275,7 @@ function boot(): void { | ||
| 275 | 275 | // --- results, sent as the match goes rather than only at the end. |
| 276 | 276 | // No name goes up: the server credits the result to whoever the forge says |
| 277 | 277 | // posted it, and the name in the menu is only what the match calls you. |
| 278 | async function postResult(final: boolean): Promise<void> { | |
| 278 | async function postResult(final: boolean, left = false): Promise<void> { | |
| 279 | 279 | if (world === null) return; |
| 280 | 280 | const me = world.me; |
| 281 | 281 | const body = { |
| @@ -288,6 +288,7 @@ function boot(): void { | ||
| 288 | 288 | headshots: me.headshots, |
| 289 | 289 | damage: Math.round(me.damage), |
| 290 | 290 | finished: final, |
| 291 | left, | |
| 291 | 292 | seconds: Math.round(world.time), |
| 292 | 293 | }; |
| 293 | 294 | try { |
| @@ -353,6 +354,10 @@ function boot(): void { | ||
| 353 | 354 | lock(); |
| 354 | 355 | }); |
| 355 | 356 | const toMenu = (): void => { |
| 357 | // A match still going when the menu is reached — left from the pause | |
| 358 | // overlay, not from a summary already posted as finished — gets the one | |
| 359 | // post that says so, sent before the world it reads is gone. | |
| 360 | if (world !== null && world.match.phase !== "match_over") void postResult(false, true); | |
| 356 | 361 | running = false; |
| 357 | 362 | world = null; |
| 358 | 363 | show(pause, false); |
src/db.test.ts +58 −1
| @@ -16,7 +16,7 @@ const dir = (): string => mkdtempSync(join(tmpdir(), "db-")); | ||
| 16 | 16 | const result = (over: Partial<Result> = {}): Result => ({ |
| 17 | 17 | matchId: crypto.randomUUID(), |
| 18 | 18 | rounds: 5, scoreMine: 3, scoreTheirs: 2, |
| 19 | kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true, | |
| 19 | kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true, left: false, | |
| 20 | 20 | ...over, |
| 21 | 21 | }); |
| 22 | 22 | |
| @@ -76,3 +76,60 @@ test("rows written before identity existed become the guests they always were", | ||
| 76 | 76 | expect(saveMatch(db, writer("guest:deadbeefdeadbeefdeadbeefdeadbeef", "guest dead"), result({ matchId: "m1", kills: 10 }))).toBe(true); |
| 77 | 77 | expect(saveMatch(db, writer("user:u_9", "thief"), result({ matchId: "m1", kills: 20 }))).toBe(false); |
| 78 | 78 | }); |
| 79 | ||
| 80 | test("a row written under the old column name `left` keeps reading as abandoned", () => { | |
| 81 | const where = dir(); | |
| 82 | const old = new Database(join(where, "app.db")); | |
| 83 | old.exec(`CREATE TABLE match ( | |
| 84 | id TEXT PRIMARY KEY, identity TEXT NOT NULL DEFAULT '', session TEXT NOT NULL, name TEXT NOT NULL, | |
| 85 | kills INTEGER NOT NULL, deaths INTEGER NOT NULL, headshots INTEGER NOT NULL, | |
| 86 | damage INTEGER NOT NULL, rounds INTEGER NOT NULL, score_mine INTEGER NOT NULL, | |
| 87 | score_theirs INTEGER NOT NULL, finished INTEGER NOT NULL, left INTEGER NOT NULL DEFAULT 0, | |
| 88 | seconds INTEGER NOT NULL, | |
| 89 | created_at TEXT NOT NULL DEFAULT (datetime('now')), | |
| 90 | updated_at TEXT NOT NULL DEFAULT (datetime('now')))`); | |
| 91 | old.query( | |
| 92 | `INSERT INTO match (id, identity, session, name, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, left, seconds) | |
| 93 | VALUES ('m1', 'user:u_1', 'deadbeefdeadbeefdeadbeefdeadbeef', 'Vesper', 9, 4, 3, 1200, 5, 3, 2, 0, 1, 420)`, | |
| 94 | ).run(); | |
| 95 | old.close(); | |
| 96 | ||
| 97 | const db = openDatabase(where); | |
| 98 | expect(recentMatches(db).find((m) => m.id === "m1")?.state).toBe("abandoned"); | |
| 99 | }); | |
| 100 | ||
| 101 | test("a match round-trips its state: finished, left, or still live", () => { | |
| 102 | const db = openDatabase(dir()); | |
| 103 | const finished = result({ finished: true, left: false }); | |
| 104 | expect(saveMatch(db, writer("user:u_1", "ada"), finished)).toBe(true); | |
| 105 | expect(recentMatches(db).find((m) => m.id === finished.matchId)?.state).toBe("finished"); | |
| 106 | ||
| 107 | const left = result({ finished: false, left: true }); | |
| 108 | expect(saveMatch(db, writer("user:u_1", "ada"), left)).toBe(true); | |
| 109 | expect(recentMatches(db).find((m) => m.id === left.matchId)?.state).toBe("abandoned"); | |
| 110 | ||
| 111 | const live = result({ finished: false, left: false }); | |
| 112 | expect(saveMatch(db, writer("user:u_1", "ada"), live)).toBe(true); | |
| 113 | expect(recentMatches(db).find((m) => m.id === live.matchId)?.state).toBe("live"); | |
| 114 | }); | |
| 115 | ||
| 116 | test("a late periodic post cannot undo a leave that already landed", () => { | |
| 117 | const db = openDatabase(dir()); | |
| 118 | const w = writer("user:u_1", "ada"); | |
| 119 | const match = result({ finished: false, left: true }); | |
| 120 | expect(saveMatch(db, w, match)).toBe(true); | |
| 121 | expect(recentMatches(db).find((m) => m.id === match.matchId)?.state).toBe("abandoned"); | |
| 122 | // A periodic post already in flight before the leave, arriving after it — | |
| 123 | // same match, same round count, but reporting left: false because that was | |
| 124 | // still true when it was sent. | |
| 125 | expect(saveMatch(db, w, { ...match, left: false })).toBe(true); | |
| 126 | expect(recentMatches(db).find((m) => m.id === match.matchId)?.state).toBe("abandoned"); | |
| 127 | }); | |
| 128 | ||
| 129 | test("a row nobody ever closed reads as abandoned once it goes stale", () => { | |
| 130 | const db = openDatabase(dir()); | |
| 131 | const stale = result({ finished: false, left: false }); | |
| 132 | expect(saveMatch(db, writer("user:u_1", "ada"), stale)).toBe(true); | |
| 133 | db.query("UPDATE match SET updated_at = datetime('now', '-20 minutes') WHERE id = ?1").run(stale.matchId); | |
| 134 | expect(recentMatches(db).find((m) => m.id === stale.matchId)?.state).toBe("abandoned"); | |
| 135 | }); |
src/db.ts +45 −6
| @@ -17,6 +17,8 @@ import type { Result } from "./result.ts"; | ||
| 17 | 17 | /** Who wrote a row: the credited player, plus the session that sent it. */ |
| 18 | 18 | export type Writer = Player & { session: string }; |
| 19 | 19 | |
| 20 | export type MatchState = "finished" | "abandoned" | "live"; | |
| 21 | ||
| 20 | 22 | export type MatchRow = { |
| 21 | 23 | id: string; |
| 22 | 24 | identity: string; |
| @@ -30,8 +32,16 @@ export type MatchRow = { | ||
| 30 | 32 | score_mine: number; |
| 31 | 33 | score_theirs: number; |
| 32 | 34 | finished: number; |
| 35 | walked_out: number; | |
| 33 | 36 | seconds: number; |
| 34 | 37 | updated_at: string; |
| 38 | /** | |
| 39 | * The one place the three renderers (the page, /api/leaderboard, the | |
| 40 | * terminal) get the word for a row: finished, walked out of from the pause | |
| 41 | * menu, or — nobody ever closed it — not updated in a while, which is what | |
| 42 | * keeps a crashed browser or a closed tab from sitting on the board forever. | |
| 43 | */ | |
| 44 | state: MatchState; | |
| 35 | 45 | }; |
| 36 | 46 | |
| 37 | 47 | export type Standing = { |
| @@ -47,6 +57,21 @@ export type Standing = { | ||
| 47 | 57 | rounds: number; |
| 48 | 58 | }; |
| 49 | 59 | |
| 60 | // No post has come in for this long: whatever was playing it is gone — a | |
| 61 | // crashed browser, a closed tab, a dead connection — and the row should stop | |
| 62 | // reading as live. A round can run to MAX_SECONDS_PER_ROUND between posts, so | |
| 63 | // this sits well above that with room for a slow network on top. | |
| 64 | const ABANDON_AFTER_SECONDS = 600; | |
| 65 | ||
| 66 | // The word for a row, computed once in SQL so the page, the JSON endpoint and | |
| 67 | // the terminal read the same thing rather than each guessing it themselves. | |
| 68 | const STATE_SQL = `CASE | |
| 69 | WHEN finished = 1 THEN 'finished' | |
Approved. I read every changed line at b31093e, ran the branch's suite in a scratch checkout (340 pass, 0 fail — my clone is untouched), and exercised What I checked, and what holds:
Two things I'd keep an eye on rather than block for:
Small, on the direction, and it fixes exactly what people see first. |
||
| 70 | WHEN walked_out = 1 THEN 'abandoned' | |
| 71 | WHEN (julianday('now') - julianday(updated_at)) * 86400.0 > ${ABANDON_AFTER_SECONDS} THEN 'abandoned' | |
| 72 | ELSE 'live' | |
| 73 | END AS state`; | |
| 74 | ||
| 50 | 75 | export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Database { |
| 51 | 76 | mkdirSync(dir, { recursive: true }); |
| 52 | 77 | const db = new Database(join(dir, "app.db")); |
| @@ -76,6 +101,15 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data | ||
| 76 | 101 | db.exec("ALTER TABLE match ADD COLUMN identity TEXT NOT NULL DEFAULT ''"); |
| 77 | 102 | db.exec("UPDATE match SET identity = 'guest:' || session, name = 'guest ' || substr(session, 1, 4)"); |
| 78 | 103 | } |
| 104 | // A database written before this column existed holds no rows that were | |
| 105 | // ever walked out of on purpose — they read by the age rule instead, same | |
| 106 | // as they always did. Named to dodge `left`, which SQLite reads as a | |
| 107 | // keyword the moment `match` is ever the left side of a JOIN; an earlier | |
| 108 | // preview of this column under that name is carried over rather than lost. | |
| 109 | if (!columns.includes("walked_out")) { | |
| 110 | db.exec("ALTER TABLE match ADD COLUMN walked_out INTEGER NOT NULL DEFAULT 0"); | |
| 111 | if (columns.includes("left")) db.exec("UPDATE match SET walked_out = left"); | |
| 112 | } | |
| 79 | 113 | db.exec("CREATE INDEX IF NOT EXISTS match_by_identity ON match (identity)"); |
| 80 | 114 | return db; |
| 81 | 115 | } |
| @@ -90,17 +124,22 @@ export function saveMatch(db: Database, writer: Writer, result: Result): boolean | ||
| 90 | 124 | const existing = db.query<{ identity: string }, [string]>("SELECT identity FROM match WHERE id = ?1").get(result.matchId); |
| 91 | 125 | if (existing !== null && existing.identity !== writer.identity) return false; |
| 92 | 126 | db.query( |
| 93 | `INSERT INTO match (id, identity, session, name, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, seconds) | |
| 94 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) | |
| 127 | `INSERT INTO match (id, identity, session, name, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, walked_out, seconds) | |
| 128 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) | |
| 95 | 129 | ON CONFLICT(id) DO UPDATE SET |
| 96 | 130 | name = excluded.name, kills = excluded.kills, deaths = excluded.deaths, |
| 97 | 131 | headshots = excluded.headshots, damage = excluded.damage, rounds = excluded.rounds, |
| 98 | 132 | score_mine = excluded.score_mine, score_theirs = excluded.score_theirs, |
| 99 | finished = excluded.finished, seconds = excluded.seconds, | |
| 100 | updated_at = datetime('now')`, | |
| 133 | -- Posts race: a periodic post already in flight when the match ends | |
| 134 | -- or is walked out of can land after the post that says so. Once | |
| 135 | -- either flag is set it stays set, so the late arrival can't put a | |
| 136 | -- finished or abandoned row back to reading as live. | |
| 137 | finished = MAX(finished, excluded.finished), walked_out = MAX(walked_out, excluded.walked_out), | |
| 138 | seconds = excluded.seconds, updated_at = datetime('now')`, | |
| 101 | 139 | ).run( |
| 102 | 140 | result.matchId, writer.identity, writer.session, writer.name, result.kills, result.deaths, result.headshots, |
| 103 | result.damage, result.rounds, result.scoreMine, result.scoreTheirs, result.finished ? 1 : 0, result.seconds, | |
| 141 | result.damage, result.rounds, result.scoreMine, result.scoreTheirs, result.finished ? 1 : 0, | |
| 142 | result.left ? 1 : 0, result.seconds, | |
| 104 | 143 | ); |
| 105 | 144 | return true; |
| 106 | 145 | } |
| @@ -108,7 +147,7 @@ export function saveMatch(db: Database, writer: Writer, result: Result): boolean | ||
| 108 | 147 | export const recentMatches = (db: Database, limit = 10): MatchRow[] => |
| 109 | 148 | db.query<MatchRow, [number]>( |
| 110 | 149 | `SELECT id, identity, session, name, kills, deaths, headshots, damage, rounds, |
| 111 | score_mine, score_theirs, finished, seconds, updated_at | |
| 150 | score_mine, score_theirs, finished, walked_out, seconds, updated_at, ${STATE_SQL} | |
| 112 | 151 | FROM match WHERE rounds > 0 ORDER BY updated_at DESC LIMIT ?1`, |
| 113 | 152 | ).all(limit); |
| 114 | 153 |
src/pages/game.test.ts +32 −2
| @@ -2,9 +2,10 @@ import { expect, test } from "bun:test"; | ||
| 2 | 2 | import { mkdtempSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | import { openDatabase } from "../db.ts"; | |
| 5 | import { openDatabase, saveMatch, standings } from "../db.ts"; | |
| 6 | 6 | import { credit } from "../player.ts"; |
| 7 | import { gamePage } from "./game.ts"; | |
| 7 | import type { Result } from "../result.ts"; | |
| 8 | import { gamePage, statsHtml } from "./game.ts"; | |
| 8 | 9 | |
| 9 | 10 | const db = openDatabase(mkdtempSync(join(tmpdir(), "game-page-"))); |
| 10 | 11 | // The page is always rendered for somebody: here, a guest, the way a visitor |
| @@ -51,6 +52,35 @@ test("a fullscreen control sits in the menu and in the pause overlay", () => { | ||
| 51 | 52 | expect(pause).toContain(`aria-pressed="false"`); |
| 52 | 53 | }); |
| 53 | 54 | |
| 55 | const matchResult = (over: Partial<Result> = {}): Result => ({ | |
| 56 | matchId: crypto.randomUUID(), | |
| 57 | rounds: 5, scoreMine: 5, scoreTheirs: 1, | |
| 58 | kills: 12, deaths: 3, headshots: 5, damage: 1500, seconds: 500, finished: true, left: false, | |
| 59 | ...over, | |
| 60 | }); | |
| 61 | ||
| 62 | test("the leaderboard says abandoned for a left match and in progress for a live one", () => { | |
| 63 | const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-"))); | |
| 64 | const session = "b".repeat(32); | |
| 65 | const writer = { identity: "user:u_1", name: "nova", guest: false, session }; | |
| 66 | saveMatch(stats, writer, matchResult({ finished: false, left: true })); | |
| 67 | saveMatch(stats, writer, matchResult({ finished: false, left: false })); | |
| 68 | const html = statsHtml(stats, player); | |
| 69 | expect(html).toContain("abandoned"); | |
| 70 | expect(html).toContain("in progress"); | |
| 71 | }); | |
| 72 | ||
| 73 | test("an abandoned match still counts as a match and its kills, but never a win", () => { | |
| 74 | const stats = openDatabase(mkdtempSync(join(tmpdir(), "game-page-"))); | |
| 75 | const session = "c".repeat(32); | |
| 76 | const writer = { identity: "user:u_2", name: "juno", guest: false, session }; | |
| 77 | saveMatch(stats, writer, matchResult({ scoreMine: 5, scoreTheirs: 1, finished: false, left: true, kills: 8 })); | |
| 78 | const [row] = standings(stats); | |
| 79 | expect(row?.matches).toBe(1); | |
| 80 | expect(row?.kills).toBe(8); | |
| 81 | expect(row?.wins).toBe(0); | |
| 82 | }); | |
| 83 | ||
| 54 | 84 | test("the pause overlay carries the line a lost view raises", () => { |
| 55 | 85 | const pause = element(gamePage(db, options), "pause"); |
| 56 | 86 | // Hidden until the renderer says the browser took the context away, and |
src/pages/game.ts +5 −3
| @@ -30,10 +30,12 @@ export function statsHtml(db: Database, player: Player): string { | ||
| 30 | 30 | `<td>${ratio(s.kills, s.deaths)}</td><td>${headshotShare(s.headshots, s.kills)}</td><td>${s.wins}</td></tr>`, |
| 31 | 31 | ) |
| 32 | 32 | .join(""); |
| 33 | const status = (m: MatchRow): string => | |
| 34 | m.state === "finished" ? "" : m.state === "abandoned" ? " · abandoned" : " · in progress"; | |
| 33 | 35 | const feed = recent |
| 34 | 36 | .map( |
| 35 | 37 | (m) => `<li><b>${escape(m.name)}${tag(m.identity)}</b> ${m.score_mine}–${m.score_theirs} ` + |
| 36 | `<span>${m.kills}k ${m.deaths}d · ${m.damage} damage${m.finished === 1 ? "" : " · in progress"}</span> ` + | |
| 38 | `<span>${m.kills}k ${m.deaths}d · ${m.damage} damage${status(m)}</span> ` + | |
| 37 | 39 | `<time>${escape(m.updated_at)}</time></li>`, |
| 38 | 40 | ) |
| 39 | 41 | .join(""); |
| @@ -51,7 +53,7 @@ ${table.length === 0 | ||
| 51 | 53 | |
| 52 | 54 | export type LeaderboardJson = { |
| 53 | 55 | standings: (Omit<Standing, "identity"> & { guest: boolean })[]; |
| 54 | recent: (Omit<MatchRow, "session" | "identity"> & { guest: boolean })[]; | |
| 56 | recent: (Omit<MatchRow, "session" | "identity" | "walked_out"> & { guest: boolean })[]; | |
| 55 | 57 | }; |
| 56 | 58 | |
| 57 | 59 | /** |
| @@ -64,7 +66,7 @@ export type LeaderboardJson = { | ||
| 64 | 66 | export function leaderboardJson(db: Database): LeaderboardJson { |
| 65 | 67 | return { |
| 66 | 68 | standings: standings(db, 10).map(({ identity, ...standing }) => ({ ...standing, guest: isGuest(identity) })), |
| 67 | recent: recentMatches(db, 8).map(({ session: _session, identity, ...match }) => ({ | |
| 69 | recent: recentMatches(db, 8).map(({ session: _session, identity, walked_out: _walkedOut, ...match }) => ({ | |
| 68 | 70 | ...match, |
| 69 | 71 | guest: isGuest(identity), |
| 70 | 72 | })), |
src/result.test.ts +16
| @@ -41,3 +41,19 @@ test("nonsense is refused rather than clamped", () => { | ||
| 41 | 41 | expect(parseResult(result({ damage: Number.POSITIVE_INFINITY }))).toBe(null); |
| 42 | 42 | expect(parseResult(undefined)).toBe(null); |
| 43 | 43 | }); |
| 44 | ||
| 45 | test("a match walked out of parses as left, not finished", () => { | |
| 46 | const parsed = parseResult(result({ finished: false, left: true })); | |
| 47 | expect(parsed?.left).toBe(true); | |
| 48 | expect(parsed?.finished).toBe(false); | |
| 49 | expect(won(parsed!)).toBe(false); | |
| 50 | }); | |
| 51 | ||
| 52 | test("a client that says nothing about left gets the old behaviour", () => { | |
| 53 | const parsed = parseResult(result({ finished: false })); | |
| 54 | expect(parsed?.left).toBe(false); | |
| 55 | }); | |
| 56 | ||
| 57 | test("a match cannot be both finished and left", () => { | |
| 58 | expect(parseResult(result({ finished: true, left: true }))).toBe(null); | |
| 59 | }); |
src/result.ts +12 −1
| @@ -16,6 +16,13 @@ export type Result = { | ||
| 16 | 16 | damage: number; |
| 17 | 17 | seconds: number; |
| 18 | 18 | finished: boolean; |
| 19 | /** | |
| 20 | * The match ended because the player walked out of it, not because it | |
| 21 | * played to a result. Set by the one post `toMenu()` sends on the way out | |
| 22 | * — every other post omits it, which is also what an older client sends, | |
| 23 | * so it defaults to false and a match reads as it always has. | |
| 24 | */ | |
| 25 | left: boolean; | |
| 19 | 26 | }; |
| 20 | 27 | |
| 21 | 28 | const MAX_ROUNDS = 30; |
| @@ -62,10 +69,14 @@ export function parseResult(value: unknown): Result | null { | ||
| 62 | 69 | // minute, and one round does not take an hour. |
| 63 | 70 | if (seconds > (rounds + 1) * MAX_SECONDS_PER_ROUND) return null; |
| 64 | 71 | if (seconds < rounds * MIN_SECONDS_PER_ROUND) return null; |
| 72 | const finished = body.finished === true; | |
| 73 | const left = body.left === true; | |
| 74 | // A match cannot both have played to a result and been walked out of. | |
| 75 | if (finished && left) return null; | |
| 65 | 76 | return { |
| 66 | 77 | matchId, |
| 67 | 78 | rounds, scoreMine, scoreTheirs, kills, deaths, headshots, damage, seconds, |
| 68 | finished: body.finished === true, | |
| 79 | finished, left, | |
| 69 | 80 | }; |
| 70 | 81 | } |
| 71 | 82 |
src/tty.test.ts +24 −4
| @@ -200,16 +200,18 @@ const result = (over: Partial<Result> = {}): Result => ({ | ||
| 200 | 200 | ...over, |
| 201 | 201 | }); |
| 202 | 202 | |
| 203 | /** One frame of the terminal, with its escape sequences taken out. */ | |
| 204 | async function draw(dir: string, env: Record<string, string> = {}): Promise<string> { | |
| 203 | /** One frame of the terminal, with its escape sequences taken out. Any `keys` are typed after the first draw, to walk a tab or move the cursor. */ | |
| 204 | async function draw(dir: string, env: Record<string, string> = {}, keys = ""): Promise<string> { | |
| 205 | 205 | const child = Bun.spawn(["bun", "run", join(import.meta.dir, "tty.ts")], { |
| 206 | 206 | env: { ...process.env, FORGE_APP_DATA: dir, ...env }, |
| 207 | stdin: "ignore", | |
| 207 | stdin: "pipe", | |
| 208 | 208 | stdout: "pipe", |
| 209 | 209 | stderr: "pipe", |
| 210 | 210 | }); |
| 211 | 211 | // It draws once at start-up and then waits on keys; one frame is all we need. |
| 212 | await Bun.sleep(700); | |
| 212 | await Bun.sleep(400); | |
| 213 | for (const key of keys) child.stdin.write(key); | |
| 214 | await Bun.sleep(300); | |
| 213 | 215 | child.kill(); |
| 214 | 216 | const text = await new Response(child.stdout).text(); |
| 215 | 217 | // eslint-disable-next-line no-control-regex |
| @@ -235,6 +237,24 @@ test("the terminal says who a row belongs to, in a column a name cannot fake", a | ||
| 235 | 237 | expect(frame).toMatch(/guest aaaa\s+guest/); |
| 236 | 238 | }, 10_000); |
| 237 | 239 | |
| 240 | test("the terminal's match list says finished, abandoned or live, the same word the page uses", async () => { | |
| 241 | const dir = mkdtempSync(join(tmpdir(), "tty-")); | |
| 242 | const db = openDatabase(dir); | |
| 243 | const session = "a".repeat(32); | |
| 244 | saveMatch(db, { identity: "user:u_1", name: "finn", guest: false, session }, result({ finished: true })); | |
| 245 | saveMatch(db, { identity: "user:u_1", name: "ana", guest: false, session }, result({ finished: false, left: true })); | |
| 246 | saveMatch(db, { identity: "user:u_1", name: "lee", guest: false, session }, result({ finished: false, left: false })); | |
| 247 | db.close(); | |
| 248 | ||
| 249 | // Tab once, from standings to matches. | |
| 250 | const frame = await draw(dir, { FORGE_APP_IDENTITY_KEY: "a-key" }, "\t"); | |
| 251 | const line = (name: string): string => frame.split(/\r?\n/).find((l) => l.includes(name)) ?? ""; | |
| 252 | expect(line("finn")).not.toContain("(live)"); | |
| 253 | expect(line("finn")).not.toContain("(abandoned)"); | |
| 254 | expect(line("ana")).toContain("(abandoned)"); | |
| 255 | expect(line("lee")).toContain("(live)"); | |
| 256 | }, 10_000); | |
| 257 | ||
| 238 | 258 | test("the terminal says when identity is off and everybody is a guest", async () => { |
| 239 | 259 | const dir = mkdtempSync(join(tmpdir(), "tty-")); |
| 240 | 260 | openDatabase(dir).close(); |
src/tty.ts +2 −2
| @@ -113,9 +113,9 @@ export function render(state: State, data: Data): string[] { | ||
| 113 | 113 | lines.push(`${esc}2m ${pad("player", 22)}${pad("who", 8)}${pad("score", 8)}${pad("k/d", 8)}${pad("damage", 8)}when${esc}0m`); |
| 114 | 114 | if (data.matches.length === 0) lines.push(`${esc}2m no matches yet${esc}0m`); |
| 115 | 115 | data.matches.forEach((m, i) => { |
| 116 | const status = m.state === "finished" ? "" : m.state === "abandoned" ? " (abandoned)" : " (live)"; | |
| 116 | 117 | const line = `${pad(safe(m.name), 22)}${pad(who(m.identity), 8)}${pad(`${m.score_mine}-${m.score_theirs}`, 8)}` + |
| 117 | `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${safe(m.updated_at)}` + | |
| 118 | `${m.finished === 1 ? "" : " (live)"}`; | |
| 118 | `${pad(`${m.kills}/${m.deaths}`, 8)}${pad(String(m.damage), 8)}${safe(m.updated_at)}${status}`; | |
| 119 | 119 | lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`); |
| 120 | 120 | }); |
| 121 | 121 | } |
Changes requested for one reason, and it is not the code: the head commit (
fa239d0, "Addressed both non-blocking notes") contains nothing but five scratch files under.scout-tmp/—out1..4.html, the scraped leaderboard fragments, andcookies2.txt, a curl cookie jar holding a livecs_session=12894e80…token. A session cookie is a credential; it belongs nowhere in the tree, and neither do four HTML dumps of a local run. The repository has no secrets and no dead files today, and this would be the first of both.Fix this first:
git rm -r --cached .scout-tmp && rm -rf .scout-tmp, add.scout-tmp/to.gitignorebeside.checkdata/, and push. Nothing undersrc/needs to change. While you are in the description: it still says the new column isleft, butdfa9cbcrenamed it towalked_out— worth correcting, since that text is what lands in the log.The rest I read at
fa239d0and it is good, including both notes from my last pass, which are genuinely addressed:leftbecamewalked_out, and the migration carries a database that already got the preview column across (UPDATE match SET walked_out = left, guarded onPRAGMA table_info) instead of dropping those rows. There is a test that builds the old schema by hand and asserts the row still readsabandoned— that is the right way to prove a migration.finished = MAX(finished, excluded.finished), walked_out = MAX(walked_out, excluded.walked_out)makes both flags monotonic, so a periodic post already in flight cannot put an abandoned row back tolive. I drove exactly that sequence against a local copy of the branch over HTTP — leave post 200, lateleft:falsepost 200, row still"state":"abandoned"in/api/leaderboard— and there is a test for it.leftis one client boolean validated inparseResultlike every other field, and the impossible pair is refused:{finished:true,left:true}came back 422 from the running app. Crediting still comes fromwhoIs()on the forge's headers, another identity posting a known match id still gets 409 (verified),readBodystill counts the bytes it read, the limiter still keys on identity/session/socket address, same-origin still gates the POST, and the floor headers are all on the response.STATE_SQLinterpolates one module constant, never user text, andstateis a closed enum./api/leaderboardandsrc/tty.tsall readm.state; the public JSON drops the raw flag as redundant. Standings are untouched, so an abandoned match keeps its kills and its count and earns no win — the live JSON showswins: 0againstkills: 9.· abandonedunder Recent matches, which is exactly what the issue asked for.Delete the five files and this is an approve.