From 07d5664bd9249d34a30e6264c1e73ce4db8ac99a Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:25:52 +0300 Subject: [PATCH] Adjust ingest logic --- app/db/tables.ts | 16 +- .../ScannerIngestRepository.server.ts | 750 +++++++++++++----- .../actions/scanner-ingest.server.ts | 263 ++++-- .../scanner-ingest/core/Scoreboards.test.ts | 520 +++++++----- .../scanner-ingest/core/Scoreboards.ts | 448 +++++++---- .../scanner-ingest/scanner-ingest-schemas.ts | 1 - app/features/scanner/README.md | 8 +- migrations/20260804000000-ingest.ts | 58 +- 8 files changed, 1397 insertions(+), 667 deletions(-) diff --git a/app/db/tables.ts b/app/db/tables.ts index fd64b5de7..2d2c96817 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -35,7 +35,6 @@ import type { LFGType } from "~/features/lfg/lfg-constants"; import type { SkillTeamIdentifier } from "~/features/mmr/mmr-utils"; import type { Notification as NotificationValue } from "~/features/notifications/notifications-types"; import type { ScannerMatch } from "~/features/scanner/core/scanner-match"; -import type { IngestedScoreboardData } from "~/features/scanner-ingest/core/Scoreboards"; import type { SplatoonRotationType } from "~/features/splatoon-rotations/splatoon-rotations-constants"; import type { MemberRole, @@ -480,20 +479,25 @@ export interface ReportedWeapon { export interface IngestedMatch { id: GeneratedAlways; - tournamentId: number | null; povUserId: number | null; submitterUserId: number | null; /** database timestamp (seconds) the match was played at, when known */ playedAt: number | null; data: JSONColumnType; matchHash: string; + /** server-resolved tournament the match probably belongs to; aids future linking */ + tournamentIdHint: number | null; + /** server-resolved SendouQ match the match probably belongs to; aids future linking */ + groupMatchIdHint: number | null; createdAt: Generated; } -export interface IngestedScoreboard { +/** Links an ingested match to the game result it describes (exactly one target). */ +export interface IngestedMatchLink { id: GeneratedAlways; - matchGameResultId: number; - data: JSONColumnType; + ingestedMatchId: number; + tournamentMatchGameResultId: number | null; + groupMatchMapId: number | null; createdAt: Generated; } @@ -1272,7 +1276,7 @@ export interface DB { GroupMatchMap: GroupMatchMap; GroupMember: GroupMember; IngestedMatch: IngestedMatch; - IngestedScoreboard: IngestedScoreboard; + IngestedMatchLink: IngestedMatchLink; PrivateUserNote: PrivateUserNote; LogInLink: LogInLink; LFGPost: LFGPost; diff --git a/app/features/scanner-ingest/ScannerIngestRepository.server.ts b/app/features/scanner-ingest/ScannerIngestRepository.server.ts index 67c6103e3..d8afd2fd8 100644 --- a/app/features/scanner-ingest/ScannerIngestRepository.server.ts +++ b/app/features/scanner-ingest/ScannerIngestRepository.server.ts @@ -6,10 +6,11 @@ import type { DB } from "~/db/tables"; import type { ScannerMatch } from "~/features/scanner/core/scanner-match"; import * as Matches from "./core/Matches"; import type { - IngestableGameWithTournament, - IngestedScoreboardData, - MatchedScoreboard, + IngestableGame, + IngestableGameWithContext, + IngestContext, } from "./core/Scoreboards"; +import * as Scoreboards from "./core/Scoreboards"; const opponentOneId = sql`"TournamentMatch"."opponentOne" ->> '$.id'`; const opponentTwoId = sql`"TournamentMatch"."opponentTwo" ->> '$.id'`; @@ -26,42 +27,52 @@ const MERGE_CANDIDATE_LIMIT = 50; /** * Stores ingested matches, merging partials: a match that - * `Matches.isSameMatch` recognizes as an already stored one (same - * tournament + POV user scope) enriches that row instead of inserting. - * Identical resends are no-ops via the content hash. + * `Matches.isSameMatch` recognizes as an already stored one (same POV user + * scope) enriches that row instead of inserting. Identical resends are + * no-ops via the content hash. The resolved context is stamped onto the + * rows as tournamentIdHint/groupMatchIdHint (existing hints win; missing + * ones are backfilled even on no-op resends). * - * @returns counts plus the post-merge matches (a partial arriving after an - * earlier richer send attaches downstream with the merged, fuller data) + * @returns counts plus the post-merge rows (a partial arriving after an + * earlier richer send links downstream with the merged, fuller data) */ export async function addOrMergeMatches({ - tournamentId, povUserId, submitterUserId, matches, + context, }: { - tournamentId: number | null; povUserId: number | null; submitterUserId: number | null; matches: ScannerMatch[]; + context: IngestContext | null; }) { + const hints = { + tournamentIdHint: + context?.type === "tournament" ? context.tournamentId : null, + groupMatchIdHint: context?.type === "sendouq" ? context.groupMatchId : null, + }; + let insertedCount = 0; let mergedCount = 0; - const effectiveMatches: ScannerMatch[] = []; + const effectiveMatches: Array<{ id: number; data: ScannerMatch }> = []; for (const match of matches) { const canonical = Matches.canonicalMatch(match); - const hash = matchHash({ tournamentId, povUserId, match: canonical }); + const hash = matchHash({ povUserId, match: canonical }); const effective = await db.transaction().execute(async (trx) => { const identical = await trx .selectFrom("IngestedMatch") - .select("data") + .select(["id", "data", "tournamentIdHint", "groupMatchIdHint"]) .where("matchHash", "=", hash) .executeTakeFirst(); - if (identical) return identical.data; + if (identical) { + await backfillHints(trx, identical, hints); + return { id: identical.id, data: identical.data }; + } const stored = await findMergeCandidate(trx, { - tournamentId, povUserId, match: canonical, }); @@ -69,23 +80,24 @@ export async function addOrMergeMatches({ const inserted = await trx .insertInto("IngestedMatch") .values({ - tournamentId, povUserId, submitterUserId, playedAt: toDbTimestamp(canonical.playedAt), data: JSON.stringify(canonical), matchHash: hash, + ...hints, }) - .onConflict((oc) => oc.column("matchHash").doNothing()) - .executeTakeFirst(); - if (Number(inserted.numInsertedOrUpdatedRows ?? 0) > 0) { - insertedCount++; - } - return canonical; + .returning("id") + .executeTakeFirstOrThrow(); + insertedCount++; + return { id: inserted.id, data: canonical }; } const { merged, changed } = Matches.mergeMatches(stored.data, canonical); - if (!changed) return stored.data; + if (!changed) { + await backfillHints(trx, stored, hints); + return { id: stored.id, data: stored.data }; + } const mergedCanonical = Matches.canonicalMatch(merged); await trx @@ -93,16 +105,14 @@ export async function addOrMergeMatches({ .set({ playedAt: toDbTimestamp(mergedCanonical.playedAt), data: JSON.stringify(mergedCanonical), - matchHash: matchHash({ - tournamentId, - povUserId, - match: mergedCanonical, - }), + matchHash: matchHash({ povUserId, match: mergedCanonical }), + tournamentIdHint: stored.tournamentIdHint ?? hints.tournamentIdHint, + groupMatchIdHint: stored.groupMatchIdHint ?? hints.groupMatchIdHint, }) .where("id", "=", stored.id) .execute(); mergedCount++; - return mergedCanonical; + return { id: stored.id, data: mergedCanonical }; }); effectiveMatches.push(effective); @@ -111,19 +121,42 @@ export async function addOrMergeMatches({ return { insertedCount, mergedCount, effectiveMatches }; } +async function backfillHints( + trx: Transaction, + stored: { + id: number; + tournamentIdHint: number | null; + groupMatchIdHint: number | null; + }, + hints: { tournamentIdHint: number | null; groupMatchIdHint: number | null }, +) { + const tournamentIdHint = stored.tournamentIdHint ?? hints.tournamentIdHint; + const groupMatchIdHint = stored.groupMatchIdHint ?? hints.groupMatchIdHint; + if ( + tournamentIdHint === stored.tournamentIdHint && + groupMatchIdHint === stored.groupMatchIdHint + ) { + return; + } + + await trx + .updateTable("IngestedMatch") + .set({ tournamentIdHint, groupMatchIdHint }) + .where("id", "=", stored.id) + .execute(); +} + /** * The stored match the incoming one describes the same game as, if any: - * rows in the same tournament + POV user scope, near in play time (or - * recent when either side has none), content-checked by Matches.isSameMatch. + * rows in the same POV user scope, near in play time (or recent when either + * side has none), content-checked by Matches.isSameMatch. */ async function findMergeCandidate( trx: Transaction, { - tournamentId, povUserId, match, }: { - tournamentId: number | null; povUserId: number | null; match: ScannerMatch; }, @@ -135,11 +168,7 @@ async function findMergeCandidate( const candidates = await trx .selectFrom("IngestedMatch") - .select(["id", "data"]) - .$if(tournamentId === null, (qb) => qb.where("tournamentId", "is", null)) - .$if(tournamentId !== null, (qb) => - qb.where("tournamentId", "=", tournamentId!), - ) + .select(["id", "data", "tournamentIdHint", "groupMatchIdHint"]) .$if(povUserId === null, (qb) => qb.where("povUserId", "is", null)) .$if(povUserId !== null, (qb) => qb.where("povUserId", "=", povUserId!)) .$if(match.playedAt !== null, (qb) => @@ -190,16 +219,14 @@ function toDbTimestamp(ms: number | null): number | null { } function matchHash({ - tournamentId, povUserId, match, }: { - tournamentId: number | null; povUserId: number | null; match: ScannerMatch; }) { return createHash("sha256") - .update(JSON.stringify([tournamentId, povUserId, match])) + .update(JSON.stringify([povUserId, match])) .digest("hex"); } @@ -208,38 +235,63 @@ export function gamesPlayedByUserInTournament(params: { userId: number; tournamentId: number; }) { - return gamesPlayedByUser(params); + return tournamentGames(params); } /** * Returns the games a user played in any tournament since the given - * database timestamp, in chronological order — the candidate set for - * content-based tournament resolution (Scoreboards.resolveTournamentId). + * database timestamp, in chronological order — tournament candidates for + * content-based context resolution (Scoreboards.resolveContext). */ export function gamesPlayedByUserSince(params: { userId: number; /** database timestamp (seconds) */ since: number; }) { - return gamesPlayedByUser(params); + return tournamentGames(params); } -async function gamesPlayedByUser({ +/** + * Returns the games of a tournament's casted sets (currently streamed ones + * plus the cast history), in chronological order — the candidate set for + * cast footage, whose submitter is staff rather than a player of the games. + */ +export async function castedGamesInTournament(tournamentId: number) { + const tournament = await db + .selectFrom("Tournament") + .select("castedMatchesInfo") + .where("Tournament.id", "=", tournamentId) + .executeTakeFirst(); + const castedMatchesInfo = tournament?.castedMatchesInfo; + + const tournamentMatchIds = [ + ...new Set([ + ...(castedMatchesInfo?.castedMatches ?? []).map( + (casted) => casted.matchId, + ), + ...(castedMatchesInfo?.castedMatchHistory ?? []).map( + (casted) => casted.matchId, + ), + ]), + ]; + if (tournamentMatchIds.length === 0) return []; + + return tournamentGames({ tournamentId, tournamentMatchIds }); +} + +async function tournamentGames({ userId, tournamentId, + tournamentMatchIds, since, }: { - userId: number; + userId?: number; tournamentId?: number; + tournamentMatchIds?: number[]; since?: number; -}): Promise { +}): Promise { const rows = await db - .selectFrom("TournamentMatchGameResultParticipant") - .innerJoin( - "TournamentMatchGameResult", - "TournamentMatchGameResult.id", - "TournamentMatchGameResultParticipant.matchGameResultId", - ) + .selectFrom("TournamentMatchGameResult") .innerJoin( "TournamentMatch", "TournamentMatch.id", @@ -250,11 +302,6 @@ async function gamesPlayedByUser({ "TournamentStage.id", "TournamentMatch.stageId", ) - .leftJoin( - "IngestedScoreboard", - "IngestedScoreboard.matchGameResultId", - "TournamentMatchGameResult.id", - ) .select([ "TournamentMatchGameResult.id as matchGameResultId", "TournamentMatchGameResult.matchId as tournamentMatchId", @@ -264,14 +311,30 @@ async function gamesPlayedByUser({ "TournamentMatchGameResult.winnerTeamId", "TournamentMatchGameResult.createdAt as playedAt", "TournamentStage.tournamentId", - "IngestedScoreboard.data as storedScoreboardData", opponentOneId.as("opponentOneId"), opponentTwoId.as("opponentTwoId"), ]) - .where("TournamentMatchGameResultParticipant.userId", "=", userId) + .$if(userId !== undefined, (qb) => + qb.where((eb) => + eb.exists( + eb + .selectFrom("TournamentMatchGameResultParticipant") + .select("TournamentMatchGameResultParticipant.userId") + .whereRef( + "TournamentMatchGameResultParticipant.matchGameResultId", + "=", + "TournamentMatchGameResult.id", + ) + .where("TournamentMatchGameResultParticipant.userId", "=", userId!), + ), + ), + ) .$if(tournamentId !== undefined, (qb) => qb.where("TournamentStage.tournamentId", "=", tournamentId!), ) + .$if(tournamentMatchIds !== undefined, (qb) => + qb.where("TournamentMatchGameResult.matchId", "in", tournamentMatchIds!), + ) .$if(since !== undefined, (qb) => qb.where("TournamentMatchGameResult.createdAt", ">=", since!), ) @@ -282,6 +345,10 @@ async function gamesPlayedByUser({ const inGameNamesByTeamId = await teamInGameNames( rows.flatMap((row) => [row.opponentOneId, row.opponentTwoId]), ); + const linkedNames = await linkedPlayerNamesByTarget( + "tournamentMatchGameResultId", + rows.map((row) => row.matchGameResultId), + ); return rows.map((row) => { const loserTeamId = @@ -292,22 +359,22 @@ async function gamesPlayedByUser({ : null; return { - matchGameResultId: row.matchGameResultId, - tournamentMatchId: row.tournamentMatchId, - tournamentId: row.tournamentId, + target: { + type: "tournament", + matchGameResultId: row.matchGameResultId, + tournamentMatchId: row.tournamentMatchId, + }, + context: { type: "tournament", tournamentId: row.tournamentId }, mapIndex: row.number - 1, mode: row.mode, stageId: row.stageId, - winnerTeamId: row.winnerTeamId, - loserTeamId, winnerInGameNames: inGameNamesByTeamId.get(row.winnerTeamId) ?? [], loserInGameNames: (loserTeamId !== null ? inGameNamesByTeamId.get(loserTeamId) : undefined) ?? [], playedAt: row.playedAt, - storedScoreboardPlayerNames: - row.storedScoreboardData?.players.map((player) => player.name) ?? null, + linkedPlayerNames: linkedNames.get(row.matchGameResultId) ?? null, }; }); } @@ -343,6 +410,180 @@ async function teamInGameNames(teamIds: Array) { return result; } +/** Returns a SendouQ match's games (its whole map list), in map order. */ +export function gamesInGroupMatch(groupMatchId: number) { + return sendouqGames({ groupMatchId }); +} + +/** + * Returns the reported games of SendouQ matches a user played in since the + * given database timestamp, in chronological order — SendouQ candidates for + * content-based context resolution (Scoreboards.resolveContext). + */ +export function sendouqGamesPlayedByUserSince(params: { + userId: number; + /** database timestamp (seconds) */ + since: number; +}) { + return sendouqGames(params); +} + +async function sendouqGames({ + groupMatchId, + userId, + since, +}: { + groupMatchId?: number; + userId?: number; + since?: number; +}): Promise { + const rows = await db + .selectFrom("GroupMatchMap") + .innerJoin("GroupMatch", "GroupMatch.id", "GroupMatchMap.matchId") + .select([ + "GroupMatchMap.id as groupMatchMapId", + "GroupMatchMap.matchId as groupMatchId", + "GroupMatchMap.index as mapIndex", + "GroupMatchMap.mode", + "GroupMatchMap.stageId", + "GroupMatchMap.winnerGroupId", + "GroupMatch.alphaGroupId", + "GroupMatch.bravoGroupId", + "GroupMatch.createdAt as playedAt", + ]) + .$if(groupMatchId !== undefined, (qb) => + qb.where("GroupMatchMap.matchId", "=", groupMatchId!), + ) + .$if(userId !== undefined, (qb) => + qb.where((eb) => + eb.exists( + eb + .selectFrom("GroupMember") + .select("GroupMember.userId") + .where("GroupMember.userId", "=", userId!) + .where((memberEb) => + memberEb.or([ + memberEb( + "GroupMember.groupId", + "=", + memberEb.ref("GroupMatch.alphaGroupId"), + ), + memberEb( + "GroupMember.groupId", + "=", + memberEb.ref("GroupMatch.bravoGroupId"), + ), + ]), + ), + ), + ), + ) + // content resolution walks played games only; a current match's + // pre-generated unplayed maps would flood the candidate sequence + .$if(since !== undefined, (qb) => + qb + .where("GroupMatch.createdAt", ">=", since!) + .where("GroupMatchMap.winnerGroupId", "is not", null), + ) + .orderBy("GroupMatch.createdAt", "asc") + .orderBy("GroupMatchMap.index", "asc") + .execute(); + + const inGameNamesByGroupId = await groupInGameNames( + rows.flatMap((row) => [row.alphaGroupId, row.bravoGroupId]), + ); + const linkedNames = await linkedPlayerNamesByTarget( + "groupMatchMapId", + rows.map((row) => row.groupMatchMapId), + ); + + return rows.map((row) => { + const loserGroupId = + row.winnerGroupId === row.alphaGroupId + ? row.bravoGroupId + : row.winnerGroupId === row.bravoGroupId + ? row.alphaGroupId + : null; + + return { + target: { + type: "sendouq", + groupMatchMapId: row.groupMatchMapId, + groupMatchId: row.groupMatchId, + }, + context: { type: "sendouq", groupMatchId: row.groupMatchId }, + mapIndex: row.mapIndex, + mode: row.mode, + stageId: row.stageId, + winnerInGameNames: + (row.winnerGroupId !== null + ? inGameNamesByGroupId.get(row.winnerGroupId) + : undefined) ?? [], + loserInGameNames: + (loserGroupId !== null + ? inGameNamesByGroupId.get(loserGroupId) + : undefined) ?? [], + playedAt: row.playedAt, + linkedPlayerNames: linkedNames.get(row.groupMatchMapId) ?? null, + }; + }); +} + +async function groupInGameNames(groupIds: number[]) { + const uniqueGroupIds = [...new Set(groupIds)]; + if (uniqueGroupIds.length === 0) return new Map(); + + const members = await db + .selectFrom("GroupMember") + .innerJoin("User", "User.id", "GroupMember.userId") + .select(["GroupMember.groupId", "User.inGameName"]) + .where("GroupMember.groupId", "in", uniqueGroupIds) + .execute(); + + const result = new Map(); + for (const member of members) { + if (!member.inGameName) continue; + const names = result.get(member.groupId) ?? []; + names.push(member.inGameName); + result.set(member.groupId, names); + } + + return result; +} + +/** + * The winner-first player names of each game's earliest linked ingested + * match, keyed by the given link target column's value. + */ +async function linkedPlayerNamesByTarget( + column: "tournamentMatchGameResultId" | "groupMatchMapId", + targetIds: number[], +) { + const result = new Map(); + if (targetIds.length === 0) return result; + + const rows = await db + .selectFrom("IngestedMatchLink") + .innerJoin( + "IngestedMatch", + "IngestedMatch.id", + "IngestedMatchLink.ingestedMatchId", + ) + .select([`IngestedMatchLink.${column} as targetId`, "IngestedMatch.data"]) + .where(`IngestedMatchLink.${column}`, "in", targetIds) + .orderBy("IngestedMatchLink.createdAt", "asc") + .orderBy("IngestedMatchLink.id", "asc") + .execute(); + + for (const row of rows) { + if (row.targetId === null || result.has(row.targetId)) continue; + const names = Scoreboards.winnerFirstPlayerNames(row.data); + if (names) result.set(row.targetId, names); + } + + return result; +} + /** How long before the events' timestamp their match may have started (long sets, swiss rounds get startedAt at creation). */ const MATCH_WINDOW_BEFORE_SECONDS = 4 * 60 * 60; /** Event timestamps come from client clocks, so allow the match to have "started" a little after them. */ @@ -405,157 +646,296 @@ export async function tournamentIdAt({ return row?.tournamentId ?? null; } -/** Returns the tournament's start time as a database timestamp. */ -export async function tournamentStartTime(tournamentId: number) { +/** SendouQ sets run well under this long; matches created further before the events cannot be theirs. */ +const GROUP_MATCH_WINDOW_BEFORE_SECONDS = 2 * 60 * 60; +/** Event timestamps come from client clocks, so allow the match to have been created a little after them. */ +const GROUP_MATCH_WINDOW_AFTER_SECONDS = 60 * 60; + +/** + * The SendouQ match the user was (probably) playing at the given wall-clock + * time: a group they are a member of is in a non-canceled match created + * close enough before `at`. When several qualify the latest-created wins. + */ +export async function groupMatchIdAt({ + userId, + at, +}: { + userId: number; + /** wall-clock ms */ + at: number; +}) { + const atSeconds = Math.floor(at / 1000); + const row = await db - .selectFrom("CalendarEvent") - .innerJoin( - "CalendarEventDate", - "CalendarEventDate.eventId", - "CalendarEvent.id", + .selectFrom("GroupMatch") + .select("GroupMatch.id") + .where((eb) => + eb.exists( + eb + .selectFrom("GroupMember") + .select("GroupMember.userId") + .where("GroupMember.userId", "=", userId) + .where((memberEb) => + memberEb.or([ + memberEb( + "GroupMember.groupId", + "=", + memberEb.ref("GroupMatch.alphaGroupId"), + ), + memberEb( + "GroupMember.groupId", + "=", + memberEb.ref("GroupMatch.bravoGroupId"), + ), + ]), + ), + ), ) - .select(({ fn }) => fn.min("CalendarEventDate.startsAt").as("startTime")) - .where("CalendarEvent.tournamentId", "=", tournamentId) + .where( + "GroupMatch.createdAt", + "<=", + atSeconds + GROUP_MATCH_WINDOW_AFTER_SECONDS, + ) + .where( + "GroupMatch.createdAt", + ">=", + atSeconds - GROUP_MATCH_WINDOW_BEFORE_SECONDS, + ) + .where("GroupMatch.cancelAcceptedByUserId", "is", null) + .orderBy("GroupMatch.createdAt", "desc") .executeTakeFirst(); - return row?.startTime ?? null; + return row?.id ?? null; } /** - * Stores matched scoreboards. A game that already has a stored scoreboard - * keeps it (first ingest wins). When the scoreboard's POV player is known - * (via povIndex + povUserId), their row is attributed to the user and their - * weapon is reported as a regular ReportedWeapon, unless the user already - * has one for that game. - * - * @returns count of newly stored scoreboards + * Tournaments running a match around the given wall-clock time that the + * user helps run: they authored the event, are on its staff (organizer or + * streamer), or hold an admin/organizer/streamer role in its organization. + * The candidate contexts for cast footage. */ -export async function addScoreboards({ - scoreboards, - povUserId, +export async function staffTournamentIdsAt({ + userId, + at, }: { - scoreboards: MatchedScoreboard[]; - povUserId: number | null; -}) { - let storedCount = 0; + userId: number; + /** wall-clock ms */ + at: number; +}): Promise { + const atSeconds = Math.floor(at / 1000); - for (const scoreboard of scoreboards) { - const wasInserted = await db.transaction().execute(async (trx) => { - const povPlayer = - povUserId !== null && scoreboard.povIndex !== null - ? scoreboard.data.players[scoreboard.povIndex] - : undefined; + const rows = await db + .selectFrom("TournamentMatch") + .innerJoin( + "TournamentStage", + "TournamentStage.id", + "TournamentMatch.stageId", + ) + .innerJoin( + "CalendarEvent", + "CalendarEvent.tournamentId", + "TournamentStage.tournamentId", + ) + .select("TournamentStage.tournamentId") + .distinct() + .where( + "TournamentMatch.startedAt", + "<=", + atSeconds + MATCH_WINDOW_AFTER_SECONDS, + ) + .where( + "TournamentMatch.startedAt", + ">=", + atSeconds - MATCH_WINDOW_BEFORE_SECONDS, + ) + .where((eb) => + eb.or([ + eb("CalendarEvent.authorId", "=", userId), + eb.exists( + eb + .selectFrom("TournamentStaff") + .select("TournamentStaff.userId") + .whereRef( + "TournamentStaff.tournamentId", + "=", + "TournamentStage.tournamentId", + ) + .where("TournamentStaff.userId", "=", userId), + ), + eb.exists( + eb + .selectFrom("TournamentOrganizationMember") + .select("TournamentOrganizationMember.userId") + .whereRef( + "TournamentOrganizationMember.organizationId", + "=", + "CalendarEvent.organizationId", + ) + .where("TournamentOrganizationMember.userId", "=", userId) + .where("TournamentOrganizationMember.role", "in", [ + "ADMIN", + "ORGANIZER", + "STREAMER", + ]), + ), + ]), + ) + .execute(); - const data: IngestedScoreboardData = povPlayer - ? { - ...scoreboard.data, - players: scoreboard.data.players.map((player, playerIdx) => - playerIdx === scoreboard.povIndex - ? { ...player, userId: povUserId! } - : player, - ), - } - : scoreboard.data; - - const insertResult = await trx - .insertInto("IngestedScoreboard") - .values({ - matchGameResultId: scoreboard.matchGameResultId, - data: JSON.stringify(data), - }) - .onConflict((oc) => oc.column("matchGameResultId").doNothing()) - .executeTakeFirst(); - const inserted = Number(insertResult.numInsertedOrUpdatedRows ?? 0) > 0; - - if (!povPlayer) return inserted; - - if (!inserted) { - await attributePovUser({ - trx, - matchGameResultId: scoreboard.matchGameResultId, - povIndex: scoreboard.povIndex!, - userId: povUserId!, - }); - } - - if (povPlayer.weaponSplId !== null) { - await trx - .insertInto("ReportedWeapon") - .values({ - tournamentMatchId: scoreboard.tournamentMatchId, - mapIndex: scoreboard.mapIndex, - userId: povUserId!, - weaponSplId: povPlayer.weaponSplId, - }) - .onConflict((oc) => - oc.columns(["tournamentMatchId", "mapIndex", "userId"]).doNothing(), - ) - .execute(); - } - - return inserted; - }); - - if (wasInserted) storedCount++; - } - - return storedCount; + return rows.map((row) => row.tournamentId); } -async function attributePovUser({ - trx, - matchGameResultId, - povIndex, - userId, +/** + * Links ingested matches to the game results they were matched to. A row + * links to at most one game (re-sends are no-ops); one game may collect + * links from many rows (each POV's scan of it). When the row's POV player + * is known, their weapon is reported as a regular ReportedWeapon, unless + * the user already has one for that game. + * + * @returns count of newly created links + */ +export async function addLinks({ + links, + povUserId, }: { - trx: Transaction; - matchGameResultId: number; - povIndex: number; - userId: number; + links: Array<{ + ingestedMatchId: number; + match: ScannerMatch; + game: IngestableGame; + }>; + povUserId: number | null; }) { - const existing = await trx - .selectFrom("IngestedScoreboard") - .select(["id", "data"]) - .where("matchGameResultId", "=", matchGameResultId) - .executeTakeFirst(); - if (!existing) return; + let linkedCount = 0; - if (existing.data.players.some((player) => player.userId === userId)) { - return; + for (const link of links) { + const wasInserted = await db.transaction().execute(async (trx) => { + const insertResult = await trx + .insertInto("IngestedMatchLink") + .values({ + ingestedMatchId: link.ingestedMatchId, + tournamentMatchGameResultId: + link.game.target.type === "tournament" + ? link.game.target.matchGameResultId + : null, + groupMatchMapId: + link.game.target.type === "sendouq" + ? link.game.target.groupMatchMapId + : null, + }) + .onConflict((oc) => oc.column("ingestedMatchId").doNothing()) + .executeTakeFirst(); + + await reportPovWeapon(trx, link, povUserId); + + return Number(insertResult.numInsertedOrUpdatedRows ?? 0) > 0; + }); + + if (wasInserted) linkedCount++; } - const player = existing.data.players[povIndex]; - if (!player || player.userId !== undefined) return; + return linkedCount; +} - const players = existing.data.players.map((other, playerIdx) => - playerIdx === povIndex ? { ...other, userId } : other, - ); +async function reportPovWeapon( + trx: Transaction, + { match, game }: { 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 (weaponSplId === null) return; await trx - .updateTable("IngestedScoreboard") - .set({ data: JSON.stringify({ ...existing.data, players }) }) - .where("id", "=", existing.id) + .insertInto("ReportedWeapon") + .values({ + tournamentMatchId: + game.target.type === "tournament" + ? game.target.tournamentMatchId + : null, + groupMatchId: + game.target.type === "sendouq" ? game.target.groupMatchId : null, + mapIndex: game.mapIndex, + userId: povUserId, + weaponSplId, + }) + .onConflict((oc) => + oc + .columns( + game.target.type === "tournament" + ? ["tournamentMatchId", "mapIndex", "userId"] + : ["groupMatchId", "mapIndex", "userId"], + ) + .doNothing(), + ) .execute(); } -/** Returns a tournament match's ingested scoreboards with their 0-based map indexes. */ +/** + * Returns a tournament match's ingested scoreboards with their 0-based map + * indexes, each derived from the game's linked ingested matches. + */ export async function findScoreboardsByTournamentMatchId( tournamentMatchId: number, ) { const rows = await db - .selectFrom("IngestedScoreboard") + .selectFrom("IngestedMatchLink") + .innerJoin( + "IngestedMatch", + "IngestedMatch.id", + "IngestedMatchLink.ingestedMatchId", + ) .innerJoin( "TournamentMatchGameResult", "TournamentMatchGameResult.id", - "IngestedScoreboard.matchGameResultId", + "IngestedMatchLink.tournamentMatchGameResultId", ) - .select(["TournamentMatchGameResult.number", "IngestedScoreboard.data"]) + .innerJoin( + "TournamentMatch", + "TournamentMatch.id", + "TournamentMatchGameResult.matchId", + ) + .select([ + "TournamentMatchGameResult.id as matchGameResultId", + "TournamentMatchGameResult.number", + "TournamentMatchGameResult.winnerTeamId", + opponentOneId.as("opponentOneId"), + opponentTwoId.as("opponentTwoId"), + "IngestedMatch.data", + "IngestedMatch.povUserId", + ]) .where("TournamentMatchGameResult.matchId", "=", tournamentMatchId) .orderBy("TournamentMatchGameResult.number", "asc") + .orderBy("IngestedMatchLink.createdAt", "asc") + .orderBy("IngestedMatchLink.id", "asc") .execute(); - return rows.map((row) => ({ - mapIndex: row.number - 1, - data: row.data, - })); + const byGame = new Map(); + for (const row of rows) { + const gameRows = byGame.get(row.matchGameResultId) ?? []; + gameRows.push(row); + byGame.set(row.matchGameResultId, gameRows); + } + + return [...byGame.values()].flatMap((gameRows) => { + const first = gameRows[0]!; + const loserTeamId = + first.winnerTeamId === first.opponentOneId + ? first.opponentTwoId + : first.winnerTeamId === first.opponentTwoId + ? first.opponentOneId + : null; + + const data = Scoreboards.deriveScoreboardData({ + linked: gameRows.map((row) => ({ + data: row.data, + povUserId: row.povUserId, + })), + winnerTeamId: first.winnerTeamId, + loserTeamId, + }); + if (!data) return []; + + return [{ mapIndex: first.number - 1, data }]; + }); } diff --git a/app/features/scanner-ingest/actions/scanner-ingest.server.ts b/app/features/scanner-ingest/actions/scanner-ingest.server.ts index 552670127..17fe4d0e1 100644 --- a/app/features/scanner-ingest/actions/scanner-ingest.server.ts +++ b/app/features/scanner-ingest/actions/scanner-ingest.server.ts @@ -1,3 +1,4 @@ +import { subDays } from "date-fns"; import type { ActionFunction } from "react-router"; import { Config } from "~/config"; import { requireUser } from "~/features/auth/core/user.server"; @@ -29,114 +30,220 @@ export const action: ActionFunction = async ({ request }) => { badRequestIfFalsy(await UserRepository.findLeanById(povUserId)); } - // xxx: also pass if the ingestion is live footage, if so then check users current activity and use that info instead (can/should also be persisted?) - let tournamentId = data.tournamentId ?? null; - // the resolving content walk's candidate games, kept so the scoreboard - // matching below doesn't re-query them - let candidateGames: Scoreboards.IngestableGameWithTournament[] | null = null; - if (tournamentId) { - badRequestIfFalsy( - await ScannerIngestRepository.tournamentStartTime(tournamentId), - ); - } else if (povUserId) { - // no explicit tournament: resolve from the matches' content first (the - // mode+stage sequence plus roster sides is near-unique in a user's - // history), then from when the match was played (a replay scoreboard - // carries the original recording time). Single-match requests (live - // sends) skip straight to the timestamp — content resolution needs a - // sequence to be decisive. - if (countAttachableMatches(data.matches) >= 2) { - const games = await ScannerIngestRepository.gamesPlayedByUserSince({ - userId: povUserId, - since: - // xxx: use date-fns - Math.floor(Date.now() / 1000) - CONTENT_RESOLUTION_WINDOW_SECONDS, - }); - tournamentId = Scoreboards.resolveTournamentId({ - matches: data.matches, - games, - }); - if (tournamentId) { - candidateGames = games; - logger.debug( - `ingest: resolved tournament ${tournamentId} for user ${povUserId} from match contents ` + - `(${games.length} candidate games)`, - ); - } - } - if (!tournamentId) { - const at = anchorTime(data.matches); - tournamentId = await ScannerIngestRepository.tournamentIdAt({ - userId: povUserId, - at, - }); - logger.debug( - tournamentId - ? `ingest: resolved tournament ${tournamentId} for user ${povUserId} from timestamp ${new Date(at).toISOString()}` - : `ingest: no tournament for user ${povUserId} at ${new Date(at).toISOString()} (no tournament match of theirs started around then)`, - ); - } + const matches = data.matches.filter( + (match) => match.lobby === null || match.lobby === "PRIVATE", + ); + if (matches.length === 0) { + return { + storedMatchesCount: 0, + mergedMatchesCount: 0, + linkedGamesCount: 0, + }; } + const resolved = await resolveIngestContext({ + matches, + povUserId, + casterUserId: user?.id ?? null, + }); + const { insertedCount, mergedCount, effectiveMatches } = await ScannerIngestRepository.addOrMergeMatches({ - tournamentId, povUserId, submitterUserId: user?.id ?? null, - matches: data.matches, + matches, + context: resolved?.context ?? null, }); - let storedScoreboardsCount = 0; - if (tournamentId && povUserId) { - const resolvedTournamentId = tournamentId; - const games = candidateGames - ? candidateGames.filter( - (game) => game.tournamentId === resolvedTournamentId, - ) - : await ScannerIngestRepository.gamesPlayedByUserInTournament({ - userId: povUserId, - tournamentId, - }); - - const matched = Scoreboards.matchedScoreboards({ - matches: effectiveMatches, - games, + let linkedGamesCount = 0; + if (resolved) { + const matched = Scoreboards.matchedGames({ + matches: effectiveMatches.map((effective) => effective.data), + games: resolved.games, }); - storedScoreboardsCount = await ScannerIngestRepository.addScoreboards({ - scoreboards: matched, + linkedGamesCount = await ScannerIngestRepository.addLinks({ + links: matched.map(({ matchIndex, game }) => ({ + ingestedMatchId: effectiveMatches[matchIndex]!.id, + match: effectiveMatches[matchIndex]!.data, + game, + })), povUserId, }); logger.debug( - matched.length > 0 - ? `ingest: matched ${matched.length} scoreboards in tournament ${tournamentId} to ` + - `[${matched.map((m) => `match ${m.tournamentMatchId} map ${m.mapIndex + 1}`).join(", ")}], ` + - `${storedScoreboardsCount} newly stored` - : `ingest: no scoreboards matched in tournament ${tournamentId} — user ${povUserId} has ` + - `${games.length} reported games there`, + `ingest: ${Scoreboards.contextKey(resolved.context)} matched ${matched.length} games, ` + + `${linkedGamesCount} newly linked (stored ${insertedCount}, merged ${mergedCount})`, ); } else { logger.debug( - `ingest: stored ${insertedCount} matches (${mergedCount} merged) without a match context ` + - `(tournamentId=${tournamentId}, povUserId=${povUserId})`, + `ingest: stored ${insertedCount} matches (${mergedCount} merged) without a resolved context ` + + `(povUserId=${povUserId})`, ); } return { storedMatchesCount: insertedCount, mergedMatchesCount: mergedCount, - storedScoreboardsCount, + linkedGamesCount, }; }; /** * How far back the POV user's reported games are considered as content- - * resolution candidates (365 days) + * resolution candidates */ -const CONTENT_RESOLUTION_WINDOW_SECONDS = 365 * 24 * 60 * 60; +const CONTENT_RESOLUTION_WINDOW_DAYS = 365; -/** Matches that could attach to a tournament game: their winner is known. */ +interface ResolvedIngestContext { + context: Scoreboards.IngestContext; + games: Scoreboards.IngestableGameWithContext[]; +} + +interface IngestContextCandidate { + context: Scoreboards.IngestContext; + loadGames: () => Promise; +} + +/** + * Resolves the context (tournament or SendouQ match) a request's matches + * belong to. + * + * The user's activity around the time the matches were played is the strong + * signal: the SendouQ match resp. tournament match of theirs running then + * (for cast footage, the casted sets of tournaments the submitter helps + * run as author/organizer/streamer). Candidates are scored by how many + * matches would link to their games; a candidate is kept even when nothing links + * yet (a live minimap-only match still gets its hint). With no activity, + * the matches' content decides: the mode+stage sequence plus roster sides + * is near-unique in a user's reported-game history. + */ +async function resolveIngestContext({ + matches, + povUserId, + casterUserId, +}: { + matches: ScannerMatch[]; + povUserId: number | null; + casterUserId: number | null; +}): Promise { + const at = anchorTime(matches); + const hasPovMatches = matches.some((match) => !match.cast); + const hasCastMatches = matches.some((match) => match.cast); + + const candidates: IngestContextCandidate[] = []; + const seenContexts = new Set(); + const addCandidate = (candidate: IngestContextCandidate) => { + const key = Scoreboards.contextKey(candidate.context); + if (seenContexts.has(key)) return; + seenContexts.add(key); + candidates.push(candidate); + }; + + if (povUserId && hasPovMatches) { + const groupMatchId = await ScannerIngestRepository.groupMatchIdAt({ + userId: povUserId, + at, + }); + if (groupMatchId) { + addCandidate({ + context: { type: "sendouq", groupMatchId }, + loadGames: () => + ScannerIngestRepository.gamesInGroupMatch(groupMatchId), + }); + } + + const tournamentId = await ScannerIngestRepository.tournamentIdAt({ + userId: povUserId, + at, + }); + if (tournamentId) { + addCandidate({ + context: { type: "tournament", tournamentId }, + loadGames: () => + ScannerIngestRepository.gamesPlayedByUserInTournament({ + userId: povUserId, + tournamentId, + }), + }); + } + } + + if (casterUserId && hasCastMatches) { + const staffTournamentIds = + await ScannerIngestRepository.staffTournamentIdsAt({ + userId: casterUserId, + at, + }); + for (const tournamentId of staffTournamentIds) { + addCandidate({ + context: { type: "tournament", tournamentId }, + loadGames: () => + ScannerIngestRepository.castedGamesInTournament(tournamentId), + }); + } + } + + let best: { + candidate: IngestContextCandidate; + games: Scoreboards.IngestableGameWithContext[]; + matched: number; + } | null = null; + for (const candidate of candidates) { + const games = await candidate.loadGames(); + const matched = Scoreboards.matchedGames({ matches, games }).length; + if (!best || matched > best.matched) { + best = { candidate, games, matched }; + } + } + if (best) { + logger.debug( + `ingest: resolved ${Scoreboards.contextKey(best.candidate.context)} for user ${povUserId} ` + + `from activity at ${new Date(at).toISOString()} (${best.matched} matches aligned, ${candidates.length} candidates)`, + ); + return { + context: best.candidate.context, + games: best.games, + }; + } + + if (povUserId && hasPovMatches && countAttachableMatches(matches) >= 2) { + const since = Math.floor( + subDays(new Date(), CONTENT_RESOLUTION_WINDOW_DAYS).getTime() / 1000, + ); + const games = [ + ...(await ScannerIngestRepository.gamesPlayedByUserSince({ + userId: povUserId, + since, + })), + ...(await ScannerIngestRepository.sendouqGamesPlayedByUserSince({ + userId: povUserId, + since, + })), + ]; + const context = Scoreboards.resolveContext({ matches, games }); + if (context) { + const key = Scoreboards.contextKey(context); + logger.debug( + `ingest: resolved ${key} for user ${povUserId} from match contents ` + + `(${games.length} candidate games)`, + ); + return { + context, + games: games.filter( + (game) => Scoreboards.contextKey(game.context) === key, + ), + }; + } + } + + logger.debug( + `ingest: no context for user ${povUserId} at ${new Date(at).toISOString()}`, + ); + return null; +} + +/** Matches that could link to a reported game: their winner is known. */ function countAttachableMatches(matches: ScannerMatch[]): number { return matches.filter((match) => match.winner !== null).length; } diff --git a/app/features/scanner-ingest/core/Scoreboards.test.ts b/app/features/scanner-ingest/core/Scoreboards.test.ts index 7213983f9..4376d791c 100644 --- a/app/features/scanner-ingest/core/Scoreboards.test.ts +++ b/app/features/scanner-ingest/core/Scoreboards.test.ts @@ -19,24 +19,37 @@ const WINNER_TEAM_ID = 100; const LOSER_TEAM_ID = 200; function testGame( - partial: Partial = {}, + partial: Partial & { + matchGameResultId?: number; + tournamentMatchId?: number; + } = {}, ): Scoreboards.IngestableGame { + const { matchGameResultId = 11, tournamentMatchId = 1, ...rest } = partial; return { - matchGameResultId: 11, - tournamentMatchId: 1, + target: { type: "tournament", matchGameResultId, tournamentMatchId }, mapIndex: 0, mode: "SZ", stageId: 0 as StageId, - winnerTeamId: WINNER_TEAM_ID, - loserTeamId: LOSER_TEAM_ID, winnerInGameNames: [], loserInGameNames: [], playedAt: 1000, - storedScoreboardPlayerNames: null, - ...partial, + linkedPlayerNames: null, + ...rest, }; } +function gameResultId(matched: Scoreboards.MatchedGame): number | null { + return matched.game.target.type === "tournament" + ? matched.game.target.matchGameResultId + : null; +} + +function tournamentMatchIdOf(matched: Scoreboards.MatchedGame): number | null { + return matched.game.target.type === "tournament" + ? matched.game.target.tournamentMatchId + : null; +} + function testMatch({ t = 60, mode = "SZ", @@ -140,190 +153,85 @@ function swapSides(match: ScannerMatch): ScannerMatch { }; } -describe("matchedScoreboards", () => { - it("turns a matching game's match into stored scoreboard data", () => { - const scoreboards = Scoreboards.matchedScoreboards({ - matches: [testMatch({ povIndex: 2 })], - games: [testGame()], - }); - - expect(scoreboards).toHaveLength(1); - expect(scoreboards[0]).toEqual({ - matchGameResultId: 11, - tournamentMatchId: 1, - mapIndex: 0, - povIndex: 2, - data: { - scores: [100, 52], - players: ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"].map( - (name, i) => ({ - name, - tournamentTeamId: i < 4 ? WINNER_TEAM_ID : LOSER_TEAM_ID, - weaponSplId: i < 4 ? 10 : 20, - ka: 10, - d: 5, - s: 2, - paint: 1000, - }), - ), - }, - }); - }); - - it("a winner-1 match stores identically to its winner-0 mirror", () => { - const straight = Scoreboards.matchedScoreboards({ - matches: [testMatch({ povIndex: 6 })], - games: [testGame()], - }); - const swapped = Scoreboards.matchedScoreboards({ - matches: [swapSides(testMatch({ povIndex: 6 }))], - games: [testGame()], - }); - - expect(swapped).toEqual(straight); - expect(swapped[0]!.povIndex).toBe(6); - }); - - it("stores counter samples rebased to the game's first read", () => { - const scoreboards = Scoreboards.matchedScoreboards({ - matches: [testMatch({ objective: testObjective() })], - games: [testGame()], - }); - - expect(scoreboards[0]!.data.objective).toEqual({ - mode: "SZ", - samples: [ - { - t: 0, - time: 300, - score: [100, 100], - penalty: [null, null], - control: [false, false], - }, - { - t: 30, - time: 270, - score: [80, 100], - penalty: [null, 12], - control: [true, false], - }, - ], - }); - }); - - it("stores counter samples winner-first", () => { - const straight = Scoreboards.matchedScoreboards({ - matches: [testMatch({ objective: testObjective() })], - games: [testGame()], - }); - const swapped = Scoreboards.matchedScoreboards({ - matches: [swapSides(testMatch({ objective: testObjective() }))], - games: [testGame()], - }); - - expect(swapped[0]!.data.objective).toEqual(straight[0]!.data.objective); - }); - - it("leaves out the objective of a match with no counter reads", () => { - const scoreboards = Scoreboards.matchedScoreboards({ +describe("matchedGames", () => { + it("matches a game's match and reports its index", () => { + const matched = Scoreboards.matchedGames({ matches: [testMatch()], games: [testGame()], }); - expect(scoreboards[0]!.data.objective).toBeUndefined(); + expect(matched).toHaveLength(1); + expect(matched[0]!.matchIndex).toBe(0); + expect(gameResultId(matched[0]!)).toBe(11); }); it("skips matches without a known winner", () => { - const scoreboards = Scoreboards.matchedScoreboards({ + const matched = Scoreboards.matchedGames({ matches: [{ ...testMatch(), winner: null }], games: [testGame()], }); - expect(scoreboards).toHaveLength(0); + expect(matched).toHaveLength(0); }); it("skips matches whose teams were not fully seen", () => { const partial = testMatch(); partial.teams[1].players.pop(); - const scoreboards = Scoreboards.matchedScoreboards({ + const matched = Scoreboards.matchedGames({ matches: [partial], games: [testGame()], }); - expect(scoreboards).toHaveLength(0); + expect(matched).toHaveLength(0); }); - it("carries ingested player abilities through to the stored scoreboard", () => { - const build: ScannerAbility[][] = [ - ["ISM", "ISS", "ISS", "ISS"], - ["QR", "QSJ", "QSJ", "QSJ"], - ["SSU", "RSU", "RSU", "RSU"], - ]; - const scoreboards = Scoreboards.matchedScoreboards({ - matches: [testMatch({ abilities: { 5: build } })], - games: [testGame()], - }); - - expect(scoreboards[0]!.data.players[5]!.abilities).toEqual(build); - expect(scoreboards[0]!.data.players[0]!.abilities).toBeUndefined(); - }); - - it("skips a game whose stored scoreboard has different players", () => { - const scoreboards = Scoreboards.matchedScoreboards({ + it("skips a game whose linked scoreboard has different players", () => { + const matched = Scoreboards.matchedGames({ matches: [testMatch()], games: [ testGame({ matchGameResultId: 11, - storedScoreboardPlayerNames: ["a", "b", "c", "d", "e", "f", "g", "h"], + linkedPlayerNames: ["a", "b", "c", "d", "e", "f", "g", "h"], }), testGame({ matchGameResultId: 12, playedAt: 2000 }), ], }); - expect(scoreboards.map((s) => s.matchGameResultId)).toEqual([12]); + expect(matched.map(gameResultId)).toEqual([12]); }); - it("matches a re-detection of a stored scoreboard to the same game despite misread names", () => { - const scoreboards = Scoreboards.matchedScoreboards({ + it("matches a re-detection of a linked scoreboard to the same game despite misread names", () => { + const matched = Scoreboards.matchedGames({ matches: [testMatch()], games: [ testGame({ matchGameResultId: 11, - storedScoreboardPlayerNames: [ - "w1", - "w2", - "w3", - "wA", - "l1", - "l2", - "l3", - "lB", - ], + linkedPlayerNames: ["w1", "w2", "w3", "wA", "l1", "l2", "l3", "lB"], }), testGame({ matchGameResultId: 12, playedAt: 2000 }), ], }); - expect(scoreboards.map((s) => s.matchGameResultId)).toEqual([11]); + expect(matched.map(gameResultId)).toEqual([11]); }); - it("does not count unreadable names towards stored scoreboard re-detection", () => { - const scoreboards = Scoreboards.matchedScoreboards({ + it("does not count unreadable names towards linked scoreboard re-detection", () => { + const matched = Scoreboards.matchedGames({ matches: [testMatch({ names: ["", "", "", "", "l1", "l2", "l3", "l4"] })], games: [ testGame({ matchGameResultId: 11, - storedScoreboardPlayerNames: ["", "", "", "", "l1", "l2", "l3", "l4"], + linkedPlayerNames: ["", "", "", "", "l1", "l2", "l3", "l4"], }), testGame({ matchGameResultId: 12, playedAt: 2000 }), ], }); - expect(scoreboards.map((s) => s.matchGameResultId)).toEqual([12]); + expect(matched.map(gameResultId)).toEqual([12]); }); it("matches matches to games by mode and stage", () => { - const scoreboards = Scoreboards.matchedScoreboards({ + const matched = Scoreboards.matchedGames({ matches: [testMatch({ mode: "RM", stage: 1, t: 60 })], games: [ testGame({ mapIndex: 0, mode: "SZ", stageId: 0 as StageId }), @@ -331,11 +239,11 @@ describe("matchedScoreboards", () => { ], }); - expect(scoreboards.map((s) => s.mapIndex)).toEqual([1]); + expect(matched.map((m) => m.game.mapIndex)).toEqual([1]); }); it("assigns two games on the same mode and stage in chronological order", () => { - const scoreboards = Scoreboards.matchedScoreboards({ + const matched = Scoreboards.matchedGames({ matches: [ testMatch({ t: 60, @@ -352,18 +260,14 @@ describe("matchedScoreboards", () => { ], }); - expect( - scoreboards.find((s) => s.data.players[0]!.name === "a") - ?.tournamentMatchId, - ).toBe(1); - expect( - scoreboards.find((s) => s.data.players[0]!.name === "i") - ?.tournamentMatchId, - ).toBe(2); + expect(matched.map((m) => [m.matchIndex, tournamentMatchIdOf(m)])).toEqual([ + [0, 1], + [1, 2], + ]); }); it("skips duplicate detections of the same game", () => { - const scoreboards = Scoreboards.matchedScoreboards({ + const matched = Scoreboards.matchedGames({ matches: [testMatch({ t: 60 }), testMatch({ t: 65 })], games: [ testGame({ tournamentMatchId: 1, playedAt: 1000 }), @@ -371,49 +275,30 @@ describe("matchedScoreboards", () => { ], }); - expect(scoreboards).toHaveLength(1); - expect(scoreboards[0]!.tournamentMatchId).toBe(1); + expect(matched).toHaveLength(1); + expect(tournamentMatchIdOf(matched[0]!)).toBe(1); }); it("skips matches from other lobbies", () => { - const scoreboards = Scoreboards.matchedScoreboards({ + const matched = Scoreboards.matchedGames({ matches: [testMatch({ lobby: "X" })], games: [testGame()], }); - expect(scoreboards).toHaveLength(0); + expect(matched).toHaveLength(0); }); it("skips matches with unreadable mode or stage", () => { - const scoreboards = Scoreboards.matchedScoreboards({ + const matched = Scoreboards.matchedGames({ matches: [testMatch({ mode: null }), testMatch({ stage: null })], games: [testGame()], }); - expect(scoreboards).toHaveLength(0); - }); - - it("keeps players with unread weapon or empty name", () => { - const scoreboards = Scoreboards.matchedScoreboards({ - matches: [ - testMatch({ - names: ["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"], - weapons: [10, 10, null, 10, 20, 20, 20, 20], - }), - ], - games: [testGame()], - }); - - const players = scoreboards[0]!.data.players; - expect(players).toHaveLength(8); - expect(players[1]!.name).toBe(""); - expect(players[1]!.weaponSplId).toBe(10); - expect(players[2]!.weaponSplId).toBe(null); - expect(players[2]!.ka).toBe(10); + expect(matched).toHaveLength(0); }); it("skips matches that have no matching game left", () => { - const scoreboards = Scoreboards.matchedScoreboards({ + const matched = Scoreboards.matchedGames({ matches: [ testMatch({ t: 60 }), testMatch({ @@ -424,11 +309,11 @@ describe("matchedScoreboards", () => { games: [testGame()], }); - expect(scoreboards).toHaveLength(1); + expect(matched).toHaveLength(1); }); it("skips a game whose known rosters contradict the match sides", () => { - const scoreboards = Scoreboards.matchedScoreboards({ + const matched = Scoreboards.matchedGames({ matches: [testMatch()], games: [ testGame({ @@ -447,11 +332,11 @@ describe("matchedScoreboards", () => { ], }); - expect(scoreboards.map((s) => s.tournamentMatchId)).toEqual([2]); + expect(matched.map(tournamentMatchIdOf)).toEqual([2]); }); it("matches known in-game names ignoring discriminator, case and unicode width", () => { - const scoreboards = Scoreboards.matchedScoreboards({ + const matched = Scoreboards.matchedGames({ matches: [ testMatch({ names: ["W1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"], @@ -467,26 +352,11 @@ describe("matchedScoreboards", () => { // "W1" matches winner roster "w1#1234" straight (1) but "w3" on the // winning side would match the loser roster flipped (1); straight wins ties - expect(scoreboards).toHaveLength(1); - }); - - it("keeps players whose name appears twice on the same side", () => { - const scoreboards = Scoreboards.matchedScoreboards({ - matches: [ - testMatch({ - names: ["dupe", "dupe", "w3", "w4", "l1", "l2", "l3", "dupe"], - }), - ], - games: [testGame()], - }); - - expect( - scoreboards[0]!.data.players.filter((p) => p.name === "dupe"), - ).toHaveLength(3); + expect(matched).toHaveLength(1); }); it("does not assign a game played before the previously assigned one", () => { - const scoreboards = Scoreboards.matchedScoreboards({ + const matched = Scoreboards.matchedGames({ matches: [ testMatch({ t: 60, mode: "RM", stage: 1 }), testMatch({ t: 1000, mode: "SZ", stage: 0 }), @@ -507,17 +377,218 @@ describe("matchedScoreboards", () => { ], }); - expect(scoreboards.map((s) => s.tournamentMatchId)).toEqual([2]); + expect(matched.map(tournamentMatchIdOf)).toEqual([2]); }); }); -describe("resolveTournamentId", () => { +describe("deriveScoreboardData", () => { + function derive( + linked: Array<{ data: ScannerMatch; povUserId: number | null }>, + ) { + return Scoreboards.deriveScoreboardData({ + linked, + winnerTeamId: WINNER_TEAM_ID, + loserTeamId: LOSER_TEAM_ID, + }); + } + + it("projects a match winner-first into scoreboard data", () => { + const data = derive([{ data: testMatch(), povUserId: null }]); + + expect(data).toEqual({ + scores: [100, 52], + players: ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"].map( + (name, i) => ({ + name, + tournamentTeamId: i < 4 ? WINNER_TEAM_ID : LOSER_TEAM_ID, + weaponSplId: i < 4 ? 10 : 20, + ka: 10, + d: 5, + s: 2, + paint: 1000, + }), + ), + }); + }); + + it("a winner-1 match derives identically to its winner-0 mirror", () => { + const straight = derive([{ data: testMatch(), povUserId: null }]); + const swapped = derive([{ data: swapSides(testMatch()), povUserId: null }]); + + expect(swapped).toEqual(straight); + }); + + it("returns null for a match that cannot form a scoreboard", () => { + expect(derive([])).toBe(null); + expect( + derive([{ data: { ...testMatch(), winner: null }, povUserId: null }]), + ).toBe(null); + }); + + it("rebases counter samples to the game's first read", () => { + const data = derive([ + { data: testMatch({ objective: testObjective() }), povUserId: null }, + ]); + + expect(data!.objective).toEqual({ + mode: "SZ", + samples: [ + { + t: 0, + time: 300, + score: [100, 100], + penalty: [null, null], + control: [false, false], + }, + { + t: 30, + time: 270, + score: [80, 100], + penalty: [null, 12], + control: [true, false], + }, + ], + }); + }); + + it("derives counter samples winner-first", () => { + const straight = derive([ + { data: testMatch({ objective: testObjective() }), povUserId: null }, + ]); + const swapped = derive([ + { + data: swapSides(testMatch({ objective: testObjective() })), + povUserId: null, + }, + ]); + + expect(swapped!.objective).toEqual(straight!.objective); + }); + + it("leaves out the objective of a match with no counter reads", () => { + const data = derive([{ data: testMatch(), povUserId: null }]); + + expect(data!.objective).toBeUndefined(); + }); + + it("carries ingested player abilities through", () => { + const build: ScannerAbility[][] = [ + ["ISM", "ISS", "ISS", "ISS"], + ["QR", "QSJ", "QSJ", "QSJ"], + ["SSU", "RSU", "RSU", "RSU"], + ]; + const data = derive([ + { data: testMatch({ abilities: { 5: build } }), povUserId: null }, + ]); + + expect(data!.players[5]!.abilities).toEqual(build); + expect(data!.players[0]!.abilities).toBeUndefined(); + }); + + it("keeps players with unread weapon or empty name", () => { + const data = derive([ + { + data: testMatch({ + names: ["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"], + weapons: [10, 10, null, 10, 20, 20, 20, 20], + }), + povUserId: null, + }, + ]); + + expect(data!.players).toHaveLength(8); + expect(data!.players[1]!.name).toBe(""); + expect(data!.players[1]!.weaponSplId).toBe(10); + expect(data!.players[2]!.weaponSplId).toBe(null); + expect(data!.players[2]!.ka).toBe(10); + }); + + it("keeps players whose name appears twice on the same side", () => { + const data = derive([ + { + data: testMatch({ + names: ["dupe", "dupe", "w3", "w4", "l1", "l2", "l3", "dupe"], + }), + povUserId: null, + }, + ]); + + expect(data!.players.filter((p) => p.name === "dupe")).toHaveLength(3); + }); + + it("attributes the POV seat's row to the POV user", () => { + const data = derive([{ data: testMatch({ povIndex: 2 }), povUserId: 42 }]); + + expect(data!.players[2]!.userId).toBe(42); + expect(data!.players.filter((p) => p.userId !== undefined)).toHaveLength(1); + }); + + it("attributes a losing-side POV of a winner-1 match to the right row", () => { + const data = derive([ + { data: swapSides(testMatch({ povIndex: 6 })), povUserId: 42 }, + ]); + + expect(data!.players[6]!.userId).toBe(42); + }); + + it("attributes each linked POV onto the merged scoreboard", () => { + const data = derive([ + { data: testMatch({ povIndex: 0 }), povUserId: 42 }, + { data: swapSides(testMatch({ povIndex: 5 })), povUserId: 43 }, + ]); + + expect(data!.players[0]!.userId).toBe(42); + expect(data!.players[5]!.userId).toBe(43); + }); + + it("does not attribute the same row twice", () => { + const data = derive([ + { data: testMatch({ povIndex: 2 }), povUserId: 42 }, + { data: testMatch({ povIndex: 2 }), povUserId: 43 }, + ]); + + expect(data!.players[2]!.userId).toBe(42); + }); + + it("merges a later partial's fields under the first link's values", () => { + const withoutScores: ScannerMatch = { + ...testMatch(), + matchScores: null, + }; + const data = derive([ + { data: withoutScores, povUserId: null }, + { data: testMatch(), povUserId: null }, + ]); + + expect(data!.scores).toEqual([100, 52]); + }); +}); + +describe("winnerFirstPlayerNames", () => { + it("returns names winner-first with unread names empty", () => { + const names = Scoreboards.winnerFirstPlayerNames( + swapSides( + testMatch({ names: ["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"] }), + ), + ); + + expect(names).toEqual(["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"]); + }); + + it("returns null for a match without a linkable scoreboard", () => { + expect( + Scoreboards.winnerFirstPlayerNames({ ...testMatch(), winner: null }), + ).toBe(null); + }); +}); + +describe("resolveContext", () => { /** A tournament's reported games as an ordered (mode, stageId) sequence. */ function tournamentGames( tournamentId: number, sequence: [ModeShort, number][], partial: Partial = {}, - ): Scoreboards.IngestableGameWithTournament[] { + ): Scoreboards.IngestableGameWithContext[] { return sequence.map(([mode, stageId], i) => ({ ...testGame({ matchGameResultId: tournamentId * 1000 + i, @@ -528,7 +599,28 @@ describe("resolveTournamentId", () => { playedAt: 1000 + i, ...partial, }), - tournamentId, + context: { type: "tournament", tournamentId }, + })); + } + + /** A SendouQ match's reported games as an ordered (mode, stageId) sequence. */ + function sendouqGames( + groupMatchId: number, + sequence: [ModeShort, number][], + ): Scoreboards.IngestableGameWithContext[] { + return sequence.map(([mode, stageId], i) => ({ + ...testGame({ + mapIndex: i, + mode, + stageId: stageId as StageId, + playedAt: 1000 + i, + }), + target: { + type: "sendouq", + groupMatchMapId: groupMatchId * 1000 + i, + groupMatchId, + }, + context: { type: "sendouq", groupMatchId }, })); } @@ -538,7 +630,7 @@ describe("resolveTournamentId", () => { ]; it("resolves the tournament whose games match the seen sequence", () => { - const tournamentId = Scoreboards.resolveTournamentId({ + const context = Scoreboards.resolveContext({ matches: seenSequence, games: [ ...tournamentGames(1, [ @@ -552,11 +644,29 @@ describe("resolveTournamentId", () => { ], }); - expect(tournamentId).toBe(1); + expect(context).toEqual({ type: "tournament", tournamentId: 1 }); + }); + + it("resolves a SendouQ match over a tournament when its games match better", () => { + const context = Scoreboards.resolveContext({ + matches: seenSequence, + games: [ + ...tournamentGames(1, [ + ["SZ", 3], + ["TC", 2], + ]), + ...sendouqGames(7, [ + ["SZ", 0], + ["TC", 1], + ]), + ], + }); + + expect(context).toEqual({ type: "sendouq", groupMatchId: 7 }); }); it("does not resolve from a single matching match", () => { - const tournamentId = Scoreboards.resolveTournamentId({ + const context = Scoreboards.resolveContext({ matches: [seenSequence[0]!], games: tournamentGames(1, [ ["SZ", 0], @@ -564,7 +674,7 @@ describe("resolveTournamentId", () => { ]), }); - expect(tournamentId).toBe(null); + expect(context).toBe(null); }); it("lets roster sides break a map-sequence tie", () => { @@ -572,7 +682,7 @@ describe("resolveTournamentId", () => { ["SZ", 0], ["TC", 1], ]; - const tournamentId = Scoreboards.resolveTournamentId({ + const context = Scoreboards.resolveContext({ matches: seenSequence, games: [ ...tournamentGames(1, sharedMaplist, { @@ -587,11 +697,11 @@ describe("resolveTournamentId", () => { ], }); - expect(tournamentId).toBe(1); + expect(context).toEqual({ type: "tournament", tournamentId: 1 }); }); it("skips unreadable matches but resolves from the rest", () => { - const tournamentId = Scoreboards.resolveTournamentId({ + const context = Scoreboards.resolveContext({ matches: [ seenSequence[0]!, testMatch({ t: 300, stage: null }), @@ -609,6 +719,6 @@ describe("resolveTournamentId", () => { ], }); - expect(tournamentId).toBe(1); + expect(context).toEqual({ type: "tournament", tournamentId: 1 }); }); }); diff --git a/app/features/scanner-ingest/core/Scoreboards.ts b/app/features/scanner-ingest/core/Scoreboards.ts index b4fab1d91..f3553fa4f 100644 --- a/app/features/scanner-ingest/core/Scoreboards.ts +++ b/app/features/scanner-ingest/core/Scoreboards.ts @@ -11,39 +11,49 @@ import type { ModeShort, StageId, } from "~/modules/in-game-lists/types"; -import { normalizeInGameName } from "./Matches"; +import * as Matches from "./Matches"; -/** Lobby header value scoreboards of tournament games are expected to have. */ +/** Lobby header value scoreboards of tournament/SendouQ games are expected to have. */ const TOURNAMENT_LOBBY = "PRIVATE"; /** * How many of the 8 player rows must carry the same readable name in the * same position for a match to count as a re-detection of a game's - * already stored scoreboard (allows a couple of OCR misreads). + * already linked scoreboard (allows a couple of OCR misreads). */ -const MIN_STORED_DUPLICATE_NAME_MATCHES = 6; +const MIN_LINKED_DUPLICATE_NAME_MATCHES = 6; /** How many players on the winning (first) resp. losing side of a scoreboard. */ const PLAYERS_PER_TEAM = 4; /** - * How many matches must align with one tournament's games for content + * How many matches must align with one context's games for content * resolution to trust it. A single game's (mode, stage, sides) is common * across a user's history; two already carry order. */ const MIN_RESOLVED_SCOREBOARDS = 2; -/** A game of a tournament match that ingested scoreboards can be matched against. */ +/** The match context an ingest request was resolved to belong to. */ +export type IngestContext = + | { type: "tournament"; tournamentId: number } + | { type: "sendouq"; groupMatchId: number }; + +/** The reported game result an ingested match can link to. */ +export type IngestableGameTarget = + | { + type: "tournament"; + matchGameResultId: number; + tournamentMatchId: number; + } + | { type: "sendouq"; groupMatchMapId: number; groupMatchId: number }; + +/** A game of a tournament or SendouQ match that ingested matches can be linked to. */ export interface IngestableGame { - matchGameResultId: number; - tournamentMatchId: number; + target: IngestableGameTarget; /** 0-based index of the game within its match */ mapIndex: number; mode: ModeShort; stageId: StageId; - winnerTeamId: number; - loserTeamId: number | null; - // xxx: this should not be needed? we get winner or not from the ingested event /** known in-game names of the winning team's roster, used to validate scoreboard sides */ winnerInGameNames: string[]; /** known in-game names of the losing team's roster, used to validate scoreboard sides */ @@ -51,12 +61,134 @@ export interface IngestableGame { /** database timestamp used to order games chronologically across matches */ playedAt: number; /** - * player names (in scoreboard row order) of the game's already stored - * scoreboard; null when the game has none yet. Lets matching skip taken - * games across requests while recognizing re-detections of the same - * scoreboard. + * player names (winner-first, in scoreboard row order) of an already + * linked ingested match of the game; null when the game has none yet. + * Lets matching skip taken games across requests while recognizing + * re-detections of the same scoreboard. */ - storedScoreboardPlayerNames: string[] | null; + linkedPlayerNames: string[] | null; +} + +/** A candidate game for content resolution, tagged with its context. */ +export interface IngestableGameWithContext extends IngestableGame { + context: IngestContext; +} + +export interface MatchedGame { + /** index into the input `matches` array */ + matchIndex: number; + game: IngestableGame; +} + +/** Stable grouping/equality key for an {@link IngestContext}. */ +export function contextKey(context: IngestContext): string { + return context.type === "tournament" + ? `tournament:${context.tournamentId}` + : `sendouq:${context.groupMatchId}`; +} + +/** + * Resolves which context (tournament or SendouQ match) a request's matches + * belong to from their content alone: the candidate games (the POV user's + * reported games) are grouped by context and each context is scored by how + * many matches `matchedGames` aligns with its games — the same mode+stage + * sequence walk and roster-side validation that decides what would actually + * be linked. + */ +export function resolveContext({ + matches, + games, +}: { + matches: ScannerMatch[]; + games: IngestableGameWithContext[]; +}): IngestContext | null { + const byContext = new Map(); + for (const game of games) { + const key = contextKey(game.context); + const contextGames = byContext.get(key) ?? []; + contextGames.push(game); + byContext.set(key, contextGames); + } + + let best: { context: IngestContext; matched: number } | null = null; + for (const contextGames of byContext.values()) { + const matched = matchedGames({ + matches, + games: contextGames, + }).length; + if (!best || matched > best.matched) { + best = { context: contextGames[0]!.context, matched }; + } + } + + if (!best || best.matched < MIN_RESOLVED_SCOREBOARDS) return null; + return best.context; +} + +/** + * Matches ingested matches against a context's games, deciding which game + * result each match should link to. + * + * Only matches whose winner is known with two full teams qualify (a + * minimap-only match can never link — its winner and stats are unread). + * Matches and games are both walked in chronological order: each match is + * assigned to the next not-yet-assigned game with the same mode and stage + * whose sides don't contradict the teams' known in-game names (the winning + * rows should overlap the game winner's roster, not the loser's). Matches + * from other lobbies, with unreadable mode/stage or duplicated detections + * of the same game are skipped. + * + * One session's matches may arrive over many requests (one per game), so + * games another ingest already linked to are skipped — unless the incoming + * match is a re-detection of the linked one, which is matched to the same + * game so re-sends stay idempotent and another POV's scan of the same game + * lands on it too. + */ +export function matchedGames({ + matches, + games, +}: { + matches: ScannerMatch[]; + games: IngestableGame[]; +}): MatchedGame[] { + const views = dedupeViews( + matches + .map((match, matchIndex) => { + const view = winnerFirstView(match, matchIndex); + return view ? { ...view, matchIndex } : null; + }) + .filter((view): view is IndexedView => view !== null) + .filter((view) => !view.lobby || view.lobby === TOURNAMENT_LOBBY) + .sort((a, b) => a.order - b.order), + ); + const orderedGames = games.toSorted( + (a, b) => a.playedAt - b.playedAt || a.mapIndex - b.mapIndex, + ); + + const result: MatchedGame[] = []; + + let nextGameIdx = 0; + for (const view of views) { + if (view.mode === null || view.stage === null) continue; + + for (let i = nextGameIdx; i < orderedGames.length; i++) { + const game = orderedGames[i]!; + if (game.mode !== view.mode || game.stageId !== view.stage) continue; + if (game.linkedPlayerNames) { + if (!isLinkedDuplicate(view, game.linkedPlayerNames)) { + continue; + } + } else if (!sidesMatchKnownPlayers(view, game)) { + continue; + } + + result.push({ matchIndex: view.matchIndex, game }); + nextGameIdx = i + 1; + break; + } + } + + return result; } export interface IngestedScoreboardPlayer { @@ -69,10 +201,14 @@ export interface IngestedScoreboardPlayer { paint: number | null; /** [head, clothes, shoes] ability rows gathered from the match's death screens */ abilities?: ScannerAbility[][]; - /** set only via povIndex attribution */ + /** set via POV attribution of a linked ingested match */ userId?: number; } +/** + * The scoreboard of a game derived from its linked ingested matches — the + * shape match pages render. Derived at read time, not stored. + */ export interface IngestedScoreboardData { /** game scores [winner, loser] (0-100; a knockout's winner is 100) */ scores: [number | null, number | null]; @@ -87,122 +223,72 @@ export interface IngestedScoreboardData { objective?: ScannerMatchObjective; } -export interface MatchedScoreboard { - matchGameResultId: number; - tournamentMatchId: number; - mapIndex: number; - povIndex: number | null; - data: IngestedScoreboardData; -} - -/** A candidate game for content resolution, tagged with its tournament. */ -export interface IngestableGameWithTournament extends IngestableGame { - tournamentId: number; -} - /** - * Resolves which tournament a request's matches belong to from their content - * alone: the candidate games (the POV user's reported games across - * tournaments) are grouped by tournament and each tournament is scored by - * how many matches `matchedScoreboards` aligns with its games — the same - * mode+stage sequence walk and roster-side validation that decides what - * would actually be stored. + * Derives a game's scoreboard from its linked ingested matches: the earliest + * link is the base and later ones enrich it (first-ingest-wins field-wise, + * via Matches.mergeMatches), the merged match is projected winner-first, and + * every linked POV seat attributes its player row to the POV user. + * + * `winnerTeamId`/`loserTeamId` are the game result's sides (tournament team + * or SendouQ group ids), stamped onto the rows for the reader. */ -export function resolveTournamentId({ - matches, - games, +export function deriveScoreboardData({ + linked, + winnerTeamId, + loserTeamId, }: { - matches: ScannerMatch[]; - games: IngestableGameWithTournament[]; -}): number | null { - const byTournament = new Map(); - for (const game of games) { - const tournamentGames = byTournament.get(game.tournamentId) ?? []; - tournamentGames.push(game); - byTournament.set(game.tournamentId, tournamentGames); + /** in link order, earliest first */ + linked: Array<{ data: ScannerMatch; povUserId: number | null }>; + winnerTeamId: number; + loserTeamId: number | null; +}): IngestedScoreboardData | null { + const [first, ...rest] = linked; + if (!first) return null; + + let merged = first.data; + for (const other of rest) { + merged = Matches.mergeMatches(merged, other.data).merged; } - let best: { tournamentId: number; matched: number } | null = null; - for (const [tournamentId, tournamentGames] of byTournament) { - const matched = matchedScoreboards({ - matches, - games: tournamentGames, - }).length; - if (!best || matched > best.matched) { - best = { tournamentId, matched }; - } - } + const view = winnerFirstView(merged, 0); + if (!view) return null; - if (!best || best.matched < MIN_RESOLVED_SCOREBOARDS) return null; - return best.tournamentId; -} - -/** - * Matches ingested matches against the games the POV user played and turns - * them into insertable scoreboard rows. - * - * Only matches whose winner is known with two full teams qualify (a - * minimap-only match can never attach — its winner and stats are unread). - * Matches and games are both walked in chronological order: each match is - * assigned to the next not-yet-assigned game with the same mode and stage - * whose sides don't contradict the teams' known in-game names (the winning - * rows should overlap the game winner's roster, not the loser's). Matches - * from other lobbies, with unreadable mode/stage or duplicated detections - * of the same game are skipped. - * - * One session's matches may arrive over many requests (one per game), so - * games whose scoreboard was stored by an earlier request are skipped — - * unless the incoming match is a re-detection of the stored one, which is - * matched to the same game so re-sends stay idempotent and another POV's - * attribution still lands. - */ -export function matchedScoreboards({ - matches, - games, -}: { - matches: ScannerMatch[]; - games: IngestableGame[]; -}): MatchedScoreboard[] { - const views = dedupeViews( - matches - .map(winnerFirstView) - .filter((view): view is WinnerFirstView => view !== null) - .filter((view) => !view.lobby || view.lobby === TOURNAMENT_LOBBY) - .sort((a, b) => a.order - b.order), - ); - const orderedGames = games.toSorted( - (a, b) => a.playedAt - b.playedAt || a.mapIndex - b.mapIndex, + const players = view.players.map( + (player, playerIdx): IngestedScoreboardPlayer => ({ + name: player.name.trim(), + tournamentTeamId: + playerIdx < PLAYERS_PER_TEAM ? winnerTeamId : loserTeamId, + weaponSplId: player.weaponId, + ka: player.ka, + d: player.d, + s: player.s, + paint: player.paint, + ...(player.abilities ? { abilities: player.abilities } : null), + }), ); - const result: MatchedScoreboard[] = []; + attributePovUsers(players, linked); - let nextGameIdx = 0; - for (const view of views) { - if (view.mode === null || view.stage === null) continue; - - for (let i = nextGameIdx; i < orderedGames.length; i++) { - const game = orderedGames[i]!; - if (game.mode !== view.mode || game.stageId !== view.stage) continue; - if (game.storedScoreboardPlayerNames) { - if (!isStoredDuplicate(view, game.storedScoreboardPlayerNames)) { - continue; - } - } else if (!sidesMatchKnownPlayers(view, game)) { - continue; - } - - result.push(viewToMatchedScoreboard({ view, game })); - nextGameIdx = i + 1; - break; - } - } - - return result; + return { + scores: view.scores, + players, + ...(view.objective ? { objective: view.objective } : null), + }; } /** - * A match's players in stored-scoreboard order — winning team's rows first — - * with unread names as empty strings. Null when the match can't attach: its + * A match's players winner-first in scoreboard row order (unread names as + * empty strings), or null when the match has no such view — the + * `linkedPlayerNames` a game's already linked ingest contributes. + */ +export function winnerFirstPlayerNames(match: ScannerMatch): string[] | null { + const view = winnerFirstView(match, 0); + return view ? view.players.map((player) => player.name.trim()) : null; +} + +/** + * A match's players in linked-scoreboard order — winning team's rows first — + * with unread names as empty strings. Null when the match can't link: its * winner is unknown or either team wasn't fully seen. */ interface WinnerFirstView { @@ -212,13 +298,17 @@ interface WinnerFirstView { /** game scores [winner, loser] from the match's "Score:" banner */ scores: [number | null, number | null]; players: WinnerFirstPlayer[]; - /** counter progress with both the sides and `t` already stored-shaped */ + /** counter progress with both the sides and `t` already winner-first */ objective: ScannerMatchObjective | null; povIndex: number | null; /** chronological walk key: wall-clock, else video time, else input order */ order: number; } +interface IndexedView extends WinnerFirstView { + matchIndex: number; +} + interface WinnerFirstPlayer { name: string; weaponId: MainWeaponId | null; @@ -267,9 +357,9 @@ function winnerFirstView( } /** - * Puts a match's counter samples in stored-scoreboard shape: per-team values - * winner-first like `scores` and `players`, and `t` rebased to the game's - * first read so the samples stay meaningful without the source video. + * Puts a match's counter samples in derived-scoreboard shape: per-team + * values winner-first like `scores` and `players`, and `t` rebased to the + * game's first read so the samples stay meaningful without the source video. */ function winnerFirstObjective( objective: ScannerMatchObjective | null, @@ -293,12 +383,58 @@ function winnerFirstObjective( }; } +/** + * Attributes each linked match's POV seat to its POV user on the merged + * rows: the seat's read name picks the row (unique name match), falling + * back to the seat's own winner-first position when the names don't + * contradict. A row already attributed, or a user already present, is left + * alone (first link wins). + */ +function attributePovUsers( + players: IngestedScoreboardPlayer[], + linked: Array<{ data: ScannerMatch; povUserId: number | null }>, +) { + for (const { data, povUserId } of linked) { + if (povUserId === null || data.pov === null) continue; + const view = winnerFirstView(data, 0); + if (!view || view.povIndex === null) continue; + if (players.some((player) => player.userId === povUserId)) continue; + + const povName = Matches.normalizeInGameName( + view.players[view.povIndex]!.name, + ); + const index = attributionIndex(players, povName, view.povIndex); + if (index === null || players[index]!.userId !== undefined) continue; + + players[index] = { ...players[index]!, userId: povUserId }; + } +} + +function attributionIndex( + players: IngestedScoreboardPlayer[], + povName: string, + fallbackIndex: number, +): number | null { + if (povName) { + const hits = players.flatMap((player, index) => + Matches.normalizeInGameName(player.name) === povName ? [index] : [], + ); + if (hits.length === 1) return hits[0]!; + } + + const fallback = players[fallbackIndex]; + if (!fallback) return null; + const fallbackName = Matches.normalizeInGameName(fallback.name); + if (povName && fallbackName && fallbackName !== povName) return null; + return fallbackIndex; +} + /** * Drops re-detections of the same game within one request: same mode and * stage with every player row carrying the same name. */ -function dedupeViews(sorted: WinnerFirstView[]): WinnerFirstView[] { - const result: WinnerFirstView[] = []; +function dedupeViews(sorted: IndexedView[]): IndexedView[] { + const result: IndexedView[] = []; for (const view of sorted) { const isDuplicate = result.some( @@ -325,13 +461,13 @@ function dedupeViews(sorted: WinnerFirstView[]): WinnerFirstView[] { function sidesMatchKnownPlayers(view: WinnerFirstView, game: IngestableGame) { const winnerSide = view.players .slice(0, PLAYERS_PER_TEAM) - .map((player) => normalizeInGameName(player.name)); + .map((player) => Matches.normalizeInGameName(player.name)); const loserSide = view.players .slice(PLAYERS_PER_TEAM) - .map((player) => normalizeInGameName(player.name)); + .map((player) => Matches.normalizeInGameName(player.name)); - const knownWinners = game.winnerInGameNames.map(normalizeInGameName); - const knownLosers = game.loserInGameNames.map(normalizeInGameName); + const knownWinners = game.winnerInGameNames.map(Matches.normalizeInGameName); + const knownLosers = game.loserInGameNames.map(Matches.normalizeInGameName); const straight = nameOverlap(winnerSide, knownWinners) + nameOverlap(loserSide, knownLosers); @@ -347,55 +483,19 @@ function nameOverlap(names: string[], knownNames: string[]) { } /** - * Checks whether a match is a re-detection of a game's already stored + * Checks whether a match is a re-detection of a game's already linked * scoreboard: enough player rows carry the same readable name in the same * position. Positional comparison keeps two games between the same eight * players apart — their row orders and sides practically always differ. */ -function isStoredDuplicate(view: WinnerFirstView, storedPlayerNames: string[]) { +function isLinkedDuplicate(view: WinnerFirstView, linkedPlayerNames: string[]) { const matches = view.players.filter((player, i) => { - const name = normalizeInGameName(player.name); - const storedName = storedPlayerNames[i] - ? normalizeInGameName(storedPlayerNames[i]) + const name = Matches.normalizeInGameName(player.name); + const linkedName = linkedPlayerNames[i] + ? Matches.normalizeInGameName(linkedPlayerNames[i]!) : ""; - return name !== "" && name === storedName; + return name !== "" && name === linkedName; }).length; - return matches >= MIN_STORED_DUPLICATE_NAME_MATCHES; -} - -function viewToMatchedScoreboard({ - view, - game, -}: { - view: WinnerFirstView; - game: IngestableGame; -}): MatchedScoreboard { - const players = view.players.map( - (player, playerIdx): IngestedScoreboardPlayer => { - return { - name: player.name.trim(), - tournamentTeamId: - playerIdx < PLAYERS_PER_TEAM ? game.winnerTeamId : game.loserTeamId, - weaponSplId: player.weaponId, - ka: player.ka, - d: player.d, - s: player.s, - paint: player.paint, - ...(player.abilities ? { abilities: player.abilities } : null), - }; - }, - ); - - return { - matchGameResultId: game.matchGameResultId, - tournamentMatchId: game.tournamentMatchId, - mapIndex: game.mapIndex, - povIndex: view.povIndex, - data: { - scores: view.scores, - players, - ...(view.objective ? { objective: view.objective } : null), - }, - }; + return matches >= MIN_LINKED_DUPLICATE_NAME_MATCHES; } diff --git a/app/features/scanner-ingest/scanner-ingest-schemas.ts b/app/features/scanner-ingest/scanner-ingest-schemas.ts index dc28d9d14..56cbb73ee 100644 --- a/app/features/scanner-ingest/scanner-ingest-schemas.ts +++ b/app/features/scanner-ingest/scanner-ingest-schemas.ts @@ -12,6 +12,5 @@ const MAX_MATCHES_PER_REQUEST = 50; export const ingestBodySchema = z.object({ /** the user whose point of view the matches were detected from */ povUserId: id.optional(), - tournamentId: id.optional(), matches: z.array(scannerMatchSchema).min(1).max(MAX_MATCHES_PER_REQUEST), }); diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index 6195cc633..92415108a 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -38,7 +38,7 @@ sequenceDiagram participant MB as match-builder participant UI as Live/VoD tab participant ING as /ingest (scanner-ingest) - participant DB as IngestedMatch / IngestedScoreboard + participant DB as IngestedMatch / IngestedMatchLink Cap->>W: frame + t (live/screenshot/seek) — VoD: worker decodes its own slice W->>W: scheduler dueDetectors() → gate() → parse() W-->>TL: DetectedEvents @@ -46,9 +46,9 @@ sequenceDiagram UI->>MB: buildScannerMatches(events) MB-->>UI: ScannerMatch[] + source events UI->>ING: POST { matches } (Live: on match close / scan end, VoD: whole scan) - ING->>ING: resolve tournament (content sequence ≥2, else playedAt) - ING->>DB: merge-store IngestedMatch (matchHash, isSameMatch + merge) - ING->>DB: attach winner-first view → IngestedScoreboard (first-ingest-wins, POV + ReportedWeapon) + ING->>ING: resolve context (current tournament/SendouQ activity, casts via staff roles, else content sequence ≥2) + ING->>DB: merge-store IngestedMatch (matchHash, isSameMatch + merge, context hints) + ING->>DB: link matches to game results → IngestedMatchLink (POV weapon → ReportedWeapon; scoreboards derived at read time) Note over UI: VoD "Add VoD": ScannerMatch → slim prefill param → /vods/new ``` diff --git a/migrations/20260804000000-ingest.ts b/migrations/20260804000000-ingest.ts index 1b37c50ce..19bd3fafb 100644 --- a/migrations/20260804000000-ingest.ts +++ b/migrations/20260804000000-ingest.ts @@ -1,15 +1,12 @@ import { type Kysely, sql } from "kysely"; -/** Tables for scanner-ingested matches and end-of-game scoreboards */ +/** Tables for scanner-ingested matches and their links to reported game results */ export async function up(db: Kysely): Promise { // kysely does not wrap sqlite migrations in a transaction, so do it here await db.transaction().execute(async (trx) => { await trx.schema .createTable("IngestedMatch") .addColumn("id", "integer", (col) => col.primaryKey()) - .addColumn("tournamentId", "integer", (col) => - col.references("Tournament.id").onDelete("cascade"), - ) .addColumn("povUserId", "integer", (col) => col.references("User.id").onDelete("set null"), ) @@ -19,6 +16,12 @@ export async function up(db: Kysely): Promise { .addColumn("playedAt", "integer") .addColumn("data", "text", (col) => col.notNull()) .addColumn("matchHash", "text", (col) => col.unique().notNull()) + .addColumn("tournamentIdHint", "integer", (col) => + col.references("Tournament.id").onDelete("set null"), + ) + .addColumn("groupMatchIdHint", "integer", (col) => + col.references("GroupMatch.id").onDelete("set null"), + ) .addColumn("createdAt", "integer", (col) => col.notNull().defaultTo(sql`(strftime('%s', 'now'))`), ) @@ -26,12 +29,6 @@ export async function up(db: Kysely): Promise { .modifyEnd(sql`strict`) .execute(); - await trx.schema - .createIndex("ingested_match_tournament_id") - .on("IngestedMatch") - .column("tournamentId") - .execute(); - await trx.schema .createIndex("ingested_match_pov_user_id_played_at") .on("IngestedMatch") @@ -39,20 +36,53 @@ export async function up(db: Kysely): Promise { .execute(); await trx.schema - .createTable("IngestedScoreboard") + .createIndex("ingested_match_tournament_id_hint") + .on("IngestedMatch") + .column("tournamentIdHint") + .execute(); + + await trx.schema + .createIndex("ingested_match_group_match_id_hint") + .on("IngestedMatch") + .column("groupMatchIdHint") + .execute(); + + await trx.schema + .createTable("IngestedMatchLink") .addColumn("id", "integer", (col) => col.primaryKey()) - .addColumn("matchGameResultId", "integer", (col) => + .addColumn("ingestedMatchId", "integer", (col) => col .notNull() .unique() - .references("TournamentMatchGameResult.id") + .references("IngestedMatch.id") .onDelete("cascade"), ) - .addColumn("data", "text", (col) => col.notNull()) + .addColumn("tournamentMatchGameResultId", "integer", (col) => + col.references("TournamentMatchGameResult.id").onDelete("cascade"), + ) + .addColumn("groupMatchMapId", "integer", (col) => + col.references("GroupMatchMap.id").onDelete("cascade"), + ) .addColumn("createdAt", "integer", (col) => col.notNull().defaultTo(sql`(strftime('%s', 'now'))`), ) + .addCheckConstraint( + "ingested_match_link_one_target", + sql`("tournamentMatchGameResultId" is not null) + ("groupMatchMapId" is not null) = 1`, + ) .modifyEnd(sql`strict`) .execute(); + + await trx.schema + .createIndex("ingested_match_link_tournament_match_game_result_id") + .on("IngestedMatchLink") + .column("tournamentMatchGameResultId") + .execute(); + + await trx.schema + .createIndex("ingested_match_link_group_match_map_id") + .on("IngestedMatchLink") + .column("groupMatchMapId") + .execute(); }); }