diff --git a/app/features/scanner-ingest/core/Matches.test.ts b/app/features/scanner-ingest/core/Matches.test.ts index b693e514e..968590d59 100644 --- a/app/features/scanner-ingest/core/Matches.test.ts +++ b/app/features/scanner-ingest/core/Matches.test.ts @@ -92,6 +92,48 @@ describe("isSameMatch", () => { expect(Matches.isSameMatch(a, b)).toBe(true); }); + test("close play times do not identify back-to-back games against different opponents", () => { + const playedAt = 1_700_000_000_000; + const nextSet = scannerMatch({ playedAt: playedAt + 5 * 60 * 1000 }); + nextSet.teams[1] = { + players: ["o1", "o2", "o3", "o4"].map((name, i) => + scannerMatchPlayer(name, (100 + 10 * i) as MainWeaponId), + ), + }; + expect(Matches.isSameMatch(scannerMatch({ playedAt }), nextSet)).toBe( + false, + ); + }); + + test("close play times still identify a match with a couple of misread names per team", () => { + const playedAt = 1_700_000_000_000; + const b = scannerMatch({ playedAt: playedAt + 60 * 1000 }); + b.teams[0].players[0] = scannerMatchPlayer("misread1", WEAPONS[0]!); + b.teams[0].players[1] = scannerMatchPlayer("misread2", WEAPONS[1]!); + b.teams[1].players[2] = scannerMatchPlayer("misread3", WEAPONS[6]!); + expect(Matches.isSameMatch(scannerMatch({ playedAt }), b)).toBe(true); + }); + + test("contradicting weapons rule out close play times when names are unread", () => { + const playedAt = 1_700_000_000_000; + const minimap = scannerMatch({ + playedAt: playedAt + 5 * 60 * 1000, + teams: [ + { + players: WEAPONS.slice(0, 4).map((w) => scannerMatchPlayer(null, w)), + }, + { + players: [100, 110, 120, 130].map((w) => + scannerMatchPlayer(null, w as MainWeaponId), + ), + }, + ], + }); + expect(Matches.isSameMatch(scannerMatch({ playedAt }), minimap)).toBe( + false, + ); + }); + test("far-apart play times contradict identity even with equal rosters", () => { const a = scannerMatch({ playedAt: 1_700_000_000_000 }); const b = scannerMatch({ playedAt: 1_700_000_000_000 + 60 * 60 * 1000 }); diff --git a/app/features/scanner-ingest/core/Matches.ts b/app/features/scanner-ingest/core/Matches.ts index c9781fd58..e67f0ccb2 100644 --- a/app/features/scanner-ingest/core/Matches.ts +++ b/app/features/scanner-ingest/core/Matches.ts @@ -22,6 +22,11 @@ const MIN_NAME_OVERLAP = 6; const MIN_WEAPON_OVERLAP = 7; const MIN_WEAPON_SLOTS_READ = 7; +/** A team pair with at least this many names (else weapons) read on both sides can contradict identity... */ +const MIN_SLOTS_READ_TO_CONTRADICT = 3; +/** ...when at most this many of them align: different players, so a different game. */ +const MAX_CONTRADICTING_OVERLAP = 1; + const PLAYERS_PER_TEAM = 4; /** Rebuilds a match with a fixed key order so `JSON.stringify` is stable — the hashing and change-detection representation. */ @@ -66,7 +71,8 @@ export function canonicalMatch(match: ScannerMatch): ScannerMatch { /** * Whether two (possibly partial) matches describe the same game; callers pre-scope to the same * tournament + POV user. Contradicting lobby/mode/stage/replay-code/play-time rules identity out; then a - * matching replay code, close play times, or an aligning roster (names, else weapons) rules it in. + * matching replay code rules it in, contradicting rosters (a team pair of different players) rule it out, + * and close play times or an aligning roster (names, else weapons) rule it in. */ export function isSameMatch(a: ScannerMatch, b: ScannerMatch): boolean { if (a.lobby !== null && b.lobby !== null && a.lobby !== b.lobby) return false; @@ -85,14 +91,16 @@ export function isSameMatch(a: ScannerMatch, b: ScannerMatch): boolean { } if (codeDiff !== null) return true; - if (playedDiff !== null && playedDiff <= PLAYED_AT_AFFINITY_MS) return true; const aligned = bestAlignment(a, b); + if (rostersContradict(a, b, aligned.orientation)) return false; + if (playedDiff !== null && playedDiff <= PLAYED_AT_AFFINITY_MS) return true; + if (aligned.nameOverlap >= MIN_NAME_OVERLAP) return true; if ( aligned.weaponOverlap >= MIN_WEAPON_OVERLAP && - weaponSlotsRead(a) >= MIN_WEAPON_SLOTS_READ && - weaponSlotsRead(b) >= MIN_WEAPON_SLOTS_READ + matchWeaponSlotsRead(a) >= MIN_WEAPON_SLOTS_READ && + matchWeaponSlotsRead(b) >= MIN_WEAPON_SLOTS_READ ) { return true; } @@ -268,12 +276,47 @@ function weaponOverlap(a: ScannerMatchTeam, b: ScannerMatchTeam): number { return overlap; } -function weaponSlotsRead(match: ScannerMatch): number { - return match.teams.flatMap((team) => - team.players.filter((player) => player.weaponId !== null), +function rostersContradict( + a: ScannerMatch, + b: ScannerMatch, + orientation: Alignment["orientation"], +): boolean { + const [bFirst, bSecond] = + orientation === "straight" + ? [b.teams[0], b.teams[1]] + : [b.teams[1], b.teams[0]]; + return ( + teamsContradict(a.teams[0], bFirst) || teamsContradict(a.teams[1], bSecond) + ); +} + +function teamsContradict(a: ScannerMatchTeam, b: ScannerMatchTeam): boolean { + if (Math.min(namesRead(a), namesRead(b)) >= MIN_SLOTS_READ_TO_CONTRADICT) { + return nameOverlap(a, b) <= MAX_CONTRADICTING_OVERLAP; + } + if ( + Math.min(weaponSlotsRead(a), weaponSlotsRead(b)) >= + MIN_SLOTS_READ_TO_CONTRADICT + ) { + return weaponOverlap(a, b) <= MAX_CONTRADICTING_OVERLAP; + } + return false; +} + +function namesRead(team: ScannerMatchTeam): number { + return team.players.filter( + (player) => player.name && normalizeInGameName(player.name), ).length; } +function weaponSlotsRead(team: ScannerMatchTeam): number { + return team.players.filter((player) => player.weaponId !== null).length; +} + +function matchWeaponSlotsRead(match: ScannerMatch): number { + return weaponSlotsRead(match.teams[0]) + weaponSlotsRead(match.teams[1]); +} + function swapSides(match: ScannerMatch): ScannerMatch { return { ...match,