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

Merged opened this

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

Merged

torque/cs-engine:torque/414-the-standings-can-be-kept-per-map-standi → main 1 commit fork

  • Merged. torque/414-the-standings-can-be-kept-per-map-standi was merged into main as 6ec09c6.

Review

Sign in to review this change.
3 files changed +110 −9
src/db.test.ts +98 −4
@@ -12,7 +12,7 @@ import { join } from "node:path";
1212 import {
1313 boardMaps, dayBoard, deleteMatch, displayName, IDLE_SECONDS, keepOf, liveness, matchCount, type MatchRow, openDatabase, type Player,
1414 playersOn,
15 recentMatches, recentRuns, recordedPlan, recordPlan, runBoard, saveMatch, saveRun, standings,
15 recentMatches, recentRuns, recordedPlan, recordPlan, runBoard, saveMatch, saveRun, type Standing, standings,
1616 } from "./db.ts";
1717 import { KEEP } from "./keep.ts";
1818 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,
303303 expect(standings(openDatabase(home), 10, "de_forge")[0]?.matches).toBe(1);
304304 });
305305
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
306397 test("the maps listed are generated maps with enough players on them, the most played first, and no more than asked", () => {
307398 const db = openDatabase(dir());
308399 // 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
327418 })();
328419 const plan = (sql: string): string =>
329420 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 }
333427 const median = (read: () => unknown): number =>
334428 Array.from({ length: 7 }, () => {
335429 const start = performance.now();
src/db.ts +7 −3
@@ -151,9 +151,13 @@ export function openDatabase(
151151 // Each player has one row for every map (`map` = `EVERY_MAP`, "") and one
152152 // for each map they have a result on (#242), so the board on one map is the
153153 // 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.
157161 const standingColumns = db.query<{ name: string }, []>("PRAGMA table_info(standing)").all().map((c) => c.name);
158162 if (standingColumns.length > 0 && !standingColumns.includes("map")) {
159163 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
12471247 *
12481248 * `/` is measured on this file's database, which by now holds every match the
12491249 * 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.
12511254 *
12521255 * `/engine` is the exception in size and not in kind: it carries the whole
12531256 * 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
12551258 * is that plus room for the guide to grow a section or two.
12561259 */
12571260 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,
12591262 };
12601263
12611264 test("a first visit stays inside its byte budget, compressed", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs