Public: anyone can read this; the crew can change it.
forked from trevato/cs
The standings can be kept per map: `standing` carries the map, and `standings` takes one or none #422
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/414-the-standings-can-be-kept-per-map-standi → main 1 commit fork
3 files
changed +110
−9
src/db.test.ts +98 −4
| @@ -12,7 +12,7 @@ import { join } from "node:path"; | ||
| 12 | 12 | import { |
| 13 | 13 | boardMaps, dayBoard, deleteMatch, displayName, IDLE_SECONDS, keepOf, liveness, matchCount, type MatchRow, openDatabase, type Player, |
| 14 | 14 | playersOn, |
| 15 | recentMatches, recentRuns, recordedPlan, recordPlan, runBoard, saveMatch, saveRun, standings, | |
| 15 | recentMatches, recentRuns, recordedPlan, recordPlan, runBoard, saveMatch, saveRun, type Standing, standings, | |
| 16 | 16 | } from "./db.ts"; |
| 17 | 17 | import { KEEP } from "./keep.ts"; |
| 18 | 18 | import type { PlayedMap } from "./mapname.ts"; |
| @@ -303,6 +303,97 @@ test("a standing table from before the map was a column is built again, per map, | ||
| 303 | 303 | expect(standings(openDatabase(home), 10, "de_forge")[0]?.matches).toBe(1); |
| 304 | 304 | }); |
| 305 | 305 | |
| 306 | /** | |
| 307 | * The board as it was computed before any of it was kept (#45): every counted | |
| 308 | * match grouped by player, in the order `standings` still promises. The per-map | |
| 309 | * rows must leave the whole board exactly this, row for row, whatever was written. | |
| 310 | */ | |
| 311 | const oldBoard = (db: Database, limit = 100): Standing[] => | |
| 312 | db.query<Standing, [number]>( | |
| 313 | `SELECT name, user_id, COUNT(*) AS matches, SUM(kills) AS kills, SUM(deaths) AS deaths, | |
| 314 | SUM(headshots) AS headshots, SUM(damage) AS damage, | |
| 315 | SUM(CASE WHEN finished = 1 AND score_mine > score_theirs THEN 1 ELSE 0 END) AS wins, | |
| 316 | SUM(rounds) AS rounds | |
| 317 | FROM match WHERE rounds > 0 GROUP BY user_id, name | |
| 318 | ORDER BY kills DESC, damage DESC, user_id, name LIMIT ?1`, | |
| 319 | ).all(limit); | |
| 320 | ||
| 321 | test("the board of every map is the old GROUP BY, row for row, on the same inserts", () => { | |
| 322 | const db = openDatabase(dir()); | |
| 323 | const maps: PlayedMap[] = ["de_forge", "gen_ir1", "gen_zz", "gen_1z141z3"]; | |
| 324 | const ids: string[] = []; | |
| 325 | for (let i = 0; i < 120; i++) { | |
| 326 | const id = crypto.randomUUID(); | |
| 327 | ids.push(id); | |
| 328 | const who = i % 3 === 0 ? guest(`g${i % 7}`, `s${i % 7}`) : player(`u-${i % 11}`, `p${i % 11}`); | |
| 329 | saveMatch(db, who, result({ | |
| 330 | matchId: id, map: maps[i % maps.length], kills: (i * 7) % 23, damage: (i * 131) % 2500, | |
| 331 | rounds: i % 13 === 0 ? 0 : 1 + (i % 9), scoreMine: i % 4, scoreTheirs: (i + 1) % 4, finished: i % 5 !== 0, | |
| 332 | })); | |
| 333 | } | |
| 334 | // A later post on another map, a rename and a delete: each moves rows between maps. | |
| 335 | saveMatch(db, player("u-1", "p1"), result({ matchId: ids[1], map: "gen_zz", kills: 40, rounds: 9 })); | |
| 336 | saveMatch(db, player("u-2", "renamed"), result({ matchId: ids[2], map: "gen_ir1", kills: 30, rounds: 9 })); | |
| 337 | deleteMatch(db, ids[4]!); | |
| 338 | ||
| 339 | expect(standings(db, 100)).toEqual(oldBoard(db)); | |
| 340 | expect(standings(db, 5)).toEqual(oldBoard(db, 5)); | |
| 341 | // And every map's board is the same GROUP BY narrowed to that map, so the | |
| 342 | // maps' boards between them hold every counted match exactly once. | |
| 343 | const perMap = maps.flatMap((m) => standings(db, 100, m)); | |
| 344 | expect(perMap.reduce((n, r) => n + r.matches, 0)).toBe(oldBoard(db).reduce((n, r) => n + r.matches, 0)); | |
| 345 | }); | |
| 346 | ||
| 347 | test("a migrated database reads as it did before: yesterday's board, now under de_forge as well", () => { | |
| 348 | const home = dir(); | |
| 349 | // The database as it was two migrations ago: no map on a match, one standing | |
| 350 | // row a player. Its matches were all played on de_forge, the only map there was. | |
| 351 | const old = new Database(join(home, "app.db")); | |
| 352 | old.exec(`CREATE TABLE match ( | |
| 353 | id TEXT PRIMARY KEY, session TEXT NOT NULL, user_id TEXT NOT NULL DEFAULT '', name TEXT NOT NULL, | |
| 354 | kills INTEGER NOT NULL, deaths INTEGER NOT NULL, headshots INTEGER NOT NULL, | |
| 355 | damage INTEGER NOT NULL, rounds INTEGER NOT NULL, score_mine INTEGER NOT NULL, | |
| 356 | score_theirs INTEGER NOT NULL, finished INTEGER NOT NULL, seconds INTEGER NOT NULL, | |
| 357 | created_at TEXT NOT NULL DEFAULT (datetime('now')), | |
| 358 | updated_at TEXT NOT NULL DEFAULT (datetime('now')))`); | |
| 359 | old.exec(`INSERT INTO match (id, session, user_id, name, kills, deaths, headshots, damage, rounds, | |
| 360 | score_mine, score_theirs, finished, seconds) VALUES | |
| 361 | ('m1', 's1', 'u-1', 'veteran', 9, 4, 3, 1200, 5, 3, 2, 1, 420), | |
| 362 | ('m2', 's1', 'u-1', 'veteran', 6, 5, 1, 800, 5, 1, 4, 1, 400), | |
| 363 | ('m3', 's2', '', 'rookie', 12, 7, 2, 1500, 6, 4, 2, 1, 500), | |
| 364 | ('m4', 's3', '', 'idle', 20, 1, 9, 3000, 0, 0, 0, 0, 10)`); | |
| 365 | old.exec(`CREATE TABLE standing (user_id TEXT NOT NULL, name TEXT NOT NULL, matches INTEGER NOT NULL, | |
| 366 | kills INTEGER NOT NULL, deaths INTEGER NOT NULL, headshots INTEGER NOT NULL, damage INTEGER NOT NULL, | |
| 367 | wins INTEGER NOT NULL, rounds INTEGER NOT NULL, PRIMARY KEY (user_id, name))`); | |
| 368 | old.exec(`INSERT INTO standing SELECT user_id, name, COUNT(*), SUM(kills), SUM(deaths), SUM(headshots), SUM(damage), | |
| 369 | SUM(CASE WHEN finished = 1 AND score_mine > score_theirs THEN 1 ELSE 0 END), SUM(rounds) | |
| 370 | FROM match WHERE rounds > 0 GROUP BY user_id, name`); | |
| 371 | // What the board read before, by the query it used then. | |
| 372 | const before = old.query<Standing, []>( | |
| 373 | `SELECT name, user_id, matches, kills, deaths, headshots, damage, wins, rounds FROM standing | |
| 374 | ORDER BY kills DESC, damage DESC, user_id, name`, | |
| 375 | ).all(); | |
| 376 | old.close(); | |
| 377 | ||
| 378 | const db = openDatabase(home); | |
| 379 | expect(before.map((r) => r.name)).toEqual(["veteran", "rookie"]); | |
| 380 | expect(standings(db)).toEqual(before); | |
| 381 | expect(standings(db, 10, "de_forge")).toEqual(before); | |
| 382 | expect(standings(db, 10, "gen_ir1")).toEqual([]); | |
| 383 | }); | |
| 384 | ||
| 385 | test("the de_forge board leaves out a generated map's rows even for the same player", () => { | |
| 386 | const db = openDatabase(dir()); | |
| 387 | saveMatch(db, player("u-1", "kestrel"), result({ map: "de_forge", kills: 3, damage: 300 })); | |
| 388 | saveMatch(db, player("u-1", "kestrel"), result({ map: "gen_ir1", kills: 30, damage: 3000 })); | |
| 389 | saveMatch(db, player("u-2", "falcon"), result({ map: "gen_ir1", kills: 1 })); | |
| 390 | expect(standings(db, 10, "de_forge")).toEqual([ | |
| 391 | { name: "kestrel", user_id: "u-1", matches: 1, kills: 3, deaths: 4, headshots: 3, damage: 300, wins: 1, rounds: 5 }, | |
| 392 | ]); | |
| 393 | expect(standings(db, 10, "gen_ir1").map((r) => [r.name, r.kills])).toEqual([["kestrel", 30], ["falcon", 1]]); | |
| 394 | expect(standings(db).map((r) => [r.name, r.kills])).toEqual([["kestrel", 33], ["falcon", 1]]); | |
| 395 | }); | |
| 396 | ||
| 306 | 397 | test("the maps listed are generated maps with enough players on them, the most played first, and no more than asked", () => { |
| 307 | 398 | const db = openDatabase(dir()); |
| 308 | 399 | // gen_ir1: three players. gen_zz: two. gen_aa: one, which is not a comparison. |
| @@ -327,9 +418,12 @@ test("the board on one map is a range of the index: 20,000 matches on 200 maps a | ||
| 327 | 418 | })(); |
| 328 | 419 | const plan = (sql: string): string => |
| 329 | 420 | db.query<{ detail: string }, []>(`EXPLAIN QUERY PLAN ${sql}`).all().map((r) => r.detail).join(" / "); |
| 330 | // No sort step: the rows come off `standing_by_rank` already in board order. | |
| 331 | expect(plan(`SELECT name, user_id, matches, kills, deaths, headshots, damage, wins, rounds FROM standing WHERE map = 'de_forge' ORDER BY kills DESC, damage DESC, user_id, name LIMIT 10`)) | |
| 332 | .toBe("SEARCH standing USING INDEX standing_by_rank (map=?)"); | |
| 421 | // No sort step: the rows come off `standing_by_rank` already in board order, | |
| 422 | // for one map and for every map (`map = ''`) alike. | |
| 423 | for (const map of ["de_forge", ""]) { | |
| 424 | expect(plan(`SELECT name, user_id, matches, kills, deaths, headshots, damage, wins, rounds FROM standing WHERE map = '${map}' ORDER BY kills DESC, damage DESC, user_id, name LIMIT 10`)) | |
| 425 | .toBe("SEARCH standing USING INDEX standing_by_rank (map=?)"); | |
| 426 | } | |
| 333 | 427 | const median = (read: () => unknown): number => |
| 334 | 428 | Array.from({ length: 7 }, () => { |
| 335 | 429 | const start = performance.now(); |
src/db.ts +7 −3
| @@ -151,9 +151,13 @@ export function openDatabase( | ||
| 151 | 151 | // Each player has one row for every map (`map` = `EVERY_MAP`, "") and one |
| 152 | 152 | // for each map they have a result on (#242), so the board on one map is the |
| 153 | 153 | // same sorted read as the whole one, narrowed by the first column of its |
| 154 | // index. A table built before the map was a column is dropped and built | |
| 155 | // again from `match`, which holds every number it ever had: it is a cache of | |
| 156 | // that table, not a record of its own. | |
| 154 | // index. The whole board is a kept row and not a SUM over a player's map | |
| 155 | // rows because a sum has to group every player's rows and then sort the | |
| 156 | // totals before it can stop at the limit, which is the GROUP BY this table was | |
| 157 | // built to retire; the kept row costs one more INSERT … SELECT on a write, | |
| 158 | // bounded by that one player's matches. A table built before the map was a | |
| 159 | // column is dropped and built again from `match`, which holds every number it | |
| 160 | // ever had: it is a cache of that table, not a record of its own. | |
| 157 | 161 | const standingColumns = db.query<{ name: string }, []>("PRAGMA table_info(standing)").all().map((c) => c.name); |
| 158 | 162 | if (standingColumns.length > 0 && !standingColumns.includes("map")) { |
| 159 | 163 | db.exec("DROP INDEX IF EXISTS standing_by_rank"); |
src/server.test.ts +5 −2
| @@ -1247,7 +1247,10 @@ test("every file in static/ is gzipped once, at start-up, and held beside its by | ||
| 1247 | 1247 | * |
| 1248 | 1248 | * `/` is measured on this file's database, which by now holds every match the |
| 1249 | 1249 | * tests above posted, so it is the page at its heaviest rather than at its |
| 1250 | * emptiest: 8,830 gzipped against 26,188 when this was written. | |
| 1250 | * emptiest: 8,830 gzipped against 26,188 when this was written. The board's | |
| 1251 | * map list and its filter sentence (#419) spent that room: 10,687 gzipped | |
| 1252 | * against 32,464 on 2026-09-29, so the budget is 11,000 — the same margin | |
| 1253 | * again for the next paragraph, and a page that doubles still fails. | |
| 1251 | 1254 | * |
| 1252 | 1255 | * `/engine` is the exception in size and not in kind: it carries the whole |
| 1253 | 1256 | * guide, 78 kB of text, because reading it without cloning is the point of the |
| @@ -1255,7 +1258,7 @@ test("every file in static/ is gzipped once, at start-up, and held beside its by | ||
| 1255 | 1258 | * is that plus room for the guide to grow a section or two. |
| 1256 | 1259 | */ |
| 1257 | 1260 | const BUDGET: Record<string, number> = { |
| 1258 | "/": 10500, "/weapons": 5600, "/range": 7500, "/example": 5500, "/engine": 40000, | |
| 1261 | "/": 11000, "/weapons": 5600, "/range": 7500, "/example": 5500, "/engine": 40000, | |
| 1259 | 1262 | }; |
| 1260 | 1263 | |
| 1261 | 1264 | test("a first visit stays inside its byte budget, compressed", async () => { |