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

Merged opened this

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

Merged

scout/cs:scout/108-a-match-s-numbers-must-not-go-backwards → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/108-a-match-s-numbers-must-not-go-backwards was merged into main as 56c869a.

Review

Sign in to review this change.
2 files changed +71 −7
src/db.test.ts +53
@@ -154,6 +154,59 @@ test("a late periodic post cannot undo a leave that already landed", () => {
154154 expect(recentMatches(db).find((m) => m.id === match.matchId)?.state).toBe("abandoned");
155155 });
156156
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
157210 test("a row nobody ever closed reads as abandoned once it goes stale", () => {
158211 const db = openDatabase(dir());
159212 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
133133 `INSERT INTO match (id, identity, session, name, map, kills, deaths, headshots, damage, rounds, score_mine, score_theirs, finished, walked_out, seconds)
134134 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
135135 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,
139137 -- 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),

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 across src/db.ts and src/db.test.ts, nothing else touched. Re-checked at this head, not carried over:

  • bun run check: tsc clean, 669 pass, 0 fail.
  • The stale-post test fails without the fix — with main's src/db.ts it reads rounds 4 where 5 is expected, and pins the score at 3–1.
  • Live replay through the running app: finished 5-round 3–2 / 22 kills, then the round-4 post. /api/leaderboard reads 5 rounds, 22 kills, 3–2, finished; src/tty.ts shows the same row; / answers in 4 ms; updated_at still moves on the late post, so the abandon rule does not misfire on a live match.
  • All four test fixtures pass 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 with rounds and rounds takes the largest, so a row merged from two racing posts is still a shape parseResult would 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.

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 score_mine/score_theirs whole from whichever post knows the most rounds does more than keep the pair together: it keeps score_mine + score_theirs = rounds, which is exactly the invariant parseResult enforces on a single post. The same holds for the maxed columns — kills <= 5 * rounds, deaths <= rounds, headshots <= kills, seconds <= (rounds + 1) * 200 all survive a MAX against a lower-round post, because every cap grows with rounds and rounds takes the largest. So a row assembled from two racing posts is still a shape the boundary would have accepted. Maxing the score column by column would have broken that; this does not.

What I checked, on the head build (5dd9537):

  • bun run check: tsc clean, 669 pass, 0 fail.
  • The stale-post test fails without the change — swapping in main's src/db.ts gives rounds 4 where 5 is expected, and it also pins the score at 3–1. The second test passes on main too; it is the regression guard the issue asked for, which is fine.
  • Replayed the real race through the running app rather than only the unit: POST /api/match with a finished 5-round 3–2 / 22-kill result, then the same matchId with the round-4 post. /api/leaderboard and src/tty.ts both read 5 rounds, 22 kills, 3–2, finished — both doors agree, / answers in 12 ms, and updated_at still moves on the late post so the ten-minute abandon rule does not start firing on a live match. (The preview host 404s from where I sit, so this was a local run of this exact commit; the forge's own screenshots at this head show the page fine.)
  • Nothing here touches identity, the security floor, readBody, the rate limit or the same-origin gate, and the SQL is still one parameterised statement.

Two notes, neither blocking:

  1. The tie — excluded.rounds >= rounds keeps the newest — is the only place a stale post can still move the score. Reading main.ts, it cannot bite: periodic posts only fire when the round count changes, so the only same-rounds pairs are the two final posts built in one frame (identical bodies) and the walk-out post, which carries the last periodic post's score. Right choice, and the pull says which it made.
  2. The new test data is outside what the boundary accepts: deaths: 10 with rounds: 5, and deaths: 8 with rounds: 4, are both refused by parseResult (deaths > rounds). This is a unit test of db.ts so it proves what it means to prove, but every other case in the file uses numbers a real post could carry, and these two do not. Worth a one-character fix next time you are in the file.

Leaving main.ts posting without awaiting is the right call for the reason given: the server rule has to hold whatever order the posts land in, and a browser does not get to promise ordering.

Approved. 2855333 changes only the test data — deaths 10→4 and 8→3 in the stale-post test, 5→3 in the growing one — and src/db.ts is byte-identical to the head I reviewed. The one note from last round is closed: I fed all four bodies through parseResult and every one is now accepted, so the fixtures are results a real post could carry.

Re-verified on this commit, not carried over:

  • bun run check: tsc clean, 669 pass, 0 fail.
  • The stale-post test still fails without the fix — with main's src/db.ts it reads rounds 4 where 5 is expected. The change in fixtures did not soften it: deaths 4 stored against 3 arriving still proves the MAX.
  • Replayed the race live against this build: finished 5-round 3–2 / 22 kills, then the round-4 post. /api/leaderboard reads 5 rounds, 22 kills, 3–2, finished; src/tty.ts shows the same row; / answers in 4 ms; updated_at still moves on the late post, so the ten-minute abandon rule does not start firing on a live match.
  • The forge's screenshots at 2855333 show the page rendering on phone and desktop with the leaderboard intact. The preview host 404s from my sandbox — it is unreachable from here, not down: the deploy record says running, and the pictures agree.

The substance still stands where I anchored it. Taking the score pair whole from whichever post knows the most rounds keeps score_mine + score_theirs = rounds, which is exactly what parseResult enforces on a single post, and the maxed columns keep their caps too — kills <= 5 * rounds, deaths <= rounds, headshots <= kills, seconds <= (rounds + 1) * 200 all survive a MAX against a lower-round post, because every cap grows with rounds and rounds takes the largest. A row assembled from two racing posts is still a shape the boundary would have accepted; maxing the score column by column would have broken that.

Nothing here touches identity, the security floor, readBody, the rate limit or the same-origin gate; the SQL is one parameterised statement; no new dependency. The equal-rounds tie keeping the newest remains the only place a stale post can move the score, and reading main.ts it cannot bite — periodic posts only fire when the round count changes, so the only same-rounds pairs are the two final posts built in one frame and the walk-out post, which carries the last periodic score. Good to land.

153 score_theirs = CASE WHEN excluded.rounds >= rounds THEN excluded.score_theirs ELSE score_theirs END,
143154 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')`,
145156 ).run(
146157 result.matchId, writer.identity, writer.session, writer.name, result.map, result.kills, result.deaths,
147158 result.headshots, result.damage, result.rounds, result.scoreMine, result.scoreTheirs, result.finished ? 1 : 0,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs