Improve match merge logic

This commit is contained in:
Kalle
2026-09-28 07:32:34 +03:00
parent 9cc2ae3f59
commit 39e5c624a4
2 changed files with 92 additions and 7 deletions

View File

@@ -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 });

View File

@@ -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,