diff --git a/app/db/tables.ts b/app/db/tables.ts index d48add5e6..dd9a8a1ad 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -557,6 +557,8 @@ export interface ReportedWeapon { userId: number; weaponSplId: MainWeaponId; createdAt: Generated; + /** the scanner read this weapon was reported from, null when reported by hand */ + ingestedMatchId: number | null; } export interface IngestedMatch { diff --git a/app/features/scanner-ingest/ScannerIngestRepository.server.test.ts b/app/features/scanner-ingest/ScannerIngestRepository.server.test.ts index e437fbd74..3e33947e9 100644 --- a/app/features/scanner-ingest/ScannerIngestRepository.server.test.ts +++ b/app/features/scanner-ingest/ScannerIngestRepository.server.test.ts @@ -186,9 +186,9 @@ describe("addLinks", () => { expect(await fetchLinks()).toHaveLength(2); }); - test("reports the POV player's weapon once", async () => { - const povUser = await UserFactory.create(); - const { match: groupMatch, maps } = await setupSendouqMatch(); + test("reports the POV player's weapon once, tagged with its scan and dated to the game", async () => { + const { match: groupMatch, maps, users } = await setupSendouqMatch(); + const povUser = users[0]!; const { effectiveMatches } = await ScannerIngestRepository.addOrMergeMatches({ @@ -201,7 +201,9 @@ describe("addLinks", () => { { ingestedMatchId: effectiveMatches[0].id, match: effectiveMatches[0].data, - game: sendouqGame(maps[0]), + game: sendouqGame(maps[0], { + winnerUserIds: users.slice(0, FULL_GROUP_SIZE).map((user) => user.id), + }), }, ]; @@ -215,6 +217,75 @@ describe("addLinks", () => { expect(reportedWeapons[0].mapIndex).toBe(maps[0].index); expect(reportedWeapons[0].userId).toBe(povUser.id); expect(reportedWeapons[0].weaponSplId).toBe(WEAPONS[0]); + expect(reportedWeapons[0].ingestedMatchId).toBe(effectiveMatches[0].id); + expect(reportedWeapons[0].createdAt).toBe(Math.floor(PLAYED_AT / 1000)); + }); + + test("reports no weapon for a sender outside the game's rosters", async () => { + const outsider = await UserFactory.create(); + const { maps, users } = await setupSendouqMatch(); + + const { effectiveMatches } = + await ScannerIngestRepository.addOrMergeMatches({ + povUserId: outsider.id, + submitterUserId: outsider.id, + matches: [testMatch({ pov: { team: 0, index: 0 } })], + context: null, + }); + + const linkedCount = await ScannerIngestRepository.addLinks({ + links: [ + { + ingestedMatchId: effectiveMatches[0].id, + match: effectiveMatches[0].data, + game: sendouqGame(maps[0], { + winnerUserIds: users + .slice(0, FULL_GROUP_SIZE) + .map((user) => user.id), + }), + }, + ], + povUserId: outsider.id, + }); + + expect(linkedCount).toBe(1); + expect(await fetchReportedWeapons()).toHaveLength(0); + }); + + test("deleting the ingested match deletes the weapon reported from it", async () => { + const { maps, users } = await setupSendouqMatch(); + const povUser = users[0]!; + + const { effectiveMatches } = + await ScannerIngestRepository.addOrMergeMatches({ + povUserId: povUser.id, + submitterUserId: povUser.id, + matches: [testMatch({ pov: { team: 0, index: 0 } })], + context: null, + }); + await ScannerIngestRepository.addLinks({ + links: [ + { + ingestedMatchId: effectiveMatches[0].id, + match: effectiveMatches[0].data, + game: sendouqGame(maps[0], { + winnerUserIds: users + .slice(0, FULL_GROUP_SIZE) + .map((user) => user.id), + }), + }, + ], + povUserId: povUser.id, + }); + expect(await fetchReportedWeapons()).toHaveLength(1); + + // biome-ignore lint/plugin: no production path deletes ingested matches yet + await db + .deleteFrom("IngestedMatch") + .where("id", "=", effectiveMatches[0].id) + .execute(); + + expect(await fetchReportedWeapons()).toHaveLength(0); }); }); @@ -345,7 +416,7 @@ async function setupSendouqMatch(options: { isConcluded?: boolean } = {}) { .orderBy("index", "asc") .execute(); - return { match, maps }; + return { match, maps, users }; } function fetchIngestedMatches() { @@ -368,13 +439,16 @@ function fetchReportedWeapons() { return db.selectFrom("ReportedWeapon").selectAll().execute(); } -function sendouqGame(map: { - id: number; - matchId: number; - index: number; - mode: IngestableGame["mode"]; - stageId: IngestableGame["stageId"]; -}): IngestableGame { +function sendouqGame( + map: { + id: number; + matchId: number; + index: number; + mode: IngestableGame["mode"]; + stageId: IngestableGame["stageId"]; + }, + rosters: Partial> = {}, +): IngestableGame { return { target: { type: "sendouq", @@ -388,7 +462,9 @@ function sendouqGame(map: { loserUserIds: [], winnerInGameNames: [], loserInGameNames: [], + inGameNameByUserId: new Map(), playedAt: Math.floor(PLAYED_AT / 1000), linkedPlayerNames: null, + ...rosters, }; } diff --git a/app/features/scanner-ingest/ScannerIngestRepository.server.ts b/app/features/scanner-ingest/ScannerIngestRepository.server.ts index 48b6f4937..4cfc7ac63 100644 --- a/app/features/scanner-ingest/ScannerIngestRepository.server.ts +++ b/app/features/scanner-ingest/ScannerIngestRepository.server.ts @@ -448,7 +448,8 @@ export async function addOrMergeMatches({ /** * Links ingested matches to their matched game results. A row links to at most one game (re-sends * are no-ops); a game collects links from many rows (each POV's scan). A known POV player's - * weapon is reported as a regular ReportedWeapon unless they already have one for that game. + * weapon (Scoreboards.povWeaponId) is reported as a ReportedWeapon tagged with its ingested match, + * unless they already have one for that game. * * @returns count of newly created links */ @@ -782,6 +783,10 @@ async function tournamentGames({ loserUserIds: loserRoster?.userIds ?? [], winnerInGameNames: winnerRoster?.inGameNames ?? [], loserInGameNames: loserRoster?.inGameNames ?? [], + inGameNameByUserId: new Map([ + ...(winnerRoster?.inGameNameByUserId ?? []), + ...(loserRoster?.inGameNameByUserId ?? []), + ]), playedAt: row.playedAt, linkedPlayerNames: linkedNames.get(row.matchGameResultId) ?? null, }; @@ -791,6 +796,7 @@ async function tournamentGames({ interface Roster { userIds: number[]; inGameNames: string[]; + inGameNameByUserId: Map; } async function teamRosters(teamIds: Array) { @@ -814,18 +820,22 @@ async function teamRosters(teamIds: Array) { const result = new Map(); for (const member of members) { - const roster = result.get(member.tournamentTeamId) ?? { - userIds: [], - inGameNames: [], - }; + const roster = result.get(member.tournamentTeamId) ?? emptyRoster(); roster.userIds.push(member.userId); - if (member.inGameName) roster.inGameNames.push(member.inGameName); + if (member.inGameName) { + roster.inGameNames.push(member.inGameName); + roster.inGameNameByUserId.set(member.userId, member.inGameName); + } result.set(member.tournamentTeamId, roster); } return result; } +function emptyRoster(): Roster { + return { userIds: [], inGameNames: [], inGameNameByUserId: new Map() }; +} + async function sendouqGames({ groupMatchId, userId, @@ -918,6 +928,10 @@ async function sendouqGames({ loserUserIds: loserRoster?.userIds ?? [], winnerInGameNames: winnerRoster?.inGameNames ?? [], loserInGameNames: loserRoster?.inGameNames ?? [], + inGameNameByUserId: new Map([ + ...(winnerRoster?.inGameNameByUserId ?? []), + ...(loserRoster?.inGameNameByUserId ?? []), + ]), playedAt: row.playedAt, linkedPlayerNames: linkedNames.get(row.groupMatchMapId) ?? null, }; @@ -937,12 +951,12 @@ async function groupRosters(groupIds: number[]) { const result = new Map(); for (const member of members) { - const roster = result.get(member.groupId) ?? { - userIds: [], - inGameNames: [], - }; + const roster = result.get(member.groupId) ?? emptyRoster(); roster.userIds.push(member.userId); - if (member.inGameName) roster.inGameNames.push(member.inGameName); + if (member.inGameName) { + roster.inGameNames.push(member.inGameName); + roster.inGameNameByUserId.set(member.userId, member.inGameName); + } result.set(member.groupId, roster); } @@ -981,12 +995,15 @@ async function linkedPlayerNamesByTarget( async function reportPovWeapon( trx: Transaction, - { match, game }: { match: ScannerMatch; game: IngestableGame }, + { + ingestedMatchId, + match, + game, + }: { ingestedMatchId: number; match: ScannerMatch; game: IngestableGame }, povUserId: number | null, ) { - if (povUserId === null || match.pov === null) return; - const weaponSplId = - match.teams[match.pov.team]?.players[match.pov.index]?.weaponId ?? null; + if (povUserId === null) return; + const weaponSplId = Scoreboards.povWeaponId({ match, game, povUserId }); if (weaponSplId === null) return; await trx @@ -1001,6 +1018,9 @@ async function reportPovWeapon( mapIndex: game.mapIndex, userId: povUserId, weaponSplId, + ingestedMatchId, + // season stats bucket by it, and a scan can arrive long after the game + createdAt: game.playedAt, }) .onConflict((oc) => oc diff --git a/app/features/scanner-ingest/core/Scoreboards.test.ts b/app/features/scanner-ingest/core/Scoreboards.test.ts index 9048c743d..49773006b 100644 --- a/app/features/scanner-ingest/core/Scoreboards.test.ts +++ b/app/features/scanner-ingest/core/Scoreboards.test.ts @@ -34,6 +34,7 @@ function testGame( loserUserIds: [], winnerInGameNames: [], loserInGameNames: [], + inGameNameByUserId: new Map(), playedAt: 1000, linkedPlayerNames: null, ...rest, @@ -811,6 +812,94 @@ describe("recognizablePlayerNames", () => { }); }); +describe("Scoreboards.povWeaponId", () => { + const SENDER_ID = 1; + const rosterGame = () => + testGame({ + winnerUserIds: [1, 2, 3, 4], + loserUserIds: [5, 6, 7, 8], + inGameNameByUserId: new Map( + NAMES.map((name, i) => [i + 1, `${name}#1234`]), + ), + }); + const weapons: MainWeaponId[] = [10, 20, 30, 40, 50, 60, 70, 80]; + + test.each([ + { + why: "the sender's own seat", + povIndex: 0, + povUserId: SENDER_ID, + expected: 10, + }, + { + why: "a losing sender's own seat", + povIndex: 4, + povUserId: 5, + expected: 50, + }, + { + why: "no POV seat", + povIndex: null, + povUserId: SENDER_ID, + expected: null, + }, + { + why: "a sender in neither roster (caster)", + povIndex: 0, + povUserId: 99, + expected: null, + }, + { + why: "a seat on the sender's opponents' side", + povIndex: 4, + povUserId: SENDER_ID, + expected: null, + }, + { + why: "a teammate's seat (their recording)", + povIndex: 1, + povUserId: SENDER_ID, + expected: null, + }, + ])("$why", ({ povIndex, povUserId, expected }) => { + expect( + Scoreboards.povWeaponId({ + match: testMatch({ povIndex, weapons }), + game: rosterGame(), + povUserId, + }), + ).toBe(expected); + }); + + test.each([ + { why: "unread", seatName: "" }, + { why: "garbled", seatName: "???1" }, + ])( + "a seat whose name is $why can't contradict the sender", + ({ seatName }) => { + const names = NAMES.map((name, i) => (i === 0 ? seatName : name)); + + expect( + Scoreboards.povWeaponId({ + match: testMatch({ povIndex: 0, names, weapons }), + game: rosterGame(), + povUserId: SENDER_ID, + }), + ).toBe(10); + }, + ); + + test("returns null when the seat's weapon was not read", () => { + expect( + Scoreboards.povWeaponId({ + match: testMatch({ povIndex: 0, weapons: [null, ...weapons.slice(1)] }), + game: rosterGame(), + povUserId: SENDER_ID, + }), + ).toBe(null); + }); +}); + describe("resolveContext", () => { /** A tournament's reported games as an ordered (mode, stageId) sequence. */ function tournamentGames( diff --git a/app/features/scanner-ingest/core/Scoreboards.ts b/app/features/scanner-ingest/core/Scoreboards.ts index 3045f527c..65ad2f086 100644 --- a/app/features/scanner-ingest/core/Scoreboards.ts +++ b/app/features/scanner-ingest/core/Scoreboards.ts @@ -62,6 +62,8 @@ export interface IngestableGame { winnerInGameNames: string[]; /** known in-game names of the losing team's roster, the side fallback for reads without a POV seat */ loserInGameNames: string[]; + /** both rosters' known in-game names by user id, tells whose seat a POV read marks */ + inGameNameByUserId: Map; /** 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 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 */ @@ -180,6 +182,38 @@ export function matchedGames({ return result; } +/** + * The weapon the sender played in a linked game, read off the scan's POV seat. Null unless the + * sender is in the roster on the seat's side and the seat's name isn't another roster member's: + * a caster's or a teammate's recording marks someone else's seat. + */ +export function povWeaponId({ + match, + game, + povUserId, +}: { + match: ScannerMatch; + game: IngestableGame; + povUserId: number; +}): MainWeaponId | null { + const view = winnerFirstView(match); + if (!view || view.povIndex === null) return null; + if (povSideAgreement(view, game, povUserId) !== true) return null; + + const seat = view.players[view.povIndex]!; + const seatName = Matches.normalizeInGameName(seat.name); + const seatUserIds = [...game.inGameNameByUserId] + .filter( + ([, inGameName]) => Matches.normalizeInGameName(inGameName) === seatName, + ) + .map(([userId]) => userId); + if (seatName && seatUserIds.length > 0 && !seatUserIds.includes(povUserId)) { + return null; + } + + return seat.weaponId; +} + export interface IngestedScoreboardPlayer { name: string; tournamentTeamId: number | null; diff --git a/app/features/scanner-ingest/tests/ingest-scenarios.test.ts b/app/features/scanner-ingest/tests/ingest-scenarios.test.ts index 5a46710b5..9765e66b2 100644 --- a/app/features/scanner-ingest/tests/ingest-scenarios.test.ts +++ b/app/features/scanner-ingest/tests/ingest-scenarios.test.ts @@ -356,6 +356,17 @@ describe("SendouQ flow", () => { expect(scoreboard.data.players[4]!.userId).toBe(w.bravoUsers[0]!.id); }); + test("Q13 a teammate's recording: the game links, but their weapon isn't credited to the sender", async () => { + const w = await sendouqWorld(); + await w.conclude(); + + // the POV seat is Alpha1's, sent by their teammate Alpha2 + const res = await ingest(w.alphaUsers[1]!, [w.scanned(w.maps[0]!)]); + + expect(res.linkedGamesCount).toBe(1); + expect(await fetchReportedWeapons()).toHaveLength(0); + }); + 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(); @@ -597,6 +608,23 @@ describe("tournament flow", () => { const page = await tournamentMatchPage(w.tournamentId, finalMatch.id); expect(page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([0, 1]); }); + + test("T7 cast footage with a POV seat: the caster gets no weapon for a set they didn't play", async () => { + const w = await tournamentWorld(); + const finalMatch = w.matches.at(-1)!; + await w.cast(finalMatch.id); + const caster = await createUser(); + await w.staff(caster); + const [firstGame, secondGame] = await w.games(finalMatch.id); + + const res = await ingest(caster, [ + w.scanned(firstGame!, { cast: true }), + w.scanned(secondGame!, { cast: true, pov: { team: 0, index: 0 } }), + ]); + + expect(res.linkedGamesCount).toBe(2); + expect(await fetchReportedWeapons()).toHaveLength(0); + }); }); describe("response contract & idempotency", () => { diff --git a/app/features/scanner/core/detectors/scoreboard/row.ts b/app/features/scanner/core/detectors/scoreboard/row.ts index 4e7885aaa..b63ee88c4 100644 --- a/app/features/scanner/core/detectors/scoreboard/row.ts +++ b/app/features/scanner/core/detectors/scoreboard/row.ts @@ -25,6 +25,13 @@ import { type WeaponTemplate, } from "./weapons"; +/** + * Below this the best icon is no read: every fixture's true icon scores >= 0.53, while a + * kit without a template (new after a game patch) reads as its nearest sibling at up to 0.92, + * so the floor only drops the clearest non-matches. + */ +const WEAPON_MIN_SCORE = 0.5; + /** Per-row ROI geometry; the replay detector closes these over its panel dx. */ export interface RowRois { weapon(cy: number): Roi; @@ -92,7 +99,10 @@ export function* parseScoreboardRowSteps( return { player: { name: name?.name ?? "", - weaponId: weapon ? toMainWeaponId(weapon.id) : null, + weaponId: + weapon && weapon.score >= WEAPON_MIN_SCORE + ? toMainWeaponId(weapon.id) + : null, paint: paint?.value ?? null, ka: statValues[0] ?? null, d: statValues[1] ?? null, diff --git a/migrations/20260929151035-reported-weapon-ingested-match.ts b/migrations/20260929151035-reported-weapon-ingested-match.ts new file mode 100644 index 000000000..dd6f0aabb --- /dev/null +++ b/migrations/20260929151035-reported-weapon-ingested-match.ts @@ -0,0 +1,20 @@ +import { type Kysely, sql } from "kysely"; + +/** Tags weapons reported from a scanner read with the ingested match they came from */ +export async function up(db: Kysely): Promise { + await db.transaction().execute(async (trx) => { + await trx.schema + .alterTable("ReportedWeapon") + .addColumn("ingestedMatchId", "integer", (col) => + col.references("IngestedMatch.id").onDelete("cascade"), + ) + .execute(); + + await trx.schema + .createIndex("reported_weapon_ingested_match_id") + .on("ReportedWeapon") + .column("ingestedMatchId") + .where(sql.ref("ingestedMatchId"), "is not", null) + .execute(); + }); +}