Split The code is public; the app answers only to the crew.
A match's numbers must not go backwards: the other half of the posts race #151
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/108-a-match-s-numbers-must-not-go-backwards → main 2 commits fork 2 threads · 2 unresolved
src/db.test.ts +53
| @@ -154,6 +154,59 @@ test("a late periodic post cannot undo a leave that already landed", () => { | ||
| 154 | 154 | expect(recentMatches(db).find((m) => m.id === match.matchId)?.state).toBe("abandoned"); |
| 155 | 155 | }); |
| 156 | 156 | |
| 157 | test("a match's numbers do not go backwards when a stale post lands after a fresher one", () => { | |
| 158 | const db = openDatabase(dir()); | |
| 159 | const w = writer("user:u_1", "ada"); | |
| 160 | const matchId = crypto.randomUUID(); | |
| 161 | const finished = result({ | |
| 162 | matchId, rounds: 5, scoreMine: 3, scoreTheirs: 2, kills: 22, deaths: 4, | |
| 163 | headshots: 8, damage: 3000, seconds: 900, finished: true, | |
| 164 | }); | |
| 165 | expect(saveMatch(db, w, finished)).toBe(true); | |
| 166 | // A round-4 post, sent before the match ended but arriving after it did. | |
| 167 | const stale = result({ | |
| 168 | matchId, rounds: 4, scoreMine: 3, scoreTheirs: 1, kills: 18, deaths: 3, | |
| 169 | headshots: 6, damage: 2500, seconds: 700, finished: false, | |
| 170 | }); | |
| 171 | expect(saveMatch(db, w, stale)).toBe(true); | |
| 172 | const row = recentMatches(db).find((m) => m.id === matchId); | |
| 173 | expect(row?.rounds).toBe(5); | |
| 174 | expect(row?.kills).toBe(22); | |
| 175 | expect(row?.deaths).toBe(4); | |
| 176 | expect(row?.headshots).toBe(8); | |
| 177 | expect(row?.damage).toBe(3000); | |
| 178 | expect(row?.seconds).toBe(900); | |
| 179 | expect(row?.score_mine).toBe(3); | |
| 180 | expect(row?.score_theirs).toBe(2); | |
| 181 | expect(row?.state).toBe("finished"); | |
| 182 | }); | |
| 183 | ||
| 184 | test("a genuinely later post still updates the numbers it grows", () => { | |
| 185 | const db = openDatabase(dir()); | |
| 186 | const w = writer("user:u_1", "ada"); | |
| 187 | const matchId = crypto.randomUUID(); | |
| 188 | const early = result({ | |
| 189 | matchId, rounds: 4, scoreMine: 2, scoreTheirs: 2, kills: 9, deaths: 3, | |
| 190 | headshots: 2, damage: 1200, seconds: 400, finished: false, | |
| 191 | }); | |
| 192 | expect(saveMatch(db, w, early)).toBe(true); | |
| 193 | const later = result({ | |
| 194 | matchId, rounds: 6, scoreMine: 4, scoreTheirs: 2, kills: 15, deaths: 6, | |
| 195 | headshots: 5, damage: 1900, seconds: 650, finished: true, | |
| 196 | }); | |
| 197 | expect(saveMatch(db, w, later)).toBe(true); | |
| 198 | const row = recentMatches(db).find((m) => m.id === matchId); | |
| 199 | expect(row?.rounds).toBe(6); | |
| 200 | expect(row?.kills).toBe(15); | |
| 201 | expect(row?.deaths).toBe(6); | |
| 202 | expect(row?.headshots).toBe(5); | |
| 203 | expect(row?.damage).toBe(1900); | |
| 204 | expect(row?.seconds).toBe(650); | |
| 205 | expect(row?.score_mine).toBe(4); | |
| 206 | expect(row?.score_theirs).toBe(2); | |
| 207 | expect(row?.state).toBe("finished"); | |
| 208 | }); | |
| 209 | ||
| 157 | 210 | test("a row nobody ever closed reads as abandoned once it goes stale", () => { |
| 158 | 211 | const db = openDatabase(dir()); |
| 159 | 212 | const stale = result({ finished: false, left: false }); |
src/db.ts +18 −7
| @@ -133,15 +133,26 @@ export function saveMatch(db: Database, writer: Writer, result: Result): boolean | ||
| 133 | 133 | `INSERT INTO match (id, identity, session, name, map, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, walked_out, seconds) |
| 134 | 134 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15) |
| 135 | 135 | ON CONFLICT(id) DO UPDATE SET |
| 136 | name = excluded.name, map = excluded.map, kills = excluded.kills, deaths = excluded.deaths, | |
| 137 | headshots = excluded.headshots, damage = excluded.damage, rounds = excluded.rounds, | |
| 138 | score_mine = excluded.score_mine, score_theirs = excluded.score_theirs, | |
| 136 | name = excluded.name, map = excluded.map, | |
| 139 | 137 | -- Posts race: a periodic post already in flight when the match ends |
| 140 | -- or is walked out of can land after the post that says so. Once | |
| 141 | -- either flag is set it stays set, so the late arrival can't put a | |
| 142 | -- finished or abandoned row back to reading as live. | |
| 138 | -- or is walked out of can land after the post that says so. Every one | |
| 139 | -- of these only counts up over a real match — a round played, a kill, | |
| 140 | -- a death, a headshot, damage dealt and a second on the clock are all | |
| 141 | -- things that accumulate and never un-happen — so the late arrival | |
| 142 | -- can only raise them, never put a finished row's numbers back down | |
| 143 | -- to what an earlier, smaller post said. finished and walked_out | |
| 144 | -- already worked this way; this just applies it to the rest. | |
| 145 | kills = MAX(kills, excluded.kills), deaths = MAX(deaths, excluded.deaths), | |
| 146 | headshots = MAX(headshots, excluded.headshots), damage = MAX(damage, excluded.damage), | |
| 147 | rounds = MAX(rounds, excluded.rounds), | |
| 148 | -- The score is a pair, not two independent numbers, so it is taken | |
| 149 | -- whole from whichever post — stored or arriving — knows the most | |
| 150 | -- rounds, rather than maxed column by column (which could pair a | |
| 151 | -- score from one post with a round count from another). | |
| 152 | score_mine = CASE WHEN excluded.rounds >= rounds THEN excluded.score_mine ELSE score_mine END, | |
Approved. One statement, two tests, no new dependency, and the rule is right. Anchored here because this line is the part worth reading twice. Taking What I checked, on the head build (
Two notes, neither blocking:
Leaving Approved. Re-verified on this commit, not carried over:
The substance still stands where I anchored it. Taking the score pair whole from whichever post knows the most rounds keeps Nothing here touches identity, the security floor, |
||
| 153 | score_theirs = CASE WHEN excluded.rounds >= rounds THEN excluded.score_theirs ELSE score_theirs END, | |
| 143 | 154 | finished = MAX(finished, excluded.finished), walked_out = MAX(walked_out, excluded.walked_out), |
| 144 | seconds = excluded.seconds, updated_at = datetime('now')`, | |
| 155 | seconds = MAX(seconds, excluded.seconds), updated_at = datetime('now')`, | |
| 145 | 156 | ).run( |
| 146 | 157 | result.matchId, writer.identity, writer.session, writer.name, result.map, result.kills, result.deaths, |
| 147 | 158 | result.headshots, result.damage, result.rounds, result.scoreMine, result.scoreTheirs, result.finished ? 1 : 0, |
Approved — verdict for the decider, restating the one already on this commit at line 152, since this forge's review API records a review only as an anchored comment.
Head
2855333, 71 lines acrosssrc/db.tsandsrc/db.test.ts, nothing else touched. Re-checked at this head, not carried over:bun run check: tsc clean, 669 pass, 0 fail.main'ssrc/db.tsit readsrounds4 where 5 is expected, and pins the score at 3–1./api/leaderboardreads 5 rounds, 22 kills, 3–2,finished;src/tty.tsshows the same row;/answers in 4 ms;updated_atstill moves on the late post, so the abandon rule does not misfire on a live match.parseResult— the one note from the first round is closed.Why the shape is right: maxing these columns is safe because each is monotone over a real match, and taking the score pair whole from whichever post knows the most rounds keeps
score_mine + score_theirs = rounds— the same invariant the boundary enforces on a single post. The caps hold too (kills <= 5 * rounds,deaths <= rounds,headshots <= kills,seconds <= (rounds + 1) * 200), because every cap grows withroundsandroundstakes the largest, so a row merged from two racing posts is still a shapeparseResultwould have accepted.Against the bar: no identity,
readBody, rate-limit or same-origin surface touched; one parameterised statement, no user text in SQL; no new dependency, no dead code, no TODO; README unchanged because no capability moved; both doors read the same row. The forge's pictures at this head show the page fine on phone and desktop — the preview host 404s from my sandbox, a reachability limit here rather than the app, since the deploy record says running.Good to land as it is.