Public: anyone can read this; the crew can change it.
forked from trevato/cs
Take updates from trevato/cs #367
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/362-take-updates-from-trevato-cs → main 2 commits fork
3 files
changed +164
−17
README.md +5 −1
| @@ -296,7 +296,11 @@ whatever the name field said, and a result posted without one is a **guest**. | ||
| 296 | 296 | - A guest picks their own name and nobody checked it, so it is shown as |
| 297 | 297 | `name (guest)` everywhere it appears — the leaderboard, the match feed and |
| 298 | 298 | the terminal — and the standings group by identity and name together. A guest |
| 299 | typing someone else's name gets their own row, never theirs. | |
| 299 | typing someone else's name gets their own row, never theirs. Those rows are | |
| 300 | kept in their own `standing` table, recomputed for the one player a save or | |
| 301 | a delete touched, so drawing the leaderboard on `/` reads ten rows rather | |
| 302 | than grouping every match ever played (13.9 ms at 20,000 matches before, | |
| 303 | 0.2 ms after); an older database builds the table once when it opens. | |
| 300 | 304 | - A match belongs to whoever started it: a signed-in player's from any browser |
| 301 | 305 | they are on, a guest's from the browser session that began it. Anyone else |
| 302 | 306 | posting that match id is refused with a 409. |
src/db.test.ts +78 −1
| @@ -10,7 +10,7 @@ import { mkdtempSync } from "node:fs"; | ||
| 10 | 10 | import { tmpdir } from "node:os"; |
| 11 | 11 | import { join } from "node:path"; |
| 12 | 12 | import { |
| 13 | dayBoard, displayName, IDLE_SECONDS, liveness, openDatabase, type Player, recentMatches, recentRuns, | |
| 13 | dayBoard, deleteMatch, displayName, IDLE_SECONDS, liveness, openDatabase, type Player, recentMatches, recentRuns, | |
| 14 | 14 | runBoard, saveMatch, saveRun, standings, |
| 15 | 15 | } from "./db.ts"; |
| 16 | 16 | import { playKey } from "./matchid.ts"; |
| @@ -128,6 +128,83 @@ test("a database written before the map column keeps its rows and calls them de_ | ||
| 128 | 128 | expect(recentMatches(db).find((m) => m.id === fresh)?.map).toBe("gen_ir1"); |
| 129 | 129 | }); |
| 130 | 130 | |
| 131 | test("a database written before the standing table existed builds it once, from what match already holds", () => { | |
| 132 | const home = dir(); | |
| 133 | const old = new Database(join(home, "app.db")); | |
| 134 | old.exec(`CREATE TABLE match ( | |
| 135 | id TEXT PRIMARY KEY, session TEXT NOT NULL, user_id TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, | |
| 136 | map TEXT NOT NULL DEFAULT 'de_forge', day TEXT NOT NULL DEFAULT '', | |
| 137 | kills INTEGER NOT NULL, deaths INTEGER NOT NULL, headshots INTEGER NOT NULL, | |
| 138 | damage INTEGER NOT NULL, rounds INTEGER NOT NULL, score_mine INTEGER NOT NULL, | |
| 139 | score_theirs INTEGER NOT NULL, finished INTEGER NOT NULL, seconds INTEGER NOT NULL, | |
| 140 | created_at TEXT NOT NULL DEFAULT (datetime('now')), | |
| 141 | updated_at TEXT NOT NULL DEFAULT (datetime('now')))`); | |
| 142 | old.exec(`INSERT INTO match (id, session, user_id, name, kills, deaths, headshots, damage, rounds, | |
| 143 | score_mine, score_theirs, finished, seconds) VALUES | |
| 144 | ('m1', 's1', 'u-1', 'veteran', 9, 4, 3, 1200, 5, 3, 2, 1, 420), | |
| 145 | ('m2', 's1', 'u-1', 'veteran', 6, 5, 1, 800, 5, 1, 4, 1, 400), | |
| 146 | ('m3', 's2', '', 'veteran', 20, 1, 9, 3000, 0, 0, 0, 0, 10)`); | |
| 147 | old.close(); | |
| 148 | ||
| 149 | const table = standings(openDatabase(home)); | |
| 150 | // m3 never finished a round, so it is on no board — as it never was. | |
| 151 | expect(table).toEqual([ | |
| 152 | { name: "veteran", user_id: "u-1", matches: 2, kills: 15, deaths: 9, headshots: 4, damage: 2000, wins: 1, rounds: 10 }, | |
| 153 | ]); | |
| 154 | // A second open does not build it again on top of itself. | |
| 155 | expect(standings(openDatabase(home))[0]?.matches).toBe(2); | |
| 156 | }); | |
| 157 | ||
| 158 | test("the board follows every write: a later post, a rename and a delete each move one row", () => { | |
| 159 | const db = openDatabase(dir()); | |
| 160 | const id = crypto.randomUUID(); | |
| 161 | saveMatch(db, player("u-1", "kestrel"), result({ matchId: id, kills: 3, rounds: 2 })); | |
| 162 | saveMatch(db, player("u-1", "kestrel"), result({ kills: 5, damage: 700 })); | |
| 163 | expect(standings(db)[0]).toMatchObject({ name: "kestrel", matches: 2, kills: 8 }); | |
| 164 | ||
| 165 | // A later post of the same match raises it in place, not as a third match. | |
| 166 | saveMatch(db, player("u-1", "kestrel"), result({ matchId: id, kills: 7, rounds: 4 })); | |
| 167 | expect(standings(db)[0]).toMatchObject({ matches: 2, kills: 12 }); | |
| 168 | ||
| 169 | // A post under a new name moves that match to the new name's row and leaves | |
| 170 | // the old row with only what is still under it. | |
| 171 | saveMatch(db, player("u-1", "falcon"), result({ matchId: id, kills: 7, rounds: 4 })); | |
| 172 | const rows = standings(db); | |
| 173 | expect(rows.find((r) => r.name === "falcon")).toMatchObject({ matches: 1, kills: 7 }); | |
| 174 | expect(rows.find((r) => r.name === "kestrel")).toMatchObject({ matches: 1, kills: 5 }); | |
| 175 | ||
| 176 | // Deleting the last match under a name takes the row off the board. | |
| 177 | expect(deleteMatch(db, id)).toBe(true); | |
| 178 | expect(standings(db).map((r) => r.name)).toEqual(["kestrel"]); | |
| 179 | expect(deleteMatch(db, id)).toBe(false); | |
| 180 | }); | |
| 181 | ||
| 182 | test("a refused post leaves the board as it was", () => { | |
| 183 | const db = openDatabase(dir()); | |
| 184 | const id = crypto.randomUUID(); | |
| 185 | saveMatch(db, player("u-1", "kestrel"), result({ matchId: id })); | |
| 186 | expect(saveMatch(db, player("u-2", "thief"), result({ matchId: id, kills: 99 }))).toBe(false); | |
| 187 | expect(standings(db).map((r) => r.name)).toEqual(["kestrel"]); | |
| 188 | }); | |
| 189 | ||
| 190 | test("standings reads the board, not the match table: 20,000 matches answer in under 10 ms", () => { | |
| 191 | const db = openDatabase(dir()); | |
| 192 | db.transaction(() => { | |
| 193 | for (let i = 0; i < 20_000; i++) saveMatch(db, player(`u-${i}`, `player ${i}`), result({ kills: i % 40 })); | |
| 194 | })(); | |
| 195 | // The median of seven reads after one to compile, the way `src/budget.test.ts` | |
| 196 | // measures: one GC pause in a loaded suite is one of seven, not the answer. | |
| 197 | // The GROUP BY this replaced took 13.9 ms here; the board takes 0.2 ms. | |
| 198 | const top = standings(db, 10); | |
| 199 | const times = Array.from({ length: 7 }, () => { | |
| 200 | const start = performance.now(); | |
| 201 | standings(db, 10); | |
| 202 | return performance.now() - start; | |
| 203 | }).sort((a, b) => a - b); | |
| 204 | expect(times[3]).toBeLessThan(10); | |
| 205 | expect(top[0]?.kills).toBe(39); | |
| 206 | }); | |
| 207 | ||
| 131 | 208 | // The stamp SQLite writes, for a moment `n` seconds before `NOW`: UTC, to the |
| 132 | 209 | // second, in the format `datetime('now')` hands back. |
| 133 | 210 | const NOW = Date.parse("2026-09-21T12:00:00Z"); |
src/db.ts +81 −15
| @@ -129,9 +129,57 @@ export function openDatabase(dir = process.env.FORGE_APP_DATA ?? "./data"): Data | ||
| 129 | 129 | // page draws one table per drill on every paint of `/range`. |
| 130 | 130 | db.exec("CREATE INDEX IF NOT EXISTS run_by_drill ON run (drill, score DESC)"); |
| 131 | 131 | db.exec("CREATE INDEX IF NOT EXISTS run_by_time ON run (updated_at DESC)"); |
| 132 | // The board, kept one row a player instead of grouped from every match ever | |
| 133 | // played on every paint of `/`: `saveMatch` and `deleteMatch` recompute the | |
| 134 | // one player they touched (`restand` below), bounded by that player's own | |
| 135 | // matches, so `standings` is a sorted read of a table the size of the player | |
| 136 | // count. A database written before this table existed builds it once, with | |
| 137 | // the GROUP BY `standings` used to run on every read, and never pays it again. | |
| 138 | const hasStanding = db.query<{ name: string }, []>( | |
| 139 | "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'standing'", | |
| 140 | ).get() !== null; | |
| 141 | if (!hasStanding) { | |
| 142 | db.exec(`CREATE TABLE standing ( | |
| 143 | user_id TEXT NOT NULL, | |
| 144 | name TEXT NOT NULL, | |
| 145 | matches INTEGER NOT NULL, | |
| 146 | kills INTEGER NOT NULL, | |
| 147 | deaths INTEGER NOT NULL, | |
| 148 | headshots INTEGER NOT NULL, | |
| 149 | damage INTEGER NOT NULL, | |
| 150 | wins INTEGER NOT NULL, | |
| 151 | rounds INTEGER NOT NULL, | |
| 152 | PRIMARY KEY (user_id, name) | |
| 153 | )`); | |
| 154 | db.exec(`INSERT INTO standing (user_id, name, matches, kills, deaths, headshots, damage, wins, rounds) | |
| 155 | SELECT ${STANDING_COLUMNS} FROM match WHERE rounds > 0 GROUP BY user_id, name`); | |
| 156 | } | |
| 157 | // `restand` reads one player's matches on every write; without this it reads | |
| 158 | // everyone's, and the write grows with the table the board was built to spare. | |
| 159 | db.exec("CREATE INDEX IF NOT EXISTS match_by_player ON match (user_id, name)"); | |
| 160 | db.exec("CREATE INDEX IF NOT EXISTS standing_by_rank ON standing (kills DESC, damage DESC, user_id, name)"); | |
| 132 | 161 | return db; |
| 133 | 162 | } |
| 134 | 163 | |
| 164 | // One player's row of the board, summed from their matches: the same columns | |
| 165 | // whether it is built for the whole table once or for one player on a write. | |
| 166 | const STANDING_COLUMNS = `user_id, name, COUNT(*), SUM(kills), SUM(deaths), SUM(headshots), SUM(damage), | |
| 167 | SUM(CASE WHEN finished = 1 AND score_mine > score_theirs THEN 1 ELSE 0 END), SUM(rounds)`; | |
| 168 | ||
| 169 | /** | |
| 170 | * Bring one player's row of `standing` back in line with their matches: the | |
| 171 | * old GROUP BY narrowed to `WHERE user_id = ?1 AND name = ?2`, so a write costs | |
| 172 | * that player's history and not everyone's. A player with no counted match left | |
| 173 | * (a delete, or only rounds-0 rows) has no row. | |
| 174 | */ | |
| 175 | function restand(db: Database, userId: string, name: string): void { | |
| 176 | db.query("DELETE FROM standing WHERE user_id = ?1 AND name = ?2").run(userId, name); | |
| 177 | db.query( | |
| 178 | `INSERT INTO standing (user_id, name, matches, kills, deaths, headshots, damage, wins, rounds) | |
| 179 | SELECT ${STANDING_COLUMNS} FROM match WHERE user_id = ?1 AND name = ?2 AND rounds > 0 GROUP BY user_id, name`, | |
| 180 | ).run(userId, name); | |
| 181 | } | |
| 182 | ||
| 135 | 183 | /** Who a match is written under: a verified forge identity, or a browser. */ |
| 136 | 184 | export type Player = { |
| 137 | 185 | /** The forge identity's id, or "" for a guest. */ |
| @@ -211,12 +259,25 @@ export function liveness(row: { finished: number; updated_at: string }, now = Da | ||
| 211 | 259 | */ |
| 212 | 260 | export function saveMatch(db: Database, player: Player, result: Result, now = Date.now()): boolean { |
| 213 | 261 | const existing = db |
| 214 | .query<{ session: string; user_id: string }, [string]>("SELECT session, user_id FROM match WHERE id = ?1") | |
| 262 | .query<{ session: string; user_id: string; name: string }, [string]>( | |
| 263 | "SELECT session, user_id, name FROM match WHERE id = ?1", | |
| 264 | ) | |
| 215 | 265 | .get(result.matchId); |
| 216 | 266 | if (existing !== null) { |
| 217 | 267 | if (existing.user_id !== player.userId) return false; |
| 218 | 268 | if (player.userId === "" && existing.session !== player.session) return false; |
| 219 | 269 | } |
| 270 | db.transaction(() => { | |
| 271 | write(db, player, result, now); | |
| 272 | // A post may rename the row (a forge rename, a guest who retyped), which | |
| 273 | // moves it from one board row to another: both are recomputed. | |
| 274 | if (existing !== null && existing.name !== player.name) restand(db, existing.user_id, existing.name); | |
| 275 | restand(db, player.userId, player.name); | |
| 276 | })(); | |
| 277 | return true; | |
| 278 | } | |
| 279 | ||
| 280 | function write(db: Database, player: Player, result: Result, now: number): void { | |
| 220 | 281 | db.query( |
| 221 | 282 | `INSERT INTO match (id, session, user_id, name, map, day, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, seconds) |
| 222 | 283 | VALUES (?1, ?2, ?13, ?3, ?14, ?15, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) |
| @@ -237,7 +298,6 @@ export function saveMatch(db: Database, player: Player, result: Result, now = Da | ||
| 237 | 298 | result.damage, result.rounds, result.scoreMine, result.scoreTheirs, result.finished ? 1 : 0, result.seconds, |
| 238 | 299 | player.userId, result.map, dayFiled(result.matchId, now), |
| 239 | 300 | ); |
| 240 | return true; | |
| 241 | 301 | } |
| 242 | 302 | |
| 243 | 303 | export const recentMatches = (db: Database, limit = 10): MatchRow[] => |
| @@ -252,20 +312,17 @@ export const recentMatches = (db: Database, limit = 10): MatchRow[] => | ||
| 252 | 312 | * a forge identity, or — for the guests, who are nobody in particular — the |
| 253 | 313 | * name they typed. Grouping by the name alone is how a guest calling |
| 254 | 314 | * themselves someone else lands in that someone's row. |
| 315 | * | |
| 316 | * The rows are kept in `standing` by every write (`restand`), so this read | |
| 317 | * costs the size of the board, not the size of the match table behind it. | |
| 255 | 318 | */ |
| 256 | 319 | export const standings = (db: Database, limit = 10): Standing[] => |
| 257 | 320 | db.query<Standing, [number]>( |
| 258 | `SELECT name, user_id, | |
| 259 | COUNT(*) AS matches, | |
| 260 | SUM(kills) AS kills, | |
| 261 | SUM(deaths) AS deaths, | |
| 262 | SUM(headshots) AS headshots, | |
| 263 | SUM(damage) AS damage, | |
| 264 | SUM(CASE WHEN finished = 1 AND score_mine > score_theirs THEN 1 ELSE 0 END) AS wins, | |
| 265 | SUM(rounds) AS rounds | |
| 266 | FROM match WHERE rounds > 0 | |
| 267 | GROUP BY user_id, name | |
| 268 | ORDER BY kills DESC, damage DESC | |
| 321 | `SELECT name, user_id, matches, kills, deaths, headshots, damage, wins, rounds | |
| 322 | FROM standing | |
| 323 | -- A tie is broken the way the old GROUP BY broke it, by player, said out | |
| 324 | -- loud here so the order of equal rows is the rule and not the insert order. | |
| 325 | ORDER BY kills DESC, damage DESC, user_id, name | |
| 269 | 326 | LIMIT ?1`, |
| 270 | 327 | ).all(limit); |
| 271 | 328 | |
| @@ -430,8 +487,17 @@ export const hitShare = (hits: number, shots: number): string => | ||
| 430 | 487 | export const matchCount = (db: Database): number => |
| 431 | 488 | db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM match").get()?.n ?? 0; |
| 432 | 489 | |
| 433 | export const deleteMatch = (db: Database, id: string): boolean => | |
| 434 | db.query("DELETE FROM match WHERE id = ?1").run(id).changes > 0; | |
| 490 | export function deleteMatch(db: Database, id: string): boolean { | |
| 491 | const row = db.query<{ user_id: string; name: string }, [string]>( | |
| 492 | "SELECT user_id, name FROM match WHERE id = ?1", | |
| 493 | ).get(id); | |
| 494 | if (row === null) return false; | |
| 495 | db.transaction(() => { | |
| 496 | db.query("DELETE FROM match WHERE id = ?1").run(id); | |
| 497 | restand(db, row.user_id, row.name); | |
| 498 | })(); | |
| 499 | return true; | |
| 500 | } | |
| 435 | 501 | |
| 436 | 502 | export const ratio = (kills: number, deaths: number): string => |
| 437 | 503 | (kills / Math.max(1, deaths)).toFixed(2); |
new above
offline