Public: anyone can read this; the crew can change it.

forked from trevato/cs

Take updates from trevato/cs #367

Merged opened this in answer to

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

Merged

torque/cs-engine:torque/362-take-updates-from-trevato-cs → main 2 commits fork

  • Merged. torque/362-take-updates-from-trevato-cs was merged into main as b9b604c.

Review

Sign in to review this change.
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**.
296296 - A guest picks their own name and nobody checked it, so it is shown as
297297 `name (guest)` everywhere it appears — the leaderboard, the match feed and
298298 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.
300304 - A match belongs to whoever started it: a signed-in player's from any browser
301305 they are on, a guest's from the browser session that began it. Anyone else
302306 posting that match id is refused with a 409.
src/db.test.ts +78 −1
@@ -10,7 +10,7 @@ import { mkdtempSync } from "node:fs";
1010 import { tmpdir } from "node:os";
1111 import { join } from "node:path";
1212 import {
13 dayBoard, displayName, IDLE_SECONDS, liveness, openDatabase, type Player, recentMatches, recentRuns,
13 dayBoard, deleteMatch, displayName, IDLE_SECONDS, liveness, openDatabase, type Player, recentMatches, recentRuns,
1414 runBoard, saveMatch, saveRun, standings,
1515 } from "./db.ts";
1616 import { playKey } from "./matchid.ts";
@@ -128,6 +128,83 @@ test("a database written before the map column keeps its rows and calls them de_
128128 expect(recentMatches(db).find((m) => m.id === fresh)?.map).toBe("gen_ir1");
129129 });
130130
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
131208 // The stamp SQLite writes, for a moment `n` seconds before `NOW`: UTC, to the
132209 // second, in the format `datetime('now')` hands back.
133210 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
129129 // page draws one table per drill on every paint of `/range`.
130130 db.exec("CREATE INDEX IF NOT EXISTS run_by_drill ON run (drill, score DESC)");
131131 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)");
132161 return db;
133162 }
134163
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
135183 /** Who a match is written under: a verified forge identity, or a browser. */
136184 export type Player = {
137185 /** The forge identity's id, or "" for a guest. */
@@ -211,12 +259,25 @@ export function liveness(row: { finished: number; updated_at: string }, now = Da
211259 */
212260 export function saveMatch(db: Database, player: Player, result: Result, now = Date.now()): boolean {
213261 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 )
215265 .get(result.matchId);
216266 if (existing !== null) {
217267 if (existing.user_id !== player.userId) return false;
218268 if (player.userId === "" && existing.session !== player.session) return false;
219269 }
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 {
220281 db.query(
221282 `INSERT INTO match (id, session, user_id, name, map, day, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, seconds)
222283 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
237298 result.damage, result.rounds, result.scoreMine, result.scoreTheirs, result.finished ? 1 : 0, result.seconds,
238299 player.userId, result.map, dayFiled(result.matchId, now),
239300 );
240 return true;
241301 }
242302
243303 export const recentMatches = (db: Database, limit = 10): MatchRow[] =>
@@ -252,20 +312,17 @@ export const recentMatches = (db: Database, limit = 10): MatchRow[] =>
252312 * a forge identity, or — for the guests, who are nobody in particular — the
253313 * name they typed. Grouping by the name alone is how a guest calling
254314 * 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.
255318 */
256319 export const standings = (db: Database, limit = 10): Standing[] =>
257320 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
269326 LIMIT ?1`,
270327 ).all(limit);
271328
@@ -430,8 +487,17 @@ export const hitShare = (hits: number, shots: number): string =>
430487 export const matchCount = (db: Database): number =>
431488 db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM match").get()?.n ?? 0;
432489
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 }
435501
436502 export const ratio = (kills: number, deaths: number): string =>
437503 (kills / Math.max(1, deaths)).toFixed(2);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs