diff --git a/app/features/scanner-ingest/ScannerIngestRepository.server.ts b/app/features/scanner-ingest/ScannerIngestRepository.server.ts index dbd6c1250..48b6f4937 100644 --- a/app/features/scanner-ingest/ScannerIngestRepository.server.ts +++ b/app/features/scanner-ingest/ScannerIngestRepository.server.ts @@ -949,7 +949,7 @@ async function groupRosters(groupIds: number[]) { return result; } -/** Winner-first player names of each game's earliest linked ingest, keyed by the link target column's value. */ +/** Winner-first player names of each game's earliest linked ingest with recognizable names, keyed by the link target column's value. */ async function linkedPlayerNamesByTarget( column: "tournamentMatchGameResultId" | "groupMatchMapId", targetIds: number[], @@ -972,7 +972,7 @@ async function linkedPlayerNamesByTarget( for (const row of rows) { if (row.targetId === null || result.has(row.targetId)) continue; - const names = Scoreboards.winnerFirstPlayerNames(row.data); + const names = Scoreboards.recognizablePlayerNames(row.data); if (names) result.set(row.targetId, names); } diff --git a/app/features/scanner-ingest/core/Scoreboards.test.ts b/app/features/scanner-ingest/core/Scoreboards.test.ts index 91f0c939a..9048c743d 100644 --- a/app/features/scanner-ingest/core/Scoreboards.test.ts +++ b/app/features/scanner-ingest/core/Scoreboards.test.ts @@ -277,7 +277,7 @@ describe("matchedGames", () => { games: [ testGame({ matchGameResultId: 11, - linkedPlayerNames: ["", "", "", "", "l1", "l2", "l3", "l4"], + linkedPlayerNames: ["", "", "w3", "w4", "l1", "l2", "l3", "l4"], }), testGame({ matchGameResultId: 12, playedAt: 2000 }), ], @@ -785,9 +785,9 @@ describe("deriveScoreboardData", () => { }); }); -describe("winnerFirstPlayerNames", () => { +describe("recognizablePlayerNames", () => { test("returns names winner-first with unread names empty", () => { - const names = Scoreboards.winnerFirstPlayerNames( + const names = Scoreboards.recognizablePlayerNames( swapSides( testMatch({ names: ["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"] }), ), @@ -798,7 +798,15 @@ describe("winnerFirstPlayerNames", () => { test("returns null for a match without a linkable scoreboard", () => { expect( - Scoreboards.winnerFirstPlayerNames({ ...testMatch(), winner: null }), + Scoreboards.recognizablePlayerNames({ ...testMatch(), winner: null }), + ).toBe(null); + }); + + test("returns null for a read with too few names to recognize a re-detection", () => { + expect( + Scoreboards.recognizablePlayerNames( + testMatch({ names: ["w1", "", "", "w4", "l1", "", "l3", "l4"] }), + ), ).toBe(null); }); }); diff --git a/app/features/scanner-ingest/core/Scoreboards.ts b/app/features/scanner-ingest/core/Scoreboards.ts index 72108a0ab..3045f527c 100644 --- a/app/features/scanner-ingest/core/Scoreboards.ts +++ b/app/features/scanner-ingest/core/Scoreboards.ts @@ -64,7 +64,7 @@ export interface IngestableGame { loserInGameNames: string[]; /** timestamp of the game's report: the chronological key and what a scan's play time is measured against */ playedAt: number; - /** winner-first row-order names of an already linked ingest of the game, null when none; lets matching skip taken games yet recognize re-detections */ + /** winner-first row-order names of the game's earliest linked ingest that read enough names to recognize a re-detection, null when none; lets matching skip taken games yet recognize re-detections */ linkedPlayerNames: string[] | null; } @@ -257,10 +257,22 @@ export function deriveScoreboardData({ }; } -/** A match's players winner-first in row order (unread names as ""), or null without such a view — a game's `linkedPlayerNames`. */ -export function winnerFirstPlayerNames(match: ScannerMatch): string[] | null { +/** + * A linked match's players winner-first in row order (unread names as ""), the game's + * `linkedPlayerNames`. Null when it can't form a view or read too few names to ever recognize + * a re-detection: such a read must not lock the game against every other POV's scan. + */ +export function recognizablePlayerNames(match: ScannerMatch): string[] | null { const view = winnerFirstView(match); - return view ? view.players.map((player) => player.name.trim()) : null; + if (!view) return null; + + const names = view.players.map((player) => player.name.trim()); + const readNamesCount = names.filter((name) => + Matches.normalizeInGameName(name), + ).length; + if (readNamesCount < MIN_LINKED_DUPLICATE_NAME_MATCHES) return null; + + return names; } /** Winner-first row view of a match (unread names as ""). Null when it can't link: unknown winner or a team not fully seen. */ diff --git a/app/features/scanner-ingest/tests/ingest-scenarios.test.ts b/app/features/scanner-ingest/tests/ingest-scenarios.test.ts index 83c4ae79f..5a46710b5 100644 --- a/app/features/scanner-ingest/tests/ingest-scenarios.test.ts +++ b/app/features/scanner-ingest/tests/ingest-scenarios.test.ts @@ -83,6 +83,61 @@ describe("gating & request filtering", () => { ); expect(await fetchLinks()).toHaveLength(1); }); + + test("G5 implausible values: stats, scores and names the game can't show are stored as unread", async () => { + const w = await sendouqWorld(); + await w.conclude(); + const scan = w.scanned(w.maps[0]!); + const [winners, losers] = scan.teams; + + const res = await ingest(w.povUser, [ + { + ...scan, + matchScores: [99999, -5], + teams: [ + { + players: [ + { + ...winners.players[0]!, + name: "x".repeat(100), + ka: 1e12, + d: -3, + s: 1.5, + paint: 1.5e300, + }, + ...winners.players.slice(1), + ], + }, + losers, + ], + }, + ]); + expect(res.linkedGamesCount).toBe(1); + + const scoreboard = (await qMatchPage(w.match.id)).ingestedScoreboards[0]!; + expect(scoreboard.data.scores).toEqual([null, null]); + expect(scoreboard.data.players[0]).toMatchObject({ + name: "", + ka: null, + d: null, + s: null, + paint: null, + }); + }); + + test("G6 a play time far in the future: stored without one and never linked", async () => { + const w = await sendouqWorld(); + await w.conclude(); + + const res = await ingest(w.povUser, [ + w.scanned(w.maps[0]!, { playedAt: 1e300 }), + ]); + + expect(res.storedMatchesCount).toBe(1); + expect(res.linkedGamesCount).toBe(0); + const rows = await fetchIngestedMatches(); + expect(rows[0]!.data.playedAt).toBeNull(); + }); }); describe("SendouQ flow", () => { @@ -269,6 +324,38 @@ describe("SendouQ flow", () => { ]); }); + test("Q12 a nameless first link: the opponent's named read still joins the game", async () => { + const w = await sendouqWorld(); + await w.conclude(); + const scan = w.scanned(w.maps[0]!); + + await ingest(w.povUser, [ + { + ...scan, + teams: [ + { + players: scan.teams[0].players.map((p) => ({ ...p, name: null })), + }, + { + players: scan.teams[1].players.map((p) => ({ ...p, name: null })), + }, + ], + }, + ]); + const res = await ingest(w.bravoUsers[0]!, [ + w.scanned(w.maps[0]!, { seenFrom: "loser" }), + ]); + + expect(res.linkedGamesCount).toBe(1); + const scoreboard = (await qMatchPage(w.match.id)).ingestedScoreboards[0]!; + expect(scoreboard.data.players.map((p) => p.name)).toEqual([ + ...ALPHA_NAMES, + ...BRAVO_NAMES, + ]); + expect(scoreboard.data.players[0]!.userId).toBe(w.povUser.id); + expect(scoreboard.data.players[4]!.userId).toBe(w.bravoUsers[0]!.id); + }); + test("Q11 POV read misflagged as cast: the sender's seat still resolves and links their match", async () => { const w = await sendouqWorld(); await w.conclude(); diff --git a/app/features/scanner/scanner-schemas.ts b/app/features/scanner/scanner-schemas.ts index eb2d8ae00..fae88b65a 100644 --- a/app/features/scanner/scanner-schemas.ts +++ b/app/features/scanner/scanner-schemas.ts @@ -23,6 +23,12 @@ import { SCANNER_LOBBIES } from "./scanner-types"; const detectionText = v.pipe(v.string(), v.maxLength(500)); +/** longer than any in-game name or replay code the game displays */ +const MAX_PLAUSIBLE_TEXT_LENGTH = 32; + +/** how far ahead of the server's clock a sender's clock may run */ +const PLAYED_AT_MAX_CLOCK_SKEW_MS = 24 * 60 * 60 * 1000; + const scannerLobbySchema = v.picklist(SCANNER_LOBBIES); export const modeShortSchema = v.picklist(modesShort); export const stageIdSchema = v.picklist(stageIds); @@ -36,12 +42,12 @@ const scannerAbilitySchema = v.union([ ]); const scannerMatchPlayerSchema = v.object({ - name: v.nullable(detectionText), + name: plausibleText(), weaponId: v.nullable(mainWeaponIdSchema), - paint: v.nullable(v.number()), - ka: v.nullable(v.number()), - d: v.nullable(v.number()), - s: v.nullable(v.number()), + paint: plausibleCount(9999), + ka: plausibleCount(99), + d: plausibleCount(99), + s: plausibleCount(99), /** [head, clothes, shoes] ability rows; a row may hold its main alone */ abilities: v.optional( v.pipe( @@ -63,8 +69,8 @@ const MAX_OBJECTIVE_SAMPLES = 1000; const scannerMatchObjectiveSampleSchema = v.object({ t: v.pipe(v.number(), v.integer(), v.minValue(0)), time: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))), - score: v.tuple([v.nullable(v.number()), v.nullable(v.number())]), - penalty: v.tuple([v.nullable(v.number()), v.nullable(v.number())]), + score: v.tuple([plausibleCount(100), plausibleCount(100)]), + penalty: v.tuple([plausibleCount(999), plausibleCount(999)]), control: v.nullable(teamIndexSchema), position: v.optional( v.nullable(v.pipe(v.number(), v.minValue(-100), v.maxValue(100))), @@ -106,21 +112,28 @@ const MAX_KILLS = 200; const scannerMatchKillSchema = v.object({ t: v.pipe(v.number(), v.integer(), v.minValue(0)), time: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))), - name: v.nullable(detectionText), + name: plausibleText(), }); export const scannerMatchSchema = v.object({ startsAt: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))), endsAt: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))), /** wall-clock ms the game was played */ - playedAt: v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), + playedAt: v.nullable( + v.pipe( + v.number(), + v.integer(), + v.gtValue(0), + v.transform((playedAt) => + playedAt <= Date.now() + PLAYED_AT_MAX_CLOCK_SKEW_MS ? playedAt : null, + ), + ), + ), lobby: v.nullable(scannerLobbySchema), mode: v.nullable(modeShortSchema), stage: v.nullable(stageIdSchema), - matchScores: v.nullable( - v.tuple([v.nullable(v.number()), v.nullable(v.number())]), - ), - replayCode: v.nullable(detectionText), + matchScores: v.nullable(v.tuple([plausibleCount(100), plausibleCount(100)])), + replayCode: plausibleText(), cast: v.boolean(), objective: v.nullable(scannerMatchObjectiveSchema), playerStatus: v.nullable(scannerMatchPlayerStatusSchema), @@ -137,6 +150,30 @@ export const scannerMatchSchema = v.object({ ), }); +/** An OCR'd text no game screen could show reads as unread instead of failing the whole upload. */ +function plausibleText() { + return v.nullable( + v.pipe( + detectionText, + v.transform((text) => + text.length <= MAX_PLAUSIBLE_TEXT_LENGTH ? text : null, + ), + ), + ); +} + +/** A number the game can't display (a misread or a forged value) reads as unread instead of failing the whole upload. */ +function plausibleCount(max: number) { + return v.nullable( + v.pipe( + v.number(), + v.transform((value) => + Number.isInteger(value) && value >= 0 && value <= max ? value : null, + ), + ), + ); +} + type MutuallyAssignable = [A] extends [B] ? [B] extends [A] ? true