From d09b1afaa33b3b1944b87e8daa3b1003375e7117 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Sat, 19 Sep 2026 13:58:15 -0400 Subject: [PATCH] bug: never overwrite a recorded round - a score for a round that is already recorded (or an earlier one) comes from a server that is behind the backend; it is rejected and the server is told to re-sync instead of overwriting the real round and its backup - an identical redelivery is a no-op, and may only fill in a missing backup - a backup with no players or for another round is stored as no backup - rounds replayed after a restore are unaffected: the restore soft-deleted the ones they replace --- src/matches/events/ScoreEvent.spec.ts | 194 ++++++++++++++++++++++++++ src/matches/events/ScoreEvent.ts | 98 ++++++++++++- 2 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 src/matches/events/ScoreEvent.spec.ts diff --git a/src/matches/events/ScoreEvent.spec.ts b/src/matches/events/ScoreEvent.spec.ts new file mode 100644 index 000000000..ea40183c0 --- /dev/null +++ b/src/matches/events/ScoreEvent.spec.ts @@ -0,0 +1,194 @@ +import { Logger } from "@nestjs/common"; +import ScoreEvent from "./ScoreEvent"; + +function backup(round: number, players = true) { + const team = (name: string, accountId: number) => + players + ? `\t"${name}"\n\t{\n\t\t"${accountId}"\n\t\t{\n\t\t\t"name"\t\t"p"\n\t\t}\n\t}\n` + : ""; + + return ( + `"SaveFile"\n{\n\t"map"\t\t"de_inferno"\n\t"round"\t\t"${round}"\n` + + `\t"RoundResults"\n\t{\n\t\t"round1"\t\t"1"\n\t}\n` + + team("PlayersOnTeam1", 874739096) + + team("PlayersOnTeam2", 874739097) + + `}\n` + ); +} + +describe("ScoreEvent", () => { + const matchId = "11111111-1111-1111-1111-111111111111"; + const matchMapId = "22222222-2222-2222-2222-222222222222"; + + let processor: ScoreEvent; + let recorded: Array>; + let hasura: { query: jest.Mock; mutation: jest.Mock }; + let matchAssistant: { sendServerMatchId: jest.Mock }; + + function score(overrides: Record = {}) { + processor.setData(matchId, { + time: "2026-09-19T13:41:02.123Z", + round: 1, + match_map_id: matchMapId, + lineup_1_score: 0, + lineup_1_money: 0, + lineup_1_timeouts_available: 3, + lineup_2_score: 1, + lineup_2_money: 0, + lineup_2_timeouts_available: 3, + lineup_1_side: "CT", + lineup_2_side: "TERRORIST", + winning_side: "TERRORIST", + winning_reason: "BombExploded", + backup_file: backup(1), + ...overrides, + } as any); + } + + function inserted() { + const call = hasura.mutation.mock.calls.find( + ([mutation]) => mutation.insert_match_map_rounds_one, + ); + return call?.[0].insert_match_map_rounds_one.__args.object; + } + + beforeEach(() => { + recorded = []; + hasura = { + query: jest.fn(async () => ({ match_map_rounds: recorded })), + mutation: jest.fn(async () => ({})), + }; + matchAssistant = { sendServerMatchId: jest.fn() }; + + processor = new ScoreEvent( + new Logger("ScoreEventTest"), + hasura as any, + matchAssistant as any, + {} as any, + {} as any, + ); + }); + + it("records the next round", async () => { + score(); + + await processor.process(); + + expect(inserted().round).toBe(1); + expect(inserted().backup_file).toBe(backup(1)); + expect(matchAssistant.sendServerMatchId).not.toHaveBeenCalled(); + }); + + it("only looks at rounds that are still live", async () => { + // a restore soft-deletes the rounds it undoes, which is what lets the + // replayed ones back in + score({ round: 6 }); + + await processor.process(); + + expect(hasura.query.mock.calls[0][0].match_map_rounds.__args.where).toEqual( + { + match_map_id: { _eq: matchMapId }, + round: { _gte: 6 }, + deleted_at: { _is_null: true }, + }, + ); + }); + + it("purges the rounds a restore voided before recording the replay", async () => { + score({ round: 6 }); + + await processor.process(); + + const [cleanup, insert] = hasura.mutation.mock.calls.map(([m]) => m); + expect(cleanup.delete_match_map_rounds).toBeDefined(); + expect(insert.insert_match_map_rounds_one).toBeDefined(); + }); + + it("refuses a restarted server replaying a round that is already recorded", async () => { + // 2026-09-19: a rebooted server at 0-0 published its own round 1 over the + // real one, backup included + recorded = [ + { + id: "r1", + round: 1, + time: "2026-09-19T13:41:02.123+00:00", + backup_file: backup(1), + }, + ]; + score({ + time: "2026-09-19T13:49:42.000Z", + winning_side: "CT", + backup_file: backup(1, false), + }); + + await processor.process(); + + expect(hasura.mutation).not.toHaveBeenCalled(); + expect(matchAssistant.sendServerMatchId).toHaveBeenCalledWith(matchId); + }); + + it("refuses a round behind the latest recorded one", async () => { + recorded = [{ id: "r7", round: 7, time: "2026-09-19T13:48:00+00:00" }]; + score({ round: 3 }); + + await processor.process(); + + expect(hasura.mutation).not.toHaveBeenCalled(); + expect(matchAssistant.sendServerMatchId).toHaveBeenCalledWith(matchId); + }); + + it("treats a redelivered score as already handled", async () => { + recorded = [ + { + id: "r1", + round: 1, + time: "2026-09-19T13:41:02.123+00:00", + backup_file: backup(1), + }, + ]; + score(); + + await processor.process(); + + expect(hasura.mutation).not.toHaveBeenCalled(); + expect(matchAssistant.sendServerMatchId).not.toHaveBeenCalled(); + }); + + it("lets a redelivery supply a backup the first delivery lacked", async () => { + recorded = [ + { + id: "r1", + round: 1, + time: "2026-09-19T13:41:02.123+00:00", + backup_file: "", + }, + ]; + score(); + + await processor.process(); + + expect( + hasura.mutation.mock.calls[0][0].update_match_map_rounds_by_pk.__args, + ).toEqual({ + pk_columns: { id: "r1" }, + _set: { backup_file: backup(1) }, + }); + }); + + it("does not store a backup with no players in it", async () => { + score({ backup_file: backup(1, false) }); + + await processor.process(); + + expect(inserted().backup_file).toBe(""); + }); + + it("does not store a backup for a different round", async () => { + score({ round: 2, backup_file: backup(1) }); + + await processor.process(); + + expect(inserted().backup_file).toBe(""); + }); +}); diff --git a/src/matches/events/ScoreEvent.ts b/src/matches/events/ScoreEvent.ts index 64b766951..771b28df8 100644 --- a/src/matches/events/ScoreEvent.ts +++ b/src/matches/events/ScoreEvent.ts @@ -18,6 +18,54 @@ export default class ScoreEvent extends MatchEventProcessor<{ winning_reason: e_winning_reasons_enum; }> { public async process() { + // The backend owns the round history. A round at or past this one that is + // still live means the sender is behind it -- a server restarted into a + // fresh 0-0 game -- and applying its score would overwrite real rounds and + // their backups. Rounds replayed after a restore never land here: the + // restore soft-deleted the ones they replace. + const { match_map_rounds: recorded } = await this.hasura.query({ + match_map_rounds: { + __args: { + where: { + match_map_id: { + _eq: this.data.match_map_id, + }, + round: { + _gte: this.data.round, + }, + deleted_at: { + _is_null: true, + }, + }, + }, + id: true, + round: true, + time: true, + backup_file: true, + }, + }); + + if (recorded.length > 0) { + const same = recorded.find((round) => round.round === this.data.round); + + if ( + same && + new Date(same.time).getTime() === new Date(this.data.time).getTime() + ) { + await this.backfillBackup(same); + return; + } + + const latest = Math.max(...recorded.map((round) => round.round)); + + this.logger.error( + `[${this.matchId}] rejected score for round ${this.data.round} of match map ${this.data.match_map_id}: round ${latest} is already recorded, the server is behind`, + ); + + await this.matchAssistant.sendServerMatchId(this.matchId); + return; + } + await this.cleanupData(); await this.hasura.mutation({ @@ -26,7 +74,7 @@ export default class ScoreEvent extends MatchEventProcessor<{ object: { time: new Date(this.data.time), round: this.data.round, - backup_file: this.data.backup_file, + backup_file: this.usableBackup(), match_map_id: this.data.match_map_id, lineup_1_score: this.data.lineup_1_score, lineup_1_money: this.data.lineup_1_money, @@ -60,6 +108,54 @@ export default class ScoreEvent extends MatchEventProcessor<{ }); } + // A redelivered score is the same round again; the only thing it may still + // add is a backup the first delivery went without. + private async backfillBackup(round: { id: string; backup_file?: string }) { + const backupFile = this.usableBackup(); + + if (round.backup_file || !backupFile) { + return; + } + + await this.hasura.mutation({ + update_match_map_rounds_by_pk: { + __args: { + pk_columns: { + id: round.id, + }, + _set: { + backup_file: backupFile, + }, + }, + __typename: true, + }, + }); + } + + // CS2 writes a well-formed file even for a round that ended with nobody + // seated. Restoring one leaves every player unassigned, so it is stored as + // no backup at all rather than as something a restore would pick. + private usableBackup(): string { + const backupFile = this.data.backup_file ?? ""; + + const round = /"round"\s+"(\d+)"/.exec(backupFile); + + const usable = + round !== null && + parseInt(round[1]) === Number(this.data.round) && + ["PlayersOnTeam1", "PlayersOnTeam2"].every((team) => + new RegExp(`"${team}"\\s*\\{\\s*"[^"]+"\\s*\\{`).test(backupFile), + ); + + if (!usable && backupFile !== "") { + this.logger.error( + `[${this.matchId}] discarding unusable backup for round ${this.data.round} of match map ${this.data.match_map_id}`, + ); + } + + return usable ? backupFile : ""; + } + private async cleanupData() { await this.hasura.mutation({ delete_match_map_rounds: {