From c2bb3fb3c143cda0fb947e77e0616127071e3155 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:49:52 +0300 Subject: [PATCH] Send matches --- app/db/tables.ts | 16 +- .../ScannerIngestRepository.server.ts | 200 +++++-- .../actions/scanner-ingest.server.ts | 95 ++-- .../scanner-ingest/core/Matches.test.ts | 288 ++++++++++ app/features/scanner-ingest/core/Matches.ts | 339 ++++++++++++ .../scanner-ingest/core/Scoreboards.test.ts | 234 +++++---- .../scanner-ingest/core/Scoreboards.ts | 215 ++++---- .../scanner-ingest/scanner-ingest-schemas.ts | 77 +-- .../scanner-ingest-vod-schemas.ts | 2 +- app/features/scanner/README.md | 209 ++++---- app/features/scanner/components/LivePage.tsx | 38 +- app/features/scanner/components/VodPage.tsx | 20 +- .../scanner/components/sendou-ingest.ts | 197 +++---- .../scanner/components/sendou-upload.ts | 59 ++- app/features/scanner/core/ability-harvest.ts | 28 +- app/features/scanner/core/batches.ts | 126 ----- app/features/scanner/core/cv.ts | 4 + app/features/scanner/core/match-builder.ts | 360 +++++++++++++ app/features/scanner/core/scanner-match.ts | 62 +++ app/features/scanner/core/vod-matches.ts | 239 --------- app/features/scanner/scanner-schemas.ts | 113 ++-- app/features/scanner/tests/batches.test.ts | 235 --------- .../scanner/tests/match-builder.test.ts | 494 ++++++++++++++++++ .../scanner/tests/vod-matches.test.ts | 222 -------- migrations/20260804000000-ingest.ts | 21 +- 25 files changed, 2339 insertions(+), 1554 deletions(-) create mode 100644 app/features/scanner-ingest/core/Matches.test.ts create mode 100644 app/features/scanner-ingest/core/Matches.ts delete mode 100644 app/features/scanner/core/batches.ts create mode 100644 app/features/scanner/core/match-builder.ts create mode 100644 app/features/scanner/core/scanner-match.ts delete mode 100644 app/features/scanner/core/vod-matches.ts delete mode 100644 app/features/scanner/tests/batches.test.ts create mode 100644 app/features/scanner/tests/match-builder.test.ts delete mode 100644 app/features/scanner/tests/vod-matches.test.ts diff --git a/app/db/tables.ts b/app/db/tables.ts index b06f6e4c6..fd64b5de7 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -34,8 +34,8 @@ import type { CalendarEventTag } from "~/features/calendar/calendar-types"; 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 { IngestedEventData } from "~/features/scanner-ingest/scanner-ingest-schemas"; import type { SplatoonRotationType } from "~/features/splatoon-rotations/splatoon-rotations-constants"; import type { MemberRole, @@ -478,17 +478,15 @@ export interface ReportedWeapon { createdAt: Generated; } -export interface IngestedEvent { +export interface IngestedMatch { id: GeneratedAlways; tournamentId: number | null; povUserId: number | null; submitterUserId: number | null; - type: string; - t: number; - confidence: number; - data: JSONColumnType; - detectedAt: number | null; - eventHash: string; + /** database timestamp (seconds) the match was played at, when known */ + playedAt: number | null; + data: JSONColumnType; + matchHash: string; createdAt: Generated; } @@ -1273,7 +1271,7 @@ export interface DB { GroupMatchContinueVote: GroupMatchContinueVote; GroupMatchMap: GroupMatchMap; GroupMember: GroupMember; - IngestedEvent: IngestedEvent; + IngestedMatch: IngestedMatch; IngestedScoreboard: IngestedScoreboard; PrivateUserNote: PrivateUserNote; LogInLink: LogInLink; diff --git a/app/features/scanner-ingest/ScannerIngestRepository.server.ts b/app/features/scanner-ingest/ScannerIngestRepository.server.ts index 3317613ed..67c6103e3 100644 --- a/app/features/scanner-ingest/ScannerIngestRepository.server.ts +++ b/app/features/scanner-ingest/ScannerIngestRepository.server.ts @@ -1,77 +1,205 @@ import { createHash } from "node:crypto"; +import { subDays } from "date-fns"; import { sql, type Transaction } from "kysely"; import { db } from "~/db/sql"; 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, } from "./core/Scoreboards"; -import type { IngestedEventInput } from "./scanner-ingest-schemas"; const opponentOneId = sql`"TournamentMatch"."opponentOne" ->> '$.id'`; const opponentTwoId = sql`"TournamentMatch"."opponentTwo" ->> '$.id'`; /** - * Stores raw ingested events. Events whose contents were stored before - * (for the same tournament and POV user) are skipped. - * - * @returns count of newly stored events + * How far a stored match's playedAt may sit from an incoming one and still + * be loaded as a merge candidate (content contradictions are checked by + * Matches.isSameMatch; this only bounds the query). */ -export async function addEvents({ +const MERGE_CANDIDATE_PLAYED_AT_WINDOW_DAYS = 1; +/** How recently a playedAt-less stored match must have been created to be a candidate. */ +const MERGE_CANDIDATE_CREATED_AT_WINDOW_DAYS = 7; +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. + * + * @returns counts plus the post-merge matches (a partial arriving after an + * earlier richer send attaches downstream with the merged, fuller data) + */ +export async function addOrMergeMatches({ tournamentId, povUserId, submitterUserId, - events, + matches, }: { tournamentId: number | null; povUserId: number | null; submitterUserId: number | null; - events: IngestedEventInput[]; + matches: ScannerMatch[]; }) { - const result = await db - .insertInto("IngestedEvent") - .values( - events.map((event) => ({ + let insertedCount = 0; + let mergedCount = 0; + const effectiveMatches: ScannerMatch[] = []; + + for (const match of matches) { + const canonical = Matches.canonicalMatch(match); + const hash = matchHash({ tournamentId, povUserId, match: canonical }); + + const effective = await db.transaction().execute(async (trx) => { + const identical = await trx + .selectFrom("IngestedMatch") + .select("data") + .where("matchHash", "=", hash) + .executeTakeFirst(); + if (identical) return identical.data; + + const stored = await findMergeCandidate(trx, { tournamentId, povUserId, - submitterUserId, - type: event.type, - t: event.t, - confidence: event.confidence, - data: JSON.stringify(event.data), - detectedAt: event.detectedAt ?? null, - eventHash: eventHash({ tournamentId, povUserId, event }), - })), + match: canonical, + }); + if (!stored) { + const inserted = await trx + .insertInto("IngestedMatch") + .values({ + tournamentId, + povUserId, + submitterUserId, + playedAt: toDbTimestamp(canonical.playedAt), + data: JSON.stringify(canonical), + matchHash: hash, + }) + .onConflict((oc) => oc.column("matchHash").doNothing()) + .executeTakeFirst(); + if (Number(inserted.numInsertedOrUpdatedRows ?? 0) > 0) { + insertedCount++; + } + return canonical; + } + + const { merged, changed } = Matches.mergeMatches(stored.data, canonical); + if (!changed) return stored.data; + + const mergedCanonical = Matches.canonicalMatch(merged); + await trx + .updateTable("IngestedMatch") + .set({ + playedAt: toDbTimestamp(mergedCanonical.playedAt), + data: JSON.stringify(mergedCanonical), + matchHash: matchHash({ + tournamentId, + povUserId, + match: mergedCanonical, + }), + }) + .where("id", "=", stored.id) + .execute(); + mergedCount++; + return mergedCanonical; + }); + + effectiveMatches.push(effective); + } + + return { insertedCount, mergedCount, effectiveMatches }; +} + +/** + * 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. + */ +async function findMergeCandidate( + trx: Transaction, + { + tournamentId, + povUserId, + match, + }: { + tournamentId: number | null; + povUserId: number | null; + match: ScannerMatch; + }, +) { + const createdAfter = Math.floor( + subDays(new Date(), MERGE_CANDIDATE_CREATED_AT_WINDOW_DAYS).getTime() / + 1000, + ); + + 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!), ) - .onConflict((oc) => oc.column("eventHash").doNothing()) + .$if(povUserId === null, (qb) => qb.where("povUserId", "is", null)) + .$if(povUserId !== null, (qb) => qb.where("povUserId", "=", povUserId!)) + .$if(match.playedAt !== null, (qb) => + qb.where((eb) => + eb.or([ + eb.and([ + eb( + "playedAt", + ">=", + toDbTimestamp( + subDays( + match.playedAt!, + MERGE_CANDIDATE_PLAYED_AT_WINDOW_DAYS, + ).getTime(), + ), + ), + eb( + "playedAt", + "<=", + toDbTimestamp(match.playedAt!)! + + MERGE_CANDIDATE_PLAYED_AT_WINDOW_DAYS * 24 * 60 * 60, + ), + ]), + eb.and([ + eb("playedAt", "is", null), + eb("createdAt", ">=", createdAfter), + ]), + ]), + ), + ) + .$if(match.playedAt === null, (qb) => + qb.where("createdAt", ">=", createdAfter), + ) + .orderBy("createdAt", "desc") + .limit(MERGE_CANDIDATE_LIMIT) .execute(); - return result.reduce( - (acc, cur) => acc + Number(cur.numInsertedOrUpdatedRows ?? 0), - 0, + return ( + candidates.find((candidate) => + Matches.isSameMatch(candidate.data, match), + ) ?? null ); } -function eventHash({ +/** wall-clock ms → database timestamp (seconds) */ +function toDbTimestamp(ms: number | null): number | null { + return ms === null ? null : Math.floor(ms / 1000); +} + +function matchHash({ tournamentId, povUserId, - event, + match, }: { tournamentId: number | null; povUserId: number | null; - event: IngestedEventInput; + match: ScannerMatch; }) { return createHash("sha256") - .update( - JSON.stringify([ - tournamentId, - povUserId, - event.type, - event.t, - event.data, - ]), - ) + .update(JSON.stringify([tournamentId, povUserId, match])) .digest("hex"); } diff --git a/app/features/scanner-ingest/actions/scanner-ingest.server.ts b/app/features/scanner-ingest/actions/scanner-ingest.server.ts index 96401bdcb..44c2e9197 100644 --- a/app/features/scanner-ingest/actions/scanner-ingest.server.ts +++ b/app/features/scanner-ingest/actions/scanner-ingest.server.ts @@ -1,25 +1,19 @@ import type { ActionFunction } from "react-router"; import { requireUser } from "~/features/auth/core/user.server"; +import type { ScannerMatch } from "~/features/scanner/core/scanner-match"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { logger } from "~/utils/logger"; -import { - badRequestIfFalsy, - canAccessLohiEndpoint, - parseBody, -} from "~/utils/remix.server"; +import { badRequestIfFalsy, parseBody } from "~/utils/remix.server"; import * as Scoreboards from "../core/Scoreboards"; import * as ScannerIngestRepository from "../ScannerIngestRepository.server"; -import { - type IngestedEventInput, - ingestBodySchema, -} from "../scanner-ingest-schemas"; +import { ingestBodySchema } from "../scanner-ingest-schemas"; // xxx: dont only attach scoreboard on ingest, also when score is reported (for e.g. tournament stuff) // xxx: check why http://localhost:7001/to/4066/matches/139247?tab=result layout bad // xxx: check why http://localhost:7001/to/4066/matches/139247?tab=result first game not uploaded // xxx: this needs some thinking and documentation to cover all the cases that can be ingested export const action: ActionFunction = async ({ request }) => { - const user = canAccessLohiEndpoint(request) ? null : requireUser(); + const user = requireUser(); const data = await parseBody({ request, schema: ingestBodySchema }); @@ -29,6 +23,7 @@ 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 @@ -38,13 +33,13 @@ export const action: ActionFunction = async ({ request }) => { await ScannerIngestRepository.tournamentStartTime(tournamentId), ); } else if (povUserId) { - // no explicit tournament: resolve from the scoreboards' content first - // (the mode+stage sequence plus roster sides is near-unique in a - // user's history), then from when the events' match was played (a - // replay scoreboard carries the original recording time). Single- - // scoreboard requests (live sends) skip straight to the timestamp — - // content resolution needs a sequence to be decisive. - if (countScoreboardEvents(data.events) >= 2) { + // 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: @@ -52,19 +47,19 @@ export const action: ActionFunction = async ({ request }) => { Math.floor(Date.now() / 1000) - CONTENT_RESOLUTION_WINDOW_SECONDS, }); tournamentId = Scoreboards.resolveTournamentId({ - events: data.events, + matches: data.matches, games, }); if (tournamentId) { candidateGames = games; logger.debug( - `ingest: resolved tournament ${tournamentId} for user ${povUserId} from scoreboard contents ` + + `ingest: resolved tournament ${tournamentId} for user ${povUserId} from match contents ` + `(${games.length} candidate games)`, ); } } if (!tournamentId) { - const at = anchorTime(data.events); + const at = anchorTime(data.matches); tournamentId = await ScannerIngestRepository.tournamentIdAt({ userId: povUserId, at, @@ -77,12 +72,13 @@ export const action: ActionFunction = async ({ request }) => { } } - const storedEventsCount = await ScannerIngestRepository.addEvents({ - tournamentId, - povUserId, - submitterUserId: user?.id ?? null, - events: data.events, - }); + const { insertedCount, mergedCount, effectiveMatches } = + await ScannerIngestRepository.addOrMergeMatches({ + tournamentId, + povUserId, + submitterUserId: user?.id ?? null, + matches: data.matches, + }); let storedScoreboardsCount = 0; if (tournamentId && povUserId) { @@ -97,7 +93,7 @@ export const action: ActionFunction = async ({ request }) => { }); const matched = Scoreboards.matchedScoreboards({ - events: data.events, + matches: effectiveMatches, games, }); @@ -116,12 +112,16 @@ export const action: ActionFunction = async ({ request }) => { ); } else { logger.debug( - `ingest: stored ${storedEventsCount} events without a match context ` + + `ingest: stored ${insertedCount} matches (${mergedCount} merged) without a match context ` + `(tournamentId=${tournamentId}, povUserId=${povUserId})`, ); } - return { storedEventsCount, storedScoreboardsCount }; + return { + storedMatchesCount: insertedCount, + mergedMatchesCount: mergedCount, + storedScoreboardsCount, + }; }; /** @@ -130,37 +130,20 @@ export const action: ActionFunction = async ({ request }) => { */ const CONTENT_RESOLUTION_WINDOW_SECONDS = 365 * 24 * 60 * 60; -function countScoreboardEvents(events: IngestedEventInput[]): number { - return events.filter( - (event) => event.type === "Scoreboard" || event.type === "ScoreboardReplay", - ).length; +/** Matches that could attach to a tournament game: their winner is known. */ +function countAttachableMatches(matches: ScannerMatch[]): number { + return matches.filter((match) => match.winner !== null).length; } /** - * The wall-clock time the events' match was (probably) played: the latest - * scoreboard's recording time (replays) or detection time, falling back to - * any event's detection time and finally to "now". + * The wall-clock time the request's matches were (probably) played: the + * latest match's playedAt, falling back to "now". */ -function anchorTime(events: IngestedEventInput[]): number { - const anchors = events - .filter( - (event) => - event.type === "Scoreboard" || event.type === "ScoreboardReplay", - ) - .map( - (event) => - (event.type === "ScoreboardReplay" ? event.recordedAt : null) ?? - event.detectedAt, - ) - .filter( - (anchor): anchor is number => anchor !== undefined && anchor !== null, - ); - if (anchors.length > 0) return Math.max(...anchors); - - const detections = events - .map((event) => event.detectedAt) - .filter((detectedAt): detectedAt is number => detectedAt !== undefined); - if (detections.length > 0) return Math.max(...detections); +function anchorTime(matches: ScannerMatch[]): number { + const playedAts = matches + .map((match) => match.playedAt) + .filter((playedAt): playedAt is number => playedAt !== null); + if (playedAts.length > 0) return Math.max(...playedAts); return Date.now(); } diff --git a/app/features/scanner-ingest/core/Matches.test.ts b/app/features/scanner-ingest/core/Matches.test.ts new file mode 100644 index 000000000..2da75b526 --- /dev/null +++ b/app/features/scanner-ingest/core/Matches.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from "vitest"; +import type { + ScannerMatch, + ScannerMatchPlayer, +} from "~/features/scanner/core/scanner-match"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import * as Matches from "./Matches"; + +const NAMES = ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"]; +const WEAPONS: MainWeaponId[] = [10, 20, 30, 40, 50, 60, 70, 80]; + +function player( + name: string | null, + weaponId: MainWeaponId | null, + partial: Partial = {}, +): ScannerMatchPlayer { + return { + name, + weaponId, + paint: null, + ka: null, + d: null, + s: null, + ...partial, + }; +} + +function testMatch(partial: Partial = {}): ScannerMatch { + return { + startsAt: 100, + endsAt: 400, + playedAt: null, + lobby: "PRIVATE", + mode: "SZ", + stage: 0, + matchScores: null, + replayCode: null, + cast: false, + teams: [ + { + score: 100, + players: NAMES.slice(0, 4).map((n, i) => player(n, WEAPONS[i]!)), + }, + { + score: 52, + players: NAMES.slice(4).map((n, i) => player(n, WEAPONS[4 + i]!)), + }, + ], + winner: 0, + pov: null, + ...partial, + }; +} + +/** The same rosters seen from the other side (e.g. a minimap alpha/bravo view). */ +function sideSwapped(match: ScannerMatch): ScannerMatch { + return { + ...match, + teams: [match.teams[1], match.teams[0]], + winner: match.winner === null ? null : match.winner === 0 ? 1 : 0, + matchScores: + match.matchScores === null + ? null + : [match.matchScores[1], match.matchScores[0]], + }; +} + +describe("canonicalMatch", () => { + it("serializes identically regardless of input key order", () => { + const match = testMatch(); + const reordered = JSON.parse( + JSON.stringify({ + winner: match.winner, + teams: match.teams, + cast: match.cast, + replayCode: match.replayCode, + matchScores: match.matchScores, + stage: match.stage, + mode: match.mode, + lobby: match.lobby, + playedAt: match.playedAt, + endsAt: match.endsAt, + startsAt: match.startsAt, + pov: match.pov, + }), + ) as ScannerMatch; + + expect(JSON.stringify(Matches.canonicalMatch(reordered))).toBe( + JSON.stringify(Matches.canonicalMatch(match)), + ); + }); +}); + +describe("isSameMatch", () => { + it("recognizes an identical match", () => { + expect(Matches.isSameMatch(testMatch(), testMatch())).toBe(true); + }); + + it("matching replay codes are a strong key", () => { + const a = testMatch({ + replayCode: "RABC-DEFG-HIJK-LMNO", + teams: testMatch().teams, + }); + const b = testMatch({ + replayCode: "RABC-DEFG-HIJK-LMNO", + teams: [ + { score: null, players: [] }, + { score: null, players: [] }, + ], + winner: null, + }); + expect(Matches.isSameMatch(a, b)).toBe(true); + }); + + it("tolerates OCR jitter in the replay code", () => { + const a = testMatch({ replayCode: "RABC-DEFG-HIJK-LMNO" }); + const b = testMatch({ replayCode: "RA8C-DEFG-HIJK-LMN0" }); + expect(Matches.isSameMatch(a, b)).toBe(true); + }); + + it("clearly different replay codes contradict identity", () => { + const a = testMatch({ replayCode: "RABC-DEFG-HIJK-LMNO" }); + const b = testMatch({ replayCode: "RZYX-WVUT-SRQP-ONML" }); + expect(Matches.isSameMatch(a, b)).toBe(false); + }); + + it("close play times identify a match", () => { + const a = testMatch({ playedAt: 1_700_000_000_000 }); + const b = testMatch({ + playedAt: 1_700_000_000_000 + 5 * 60 * 1000, + teams: [ + { score: null, players: [] }, + { score: null, players: [] }, + ], + winner: null, + }); + expect(Matches.isSameMatch(a, b)).toBe(true); + }); + + it("far-apart play times contradict identity even with equal rosters", () => { + const a = testMatch({ playedAt: 1_700_000_000_000 }); + const b = testMatch({ playedAt: 1_700_000_000_000 + 60 * 60 * 1000 }); + expect(Matches.isSameMatch(a, b)).toBe(false); + }); + + it("differing modes or stages contradict identity", () => { + expect( + Matches.isSameMatch(testMatch({ mode: "SZ" }), testMatch({ mode: "TC" })), + ).toBe(false); + expect( + Matches.isSameMatch(testMatch({ stage: 0 }), testMatch({ stage: 1 })), + ).toBe(false); + }); + + it("a null mode does not contradict a read one", () => { + expect( + Matches.isSameMatch(testMatch({ mode: null }), testMatch({ mode: "TC" })), + ).toBe(true); + }); + + it("roster overlap identifies a match even side-swapped", () => { + expect(Matches.isSameMatch(testMatch(), sideSwapped(testMatch()))).toBe( + true, + ); + }); + + it("roster overlap survives a couple of misread names", () => { + const b = testMatch(); + b.teams[0].players[0] = player("misread", WEAPONS[0]!); + b.teams[1].players[3] = player(null, WEAPONS[7]!); + expect(Matches.isSameMatch(testMatch(), b)).toBe(true); + }); + + it("weapons alone identify a match when names are unread (minimap vs scoreboard)", () => { + const minimap = testMatch({ + winner: null, + lobby: null, + teams: [ + { + score: null, + players: WEAPONS.slice(0, 4).map((w) => player(null, w)), + }, + { + score: null, + players: WEAPONS.slice(4).map((w) => player(null, w)), + }, + ], + }); + expect(Matches.isSameMatch(testMatch(), minimap)).toBe(true); + }); + + it("unrelated matches are not the same", () => { + const other = testMatch({ + teams: [ + { + score: 88, + players: ["a", "b", "c", "d"].map((n, i) => + player(n, (100 + 10 * i) as MainWeaponId), + ), + }, + { + score: 12, + players: ["e", "f", "g", "h"].map((n, i) => + player(n, (200 + 10 * i) as MainWeaponId), + ), + }, + ], + }); + expect(Matches.isSameMatch(testMatch(), other)).toBe(false); + }); +}); + +describe("mergeMatches", () => { + it("fills stored nulls and reports no change when nothing was added", () => { + const existing = testMatch({ mode: null, playedAt: null }); + const incoming = testMatch({ mode: "SZ", playedAt: 1_700_000_000_000 }); + + const first = Matches.mergeMatches(existing, incoming); + expect(first.changed).toBe(true); + expect(first.merged.mode).toBe("SZ"); + expect(first.merged.playedAt).toBe(1_700_000_000_000); + + const second = Matches.mergeMatches(first.merged, incoming); + expect(second.changed).toBe(false); + }); + + it("stored values win on conflict", () => { + const existing = testMatch({ stage: 0 }); + const incoming = testMatch({ stage: null }); + incoming.teams[0].players[0] = player("other", 999 as MainWeaponId); + + const { merged } = Matches.mergeMatches(existing, incoming); + expect(merged.stage).toBe(0); + expect(merged.teams[0].players[0]!.name).toBe("w1"); + }); + + it("aligns a side-swapped incoming match before merging", () => { + const existing = testMatch({ winner: null, matchScores: null }); + const incoming = sideSwapped( + testMatch({ matchScores: [3, 1], playedAt: 1_700_000_000_000 }), + ); + + const { merged } = Matches.mergeMatches(existing, incoming); + expect(merged.winner).toBe(0); + expect(merged.matchScores).toEqual([3, 1]); + expect(merged.teams[0].players.map((p) => p.name)).toEqual( + NAMES.slice(0, 4), + ); + }); + + it("merges player rows by name, keeping stored stats and adding missing ones", () => { + const existing = testMatch(); + existing.teams[1].players[1] = player("l2", null); + const incoming = testMatch(); + incoming.teams[1].players = [ + player("l2", WEAPONS[5]!, { ka: 12, abilities: [["ISM"]] }), + player("l1", WEAPONS[4]!), + player("l3", WEAPONS[6]!), + player("l4", WEAPONS[7]!), + ]; + + const { merged } = Matches.mergeMatches(existing, incoming); + const l2 = merged.teams[1].players[1]!; + expect(l2.weaponId).toBe(WEAPONS[5]); + expect(l2.ka).toBe(12); + expect(l2.abilities).toEqual([["ISM"]]); + }); + + it("fills empty teams from the incoming match", () => { + const existing = testMatch({ + winner: null, + teams: [ + { score: null, players: [] }, + { score: null, players: [] }, + ], + replayCode: "RABC-DEFG-HIJK-LMNO", + }); + const incoming = testMatch({ replayCode: "RABC-DEFG-HIJK-LMNO" }); + + const { merged, changed } = Matches.mergeMatches(existing, incoming); + expect(changed).toBe(true); + expect(merged.winner).toBe(0); + expect(merged.teams[0].players.map((p) => p.name)).toEqual( + NAMES.slice(0, 4), + ); + expect(merged.teams[0].score).toBe(100); + }); +}); diff --git a/app/features/scanner-ingest/core/Matches.ts b/app/features/scanner-ingest/core/Matches.ts new file mode 100644 index 000000000..4ab69649c --- /dev/null +++ b/app/features/scanner-ingest/core/Matches.ts @@ -0,0 +1,339 @@ +/** + * Pure logic for stored scanner matches: canonical serialization (hashing), + * deciding whether two partial ScannerMatches describe the same game, and + * merging a newly ingested partial into a stored one. + */ +import type { + ScannerMatch, + ScannerMatchPlayer, + ScannerMatchTeam, +} from "~/features/scanner/core/scanner-match"; + +/** + * Replay codes are random enough that two different games share almost no + * positions; this many differing characters still reads as OCR jitter of + * the same code, at or above it as a different game. + */ +const REPLAY_CODE_MAX_OCR_ERRORS = 3; + +/** Two reads of one game land within this of each other (clock skew, retries). */ +const PLAYED_AT_AFFINITY_MS = 10 * 60 * 1000; +/** Reads further apart than this cannot be the same few-minute game. */ +const PLAYED_AT_CONTRADICTION_MS = 20 * 60 * 1000; + +/** How many of the 8 rosters' readable names must align for identity. */ +const MIN_NAME_OVERLAP = 6; +/** How many of the 8 weapon slots must align (with ≥7 read on both sides). */ +const MIN_WEAPON_OVERLAP = 7; +const MIN_WEAPON_SLOTS_READ = 7; + +const PLAYERS_PER_TEAM = 4; + +/** + * Rebuilds a match with a fixed key order so `JSON.stringify` of the result + * is stable regardless of how the input was constructed — the hashing and + * change-detection representation. + */ +export function canonicalMatch(match: ScannerMatch): ScannerMatch { + return { + startsAt: match.startsAt, + endsAt: match.endsAt, + playedAt: match.playedAt, + lobby: match.lobby, + mode: match.mode, + stage: match.stage, + matchScores: + match.matchScores === null + ? null + : [match.matchScores[0], match.matchScores[1]], + replayCode: match.replayCode, + cast: match.cast, + teams: [canonicalTeam(match.teams[0]), canonicalTeam(match.teams[1])], + winner: match.winner, + pov: + match.pov === null + ? null + : { team: match.pov.team, index: match.pov.index }, + }; +} + +/** + * Whether two (possibly partial) matches describe the same game. Callers + * pre-scope candidates to the same tournament + POV user; this checks the + * contents: contradicting mode/stage/replay-code/play-time rules identity + * out, then a matching replay code, close play times, or an aligning roster + * (names, or weapons when names are unread) rules it in. + */ +export function isSameMatch(a: ScannerMatch, b: ScannerMatch): boolean { + if (a.mode !== null && b.mode !== null && a.mode !== b.mode) return false; + if (a.stage !== null && b.stage !== null && a.stage !== b.stage) return false; + + const codeDiff = replayCodeDiff(a.replayCode, b.replayCode); + if (codeDiff !== null && codeDiff > REPLAY_CODE_MAX_OCR_ERRORS) return false; + + const playedDiff = + a.playedAt !== null && b.playedAt !== null + ? Math.abs(a.playedAt - b.playedAt) + : null; + if (playedDiff !== null && playedDiff > PLAYED_AT_CONTRADICTION_MS) { + return false; + } + + if (codeDiff !== null) return true; + if (playedDiff !== null && playedDiff <= PLAYED_AT_AFFINITY_MS) return true; + + const aligned = bestAlignment(a, b); + if (aligned.nameOverlap >= MIN_NAME_OVERLAP) return true; + if ( + aligned.weaponOverlap >= MIN_WEAPON_OVERLAP && + weaponSlotsRead(a) >= MIN_WEAPON_SLOTS_READ && + weaponSlotsRead(b) >= MIN_WEAPON_SLOTS_READ + ) { + return true; + } + return false; +} + +/** + * Merges a newly ingested partial into the stored match: the incoming teams + * are first aligned to the stored orientation (a scoreboard match's teams[0] + * is the winner side while a minimap match's is alpha), then every field + * fills stored nulls, stored values winning on conflict (mirroring the + * scoreboard attachment's first-ingest-wins). `changed` is false when the + * merge added nothing, so callers can skip the write. + */ +export function mergeMatches( + existing: ScannerMatch, + incoming: ScannerMatch, +): { merged: ScannerMatch; changed: boolean } { + const oriented = + bestAlignment(existing, incoming).orientation === "swapped" + ? swapSides(incoming) + : incoming; + + const merged: ScannerMatch = { + startsAt: existing.startsAt ?? oriented.startsAt, + endsAt: existing.endsAt ?? oriented.endsAt, + playedAt: existing.playedAt ?? oriented.playedAt, + lobby: existing.lobby ?? oriented.lobby, + mode: existing.mode ?? oriented.mode, + stage: existing.stage ?? oriented.stage, + matchScores: mergeScorePair(existing.matchScores, oriented.matchScores), + replayCode: existing.replayCode ?? oriented.replayCode, + cast: existing.cast || oriented.cast, + teams: [ + mergeTeam(existing.teams[0], oriented.teams[0]), + mergeTeam(existing.teams[1], oriented.teams[1]), + ], + winner: existing.winner ?? oriented.winner, + pov: existing.pov ?? oriented.pov, + }; + + return { + merged, + changed: + JSON.stringify(canonicalMatch(merged)) !== + JSON.stringify(canonicalMatch(existing)), + }; +} + +/** Lowercased, width-normalized in-game name without the #discriminator. */ +export function normalizeInGameName(name: string): string { + return name.split("#")[0]!.normalize("NFKC").trim().toLowerCase(); +} + +function canonicalTeam(team: ScannerMatchTeam): ScannerMatchTeam { + return { + score: team.score, + players: team.players.map(canonicalPlayer), + }; +} + +function canonicalPlayer(player: ScannerMatchPlayer): ScannerMatchPlayer { + return { + name: player.name, + weaponId: player.weaponId, + paint: player.paint, + ka: player.ka, + d: player.d, + s: player.s, + ...(player.abilities ? { abilities: player.abilities } : null), + }; +} + +/** + * Positions at which two replay codes differ; null when either is unread. + * A length mismatch counts every position of the longer code. + */ +function replayCodeDiff(a: string | null, b: string | null): number | null { + if (a === null || b === null) return null; + const longer = Math.max(a.length, b.length); + let diff = longer - Math.min(a.length, b.length); + for (let i = 0; i < Math.min(a.length, b.length); i++) { + if (a[i] !== b[i]) diff++; + } + return diff; +} + +interface Alignment { + orientation: "straight" | "swapped"; + /** aligned readable-name matches across both team pairs (0-8) */ + nameOverlap: number; + /** aligned weapon multiset overlap across both team pairs (0-8) */ + weaponOverlap: number; +} + +/** + * How `b`'s teams best map onto `a`'s: as-is or sides swapped, scored by + * name and weapon overlap. Ties keep "straight". + */ +function bestAlignment(a: ScannerMatch, b: ScannerMatch): Alignment { + const straight = pairScore(a, b.teams[0], b.teams[1]); + const swapped = pairScore(a, b.teams[1], b.teams[0]); + const straightTotal = straight.nameOverlap + straight.weaponOverlap; + const swappedTotal = swapped.nameOverlap + swapped.weaponOverlap; + return swappedTotal > straightTotal + ? { orientation: "swapped", ...swapped } + : { orientation: "straight", ...straight }; +} + +function pairScore( + a: ScannerMatch, + bFirst: ScannerMatchTeam, + bSecond: ScannerMatchTeam, +): { nameOverlap: number; weaponOverlap: number } { + return { + nameOverlap: + nameOverlap(a.teams[0], bFirst) + nameOverlap(a.teams[1], bSecond), + weaponOverlap: + weaponOverlap(a.teams[0], bFirst) + weaponOverlap(a.teams[1], bSecond), + }; +} + +function nameOverlap(a: ScannerMatchTeam, b: ScannerMatchTeam): number { + const bNames = new Set( + b.players + .map((player) => (player.name ? normalizeInGameName(player.name) : "")) + .filter(Boolean), + ); + return a.players.filter( + (player) => player.name && bNames.has(normalizeInGameName(player.name)), + ).length; +} + +function weaponOverlap(a: ScannerMatchTeam, b: ScannerMatchTeam): number { + const pool = b.players + .map((player) => player.weaponId) + .filter((id) => id !== null); + let overlap = 0; + for (const player of a.players) { + if (player.weaponId === null) continue; + const i = pool.indexOf(player.weaponId); + if (i === -1) continue; + pool.splice(i, 1); + overlap++; + } + return overlap; +} + +function weaponSlotsRead(match: ScannerMatch): number { + return match.teams.flatMap((team) => + team.players.filter((player) => player.weaponId !== null), + ).length; +} + +function swapSides(match: ScannerMatch): ScannerMatch { + return { + ...match, + teams: [match.teams[1], match.teams[0]], + winner: match.winner === null ? null : match.winner === 0 ? 1 : 0, + pov: + match.pov === null + ? null + : { ...match.pov, team: match.pov.team === 0 ? 1 : 0 }, + matchScores: + match.matchScores === null + ? null + : [match.matchScores[1], match.matchScores[0]], + }; +} + +function mergeScorePair( + existing: [number | null, number | null] | null, + incoming: [number | null, number | null] | null, +): [number | null, number | null] | null { + if (existing === null) return incoming; + if (incoming === null) return existing; + return [existing[0] ?? incoming[0], existing[1] ?? incoming[1]]; +} + +/** + * Merge one team's rows: each stored row takes its incoming counterpart — + * matched by readable name, then by a weapon unique among the unmatched, + * then by position — field-wise with stored values winning. Incoming rows + * no stored row claimed append while the team stays ≤4. + */ +function mergeTeam( + existing: ScannerMatchTeam, + incoming: ScannerMatchTeam, +): ScannerMatchTeam { + const pool = incoming.players.map((player) => ({ player, used: false })); + const counterparts: (ScannerMatchPlayer | null)[] = existing.players.map( + (player) => { + const name = player.name ? normalizeInGameName(player.name) : ""; + if (!name) return null; + const hit = pool.find( + (entry) => + !entry.used && + entry.player.name !== null && + normalizeInGameName(entry.player.name) === name, + ); + if (!hit) return null; + hit.used = true; + return hit.player; + }, + ); + existing.players.forEach((player, i) => { + if (counterparts[i] || player.weaponId === null) return; + const hits = pool.filter( + (entry) => !entry.used && entry.player.weaponId === player.weaponId, + ); + if (hits.length !== 1) return; + hits[0]!.used = true; + counterparts[i] = hits[0]!.player; + }); + existing.players.forEach((_, i) => { + if (counterparts[i]) return; + const hit = pool[i]?.used === false ? pool[i]! : pool.find((e) => !e.used); + if (!hit) return; + hit.used = true; + counterparts[i] = hit.player; + }); + + const players = existing.players.map((player, i) => { + const counterpart = counterparts[i]; + return counterpart ? mergePlayer(player, counterpart) : player; + }); + for (const entry of pool) { + if (entry.used || players.length >= PLAYERS_PER_TEAM) continue; + players.push(entry.player); + } + + return { score: existing.score ?? incoming.score, players }; +} + +function mergePlayer( + existing: ScannerMatchPlayer, + incoming: ScannerMatchPlayer, +): ScannerMatchPlayer { + const abilities = existing.abilities ?? incoming.abilities; + return { + name: existing.name ?? incoming.name, + weaponId: existing.weaponId ?? incoming.weaponId, + paint: existing.paint ?? incoming.paint, + ka: existing.ka ?? incoming.ka, + d: existing.d ?? incoming.d, + s: existing.s ?? incoming.s, + ...(abilities ? { abilities } : null), + }; +} diff --git a/app/features/scanner-ingest/core/Scoreboards.test.ts b/app/features/scanner-ingest/core/Scoreboards.test.ts index e4a1767cf..6671de103 100644 --- a/app/features/scanner-ingest/core/Scoreboards.test.ts +++ b/app/features/scanner-ingest/core/Scoreboards.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it } from "vitest"; +import type { + ScannerMatch, + ScannerMatchPlayer, +} from "~/features/scanner/core/scanner-match"; import type { ScannerAbility, ScannerLobby, @@ -8,7 +12,6 @@ import type { ModeShort, StageId, } from "~/modules/in-game-lists/types"; -import type { IngestedEventInput } from "../scanner-ingest-schemas"; import * as Scoreboards from "./Scoreboards"; const WINNER_TEAM_ID = 100; @@ -33,7 +36,7 @@ function testGame( }; } -function testScoreboard({ +function testMatch({ t = 60, mode = "SZ", stage = 0, @@ -51,34 +54,57 @@ function testScoreboard({ weapons?: (MainWeaponId | null)[]; abilities?: Record; povIndex?: number | null; -} = {}): IngestedEventInput { +} = {}): ScannerMatch { + const players = names.map( + (name, i): ScannerMatchPlayer => ({ + name: name || null, + weaponId: weapons[i]!, + paint: 1000, + ka: 10, + d: 5, + s: 2, + ...(abilities[i] ? { abilities: abilities[i] } : null), + }), + ); return { - type: "Scoreboard", - t, - confidence: 0.9, - data: { - lobby, - mode, - stage, - scores: [100, 52], - players: names.map((name, i) => ({ - name, - weaponId: weapons[i]!, - paint: 1000, - ka: 10, - d: 5, - s: 2, - ...(abilities[i] ? { abilities: abilities[i] } : null), - })), - povIndex, - }, + startsAt: t, + endsAt: t, + playedAt: null, + lobby, + mode, + stage, + matchScores: null, + replayCode: null, + cast: false, + teams: [ + { score: 100, players: players.slice(0, 4) }, + { score: 52, players: players.slice(4) }, + ], + winner: 0, + pov: + povIndex === null + ? null + : { team: povIndex < 4 ? 0 : 1, index: povIndex % 4 }, + }; +} + +/** The same game reported with sides in the other on-screen order. */ +function swapSides(match: ScannerMatch): ScannerMatch { + return { + ...match, + teams: [match.teams[1], match.teams[0]], + winner: match.winner === null ? null : match.winner === 0 ? 1 : 0, + pov: + match.pov === null + ? null + : { ...match.pov, team: match.pov.team === 0 ? 1 : 0 }, }; } describe("matchedScoreboards", () => { - it("turns a matching game's scoreboard into stored scoreboard data", () => { + it("turns a matching game's match into stored scoreboard data", () => { const scoreboards = Scoreboards.matchedScoreboards({ - events: [testScoreboard({ povIndex: 2 })], + matches: [testMatch({ povIndex: 2 })], games: [testGame()], }); @@ -105,6 +131,40 @@ describe("matchedScoreboards", () => { }); }); + 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("skips matches without a known winner", () => { + const scoreboards = Scoreboards.matchedScoreboards({ + matches: [{ ...testMatch(), winner: null }], + games: [testGame()], + }); + + expect(scoreboards).toHaveLength(0); + }); + + it("skips matches whose teams were not fully seen", () => { + const partial = testMatch(); + partial.teams[1].players.pop(); + const scoreboards = Scoreboards.matchedScoreboards({ + matches: [partial], + games: [testGame()], + }); + + expect(scoreboards).toHaveLength(0); + }); + it("carries ingested player abilities through to the stored scoreboard", () => { const build: ScannerAbility[][] = [ ["ISM", "ISS", "ISS", "ISS"], @@ -112,7 +172,7 @@ describe("matchedScoreboards", () => { ["SSU", "RSU", "RSU", "RSU"], ]; const scoreboards = Scoreboards.matchedScoreboards({ - events: [testScoreboard({ abilities: { 5: build } })], + matches: [testMatch({ abilities: { 5: build } })], games: [testGame()], }); @@ -122,7 +182,7 @@ describe("matchedScoreboards", () => { it("skips a game whose stored scoreboard has different players", () => { const scoreboards = Scoreboards.matchedScoreboards({ - events: [testScoreboard()], + matches: [testMatch()], games: [ testGame({ matchGameResultId: 11, @@ -137,7 +197,7 @@ describe("matchedScoreboards", () => { it("matches a re-detection of a stored scoreboard to the same game despite misread names", () => { const scoreboards = Scoreboards.matchedScoreboards({ - events: [testScoreboard()], + matches: [testMatch()], games: [ testGame({ matchGameResultId: 11, @@ -161,9 +221,7 @@ describe("matchedScoreboards", () => { it("does not count unreadable names towards stored scoreboard re-detection", () => { const scoreboards = Scoreboards.matchedScoreboards({ - events: [ - testScoreboard({ names: ["", "", "", "", "l1", "l2", "l3", "l4"] }), - ], + matches: [testMatch({ names: ["", "", "", "", "l1", "l2", "l3", "l4"] })], games: [ testGame({ matchGameResultId: 11, @@ -176,9 +234,9 @@ describe("matchedScoreboards", () => { expect(scoreboards.map((s) => s.matchGameResultId)).toEqual([12]); }); - it("matches scoreboards to games by mode and stage", () => { + it("matches matches to games by mode and stage", () => { const scoreboards = Scoreboards.matchedScoreboards({ - events: [testScoreboard({ mode: "RM", stage: 1, t: 60 })], + matches: [testMatch({ mode: "RM", stage: 1, t: 60 })], games: [ testGame({ mapIndex: 0, mode: "SZ", stageId: 0 as StageId }), testGame({ mapIndex: 1, mode: "RM", stageId: 1 as StageId }), @@ -190,12 +248,12 @@ describe("matchedScoreboards", () => { it("assigns two games on the same mode and stage in chronological order", () => { const scoreboards = Scoreboards.matchedScoreboards({ - events: [ - testScoreboard({ + matches: [ + testMatch({ t: 60, names: ["a", "b", "c", "d", "e", "f", "g", "h"], }), - testScoreboard({ + testMatch({ t: 5000, names: ["i", "j", "k", "l", "m", "n", "o", "p"], }), @@ -216,9 +274,9 @@ describe("matchedScoreboards", () => { ).toBe(2); }); - it("skips duplicate detections of the same scoreboard", () => { + it("skips duplicate detections of the same game", () => { const scoreboards = Scoreboards.matchedScoreboards({ - events: [testScoreboard({ t: 60 }), testScoreboard({ t: 65 })], + matches: [testMatch({ t: 60 }), testMatch({ t: 65 })], games: [ testGame({ tournamentMatchId: 1, playedAt: 1000 }), testGame({ tournamentMatchId: 2, playedAt: 2000 }), @@ -229,18 +287,18 @@ describe("matchedScoreboards", () => { expect(scoreboards[0]!.tournamentMatchId).toBe(1); }); - it("skips scoreboards from other lobbies", () => { + it("skips matches from other lobbies", () => { const scoreboards = Scoreboards.matchedScoreboards({ - events: [testScoreboard({ lobby: "X" })], + matches: [testMatch({ lobby: "X" })], games: [testGame()], }); expect(scoreboards).toHaveLength(0); }); - it("skips scoreboards with unreadable mode or stage", () => { + it("skips matches with unreadable mode or stage", () => { const scoreboards = Scoreboards.matchedScoreboards({ - events: [testScoreboard({ mode: null }), testScoreboard({ stage: null })], + matches: [testMatch({ mode: null }), testMatch({ stage: null })], games: [testGame()], }); @@ -249,8 +307,8 @@ describe("matchedScoreboards", () => { it("keeps players with unread weapon or empty name", () => { const scoreboards = Scoreboards.matchedScoreboards({ - events: [ - testScoreboard({ + matches: [ + testMatch({ names: ["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"], weapons: [10, 10, null, 10, 20, 20, 20, 20], }), @@ -266,27 +324,11 @@ describe("matchedScoreboards", () => { expect(players[2]!.ka).toBe(10); }); - it("skips non-scoreboard events", () => { + it("skips matches that have no matching game left", () => { const scoreboards = Scoreboards.matchedScoreboards({ - events: [ - { - type: "MapStart", - t: 10, - confidence: 0.9, - data: { mode: "SZ", stage: 0 }, - }, - ], - games: [testGame()], - }); - - expect(scoreboards).toHaveLength(0); - }); - - it("skips scoreboards that have no matching game left", () => { - const scoreboards = Scoreboards.matchedScoreboards({ - events: [ - testScoreboard({ t: 60 }), - testScoreboard({ + matches: [ + testMatch({ t: 60 }), + testMatch({ t: 5000, names: ["i", "j", "k", "l", "m", "n", "o", "p"], }), @@ -297,37 +339,13 @@ describe("matchedScoreboards", () => { expect(scoreboards).toHaveLength(1); }); - it("uses ScoreboardReplay events too", () => { - const scoreboard = testScoreboard(); + it("skips a game whose known rosters contradict the match sides", () => { const scoreboards = Scoreboards.matchedScoreboards({ - events: [ - { - ...scoreboard, - type: "ScoreboardReplay", - data: { - ...(scoreboard.data as Extract< - IngestedEventInput, - { type: "Scoreboard" } - >["data"]), - timestamp: "3/7/2026 22:28", - replayCode: "ABCD-EFGH-IJKL-MNOP", - matchScores: [100, 52], - }, - }, - ], - games: [testGame()], - }); - - expect(scoreboards).toHaveLength(1); - }); - - it("skips a game whose known rosters contradict the scoreboard sides", () => { - const scoreboards = Scoreboards.matchedScoreboards({ - events: [testScoreboard()], + matches: [testMatch()], games: [ testGame({ tournamentMatchId: 1, - // scoreboard winners are w1-w4 but this game was won by the l* players + // match winners are w1-w4 but this game was won by the l* players winnerInGameNames: ["l1#1234", "l2"], loserInGameNames: ["w1", "w2"], playedAt: 1000, @@ -346,8 +364,8 @@ describe("matchedScoreboards", () => { it("matches known in-game names ignoring discriminator, case and unicode width", () => { const scoreboards = Scoreboards.matchedScoreboards({ - events: [ - testScoreboard({ + matches: [ + testMatch({ names: ["W1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"], }), ], @@ -366,8 +384,8 @@ describe("matchedScoreboards", () => { it("keeps players whose name appears twice on the same side", () => { const scoreboards = Scoreboards.matchedScoreboards({ - events: [ - testScoreboard({ + matches: [ + testMatch({ names: ["dupe", "dupe", "w3", "w4", "l1", "l2", "l3", "dupe"], }), ], @@ -381,9 +399,9 @@ describe("matchedScoreboards", () => { it("does not assign a game played before the previously assigned one", () => { const scoreboards = Scoreboards.matchedScoreboards({ - events: [ - testScoreboard({ t: 60, mode: "RM", stage: 1 }), - testScoreboard({ t: 1000, mode: "SZ", stage: 0 }), + matches: [ + testMatch({ t: 60, mode: "RM", stage: 1 }), + testMatch({ t: 1000, mode: "SZ", stage: 0 }), ], games: [ testGame({ @@ -427,13 +445,13 @@ describe("resolveTournamentId", () => { } const seenSequence = [ - testScoreboard({ t: 60, mode: "SZ", stage: 0 }), - testScoreboard({ t: 600, mode: "TC", stage: 1 }), + testMatch({ t: 60, mode: "SZ", stage: 0 }), + testMatch({ t: 600, mode: "TC", stage: 1 }), ]; - it("resolves the tournament whose games match the scoreboard sequence", () => { + it("resolves the tournament whose games match the seen sequence", () => { const tournamentId = Scoreboards.resolveTournamentId({ - events: seenSequence, + matches: seenSequence, games: [ ...tournamentGames(1, [ ["SZ", 0], @@ -449,9 +467,9 @@ describe("resolveTournamentId", () => { expect(tournamentId).toBe(1); }); - it("does not resolve from a single matching scoreboard", () => { + it("does not resolve from a single matching match", () => { const tournamentId = Scoreboards.resolveTournamentId({ - events: [seenSequence[0]!], + matches: [seenSequence[0]!], games: tournamentGames(1, [ ["SZ", 0], ["TC", 1], @@ -467,13 +485,13 @@ describe("resolveTournamentId", () => { ["TC", 1], ]; const tournamentId = Scoreboards.resolveTournamentId({ - events: seenSequence, + matches: seenSequence, games: [ ...tournamentGames(1, sharedMaplist, { winnerInGameNames: ["w1", "w2", "w3", "w4"], loserInGameNames: ["l1", "l2", "l3", "l4"], }), - // the other tournament's rosters contradict the scoreboard sides + // the other tournament's rosters contradict the match sides ...tournamentGames(2, sharedMaplist, { winnerInGameNames: ["l1", "l2", "l3", "l4"], loserInGameNames: ["w1", "w2", "w3", "w4"], @@ -484,11 +502,11 @@ describe("resolveTournamentId", () => { expect(tournamentId).toBe(1); }); - it("skips unreadable scoreboards but resolves from the rest", () => { + it("skips unreadable matches but resolves from the rest", () => { const tournamentId = Scoreboards.resolveTournamentId({ - events: [ + matches: [ seenSequence[0]!, - testScoreboard({ t: 300, stage: null }), + testMatch({ t: 300, stage: null }), seenSequence[1]!, ], games: [ diff --git a/app/features/scanner-ingest/core/Scoreboards.ts b/app/features/scanner-ingest/core/Scoreboards.ts index 3f547f93d..b74ff87b6 100644 --- a/app/features/scanner-ingest/core/Scoreboards.ts +++ b/app/features/scanner-ingest/core/Scoreboards.ts @@ -1,26 +1,21 @@ -import type { ScannerAbility } from "~/features/scanner/scanner-types"; +import type { ScannerMatch } from "~/features/scanner/core/scanner-match"; +import type { + ScannerAbility, + ScannerLobby, +} from "~/features/scanner/scanner-types"; import type { MainWeaponId, ModeShort, StageId, } from "~/modules/in-game-lists/types"; -import type { - IngestedEventInput, - ScoreboardEventInput, -} from "../scanner-ingest-schemas"; +import { normalizeInGameName } from "./Matches"; /** Lobby header value scoreboards of tournament games are expected to have. */ const TOURNAMENT_LOBBY = "PRIVATE"; -/** - * Two scoreboards this close in the source video with identical contents are - * considered duplicate detections of the same game. - */ -const DUPLICATE_SCOREBOARD_WINDOW_SECONDS = 300; - /** * How many of the 8 player rows must carry the same readable name in the - * same position for a scoreboard to count as a re-detection of a game's + * same position for a match to count as a re-detection of a game's * already stored scoreboard (allows a couple of OCR misreads). */ const MIN_STORED_DUPLICATE_NAME_MATCHES = 6; @@ -29,7 +24,7 @@ const MIN_STORED_DUPLICATE_NAME_MATCHES = 6; const PLAYERS_PER_TEAM = 4; /** - * How many scoreboards must align with one tournament's games for content + * How many matches must align with one tournament'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. */ @@ -95,18 +90,18 @@ export interface IngestableGameWithTournament extends IngestableGame { } /** - * Resolves which tournament a request's scoreboards belong to from their - * content alone: the candidate games (the POV user's reported games across + * 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 scoreboards `matchedScoreboards` aligns with its games — the - * same mode+stage sequence walk and roster-side validation that decides - * what would actually be stored. + * 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. */ export function resolveTournamentId({ - events, + matches, games, }: { - events: IngestedEventInput[]; + matches: ScannerMatch[]; games: IngestableGameWithTournament[]; }): number | null { const byTournament = new Map(); @@ -119,7 +114,7 @@ export function resolveTournamentId({ let best: { tournamentId: number; matched: number } | null = null; for (const [tournamentId, tournamentGames] of byTournament) { const matched = matchedScoreboards({ - events, + matches, games: tournamentGames, }).length; if (!best || matched > best.matched) { @@ -132,36 +127,37 @@ export function resolveTournamentId({ } /** - * Matches scoreboard events against the games the POV user played and turns + * Matches ingested matches against the games the POV user played and turns * them into insertable scoreboard rows. * - * Events and games are both walked in chronological order: each scoreboard is + * 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 - * scoreboard rows should overlap the game winner's roster, not the loser's). - * Scoreboards from other lobbies, with unreadable mode/stage or duplicated - * detections of the same game are skipped. + * 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. * - * Scoreboards of one session may arrive over many requests (one per match), - * so games whose scoreboard was stored by an earlier request are skipped — - * unless the incoming scoreboard is a re-detection of the stored one, which - * is matched to the same game so re-sends stay idempotent and another POV's + * 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({ - events, + matches, games, }: { - events: IngestedEventInput[]; + matches: ScannerMatch[]; games: IngestableGame[]; }): MatchedScoreboard[] { - const scoreboards = dedupeScoreboards( - events - .filter(isScoreboardEvent) - .filter( - (event) => !event.data.lobby || event.data.lobby === TOURNAMENT_LOBBY, - ) - .sort((a, b) => a.t - b.t), + 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, @@ -170,23 +166,21 @@ export function matchedScoreboards({ const result: MatchedScoreboard[] = []; let nextGameIdx = 0; - for (const scoreboard of scoreboards) { - const mode = scoreboard.data.mode; - const stageId = scoreboard.data.stage; - if (mode === null || stageId === null) continue; + 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 !== mode || game.stageId !== stageId) continue; + if (game.mode !== view.mode || game.stageId !== view.stage) continue; if (game.storedScoreboardPlayerNames) { - if (!isStoredDuplicate(scoreboard, game.storedScoreboardPlayerNames)) { + if (!isStoredDuplicate(view, game.storedScoreboardPlayerNames)) { continue; } - } else if (!sidesMatchKnownPlayers(scoreboard, game)) { + } else if (!sidesMatchKnownPlayers(view, game)) { continue; } - result.push(scoreboardToMatchedScoreboard({ scoreboard, game })); + result.push(viewToMatchedScoreboard({ view, game })); nextGameIdx = i + 1; break; } @@ -195,47 +189,99 @@ export function matchedScoreboards({ return result; } -function isScoreboardEvent( - event: IngestedEventInput, -): event is ScoreboardEventInput { - return event.type === "Scoreboard" || event.type === "ScoreboardReplay"; +/** + * 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 + * winner is unknown or either team wasn't fully seen. + */ +interface WinnerFirstView { + lobby: ScannerLobby | null; + mode: ModeShort | null; + stage: StageId | null; + scores: [number | null, number | null]; + players: WinnerFirstPlayer[]; + povIndex: number | null; + /** chronological walk key: wall-clock, else video time, else input order */ + order: number; } -function dedupeScoreboards(sorted: ScoreboardEventInput[]) { - const result: ScoreboardEventInput[] = []; +interface WinnerFirstPlayer { + name: string; + weaponId: MainWeaponId | null; + paint: number | null; + ka: number | null; + d: number | null; + s: number | null; + abilities?: ScannerAbility[][]; +} - for (const scoreboard of sorted) { +function winnerFirstView( + match: ScannerMatch, + index: number, +): WinnerFirstView | null { + if (match.winner === null) return null; + const winners = match.teams[match.winner]; + const losers = match.teams[match.winner === 0 ? 1 : 0]; + if ( + winners.players.length !== PLAYERS_PER_TEAM || + losers.players.length !== PLAYERS_PER_TEAM + ) { + return null; + } + + return { + lobby: match.lobby, + mode: match.mode, + stage: match.stage, + scores: [winners.score, losers.score], + players: [...winners.players, ...losers.players].map((player) => ({ + ...player, + name: player.name ?? "", + })), + povIndex: + match.pov === null + ? null + : match.pov.team === match.winner + ? match.pov.index + : PLAYERS_PER_TEAM + match.pov.index, + order: match.playedAt ?? match.startsAt ?? index, + }; +} + +/** + * 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[] = []; + + for (const view of sorted) { const isDuplicate = result.some( (other) => - Math.abs(other.t - scoreboard.t) <= - DUPLICATE_SCOREBOARD_WINDOW_SECONDS && - other.data.mode === scoreboard.data.mode && - other.data.stage === scoreboard.data.stage && - other.data.players.every( - (player, i) => player.name === scoreboard.data.players[i]!.name, + other.mode === view.mode && + other.stage === view.stage && + other.players.every( + (player, i) => player.name === view.players[i]!.name, ), ); - if (!isDuplicate) result.push(scoreboard); + if (!isDuplicate) result.push(view); } return result; } /** - * Checks that the scoreboard's sides don't contradict the teams' known - * rosters: the winning rows should overlap the game winner's in-game names at - * least as well as the losing team's (and vice versa). A contradiction means - * the scoreboard belongs to some other game. No overlap at all (e.g. no - * in-game names set) counts as a pass. + * Checks that the view's sides don't contradict the teams' known rosters: + * the winning rows should overlap the game winner's in-game names at least + * as well as the losing team's (and vice versa). A contradiction means the + * match belongs to some other game. No overlap at all (e.g. no in-game + * names set) counts as a pass. */ -function sidesMatchKnownPlayers( - scoreboard: ScoreboardEventInput, - game: IngestableGame, -) { - const winnerSide = scoreboard.data.players +function sidesMatchKnownPlayers(view: WinnerFirstView, game: IngestableGame) { + const winnerSide = view.players .slice(0, PLAYERS_PER_TEAM) .map((player) => normalizeInGameName(player.name)); - const loserSide = scoreboard.data.players + const loserSide = view.players .slice(PLAYERS_PER_TEAM) .map((player) => normalizeInGameName(player.name)); @@ -256,16 +302,13 @@ function nameOverlap(names: string[], knownNames: string[]) { } /** - * Checks whether a scoreboard is a re-detection of a game's already stored + * Checks whether a match is a re-detection of a game's already stored * 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( - scoreboard: ScoreboardEventInput, - storedPlayerNames: string[], -) { - const matches = scoreboard.data.players.filter((player, i) => { +function isStoredDuplicate(view: WinnerFirstView, storedPlayerNames: string[]) { + const matches = view.players.filter((player, i) => { const name = normalizeInGameName(player.name); const storedName = storedPlayerNames[i] ? normalizeInGameName(storedPlayerNames[i]) @@ -276,18 +319,14 @@ function isStoredDuplicate( return matches >= MIN_STORED_DUPLICATE_NAME_MATCHES; } -function normalizeInGameName(name: string) { - return name.split("#")[0]!.normalize("NFKC").trim().toLowerCase(); -} - -function scoreboardToMatchedScoreboard({ - scoreboard, +function viewToMatchedScoreboard({ + view, game, }: { - scoreboard: ScoreboardEventInput; + view: WinnerFirstView; game: IngestableGame; }): MatchedScoreboard { - const players = scoreboard.data.players.map( + const players = view.players.map( (player, playerIdx): IngestedScoreboardPlayer => { return { name: player.name.trim(), @@ -307,9 +346,9 @@ function scoreboardToMatchedScoreboard({ matchGameResultId: game.matchGameResultId, tournamentMatchId: game.tournamentMatchId, mapIndex: game.mapIndex, - povIndex: scoreboard.data.povIndex, + povIndex: view.povIndex, data: { - scores: scoreboard.data.scores, + scores: view.scores, players, }, }; diff --git a/app/features/scanner-ingest/scanner-ingest-schemas.ts b/app/features/scanner-ingest/scanner-ingest-schemas.ts index 2877e7421..dc28d9d14 100644 --- a/app/features/scanner-ingest/scanner-ingest-schemas.ts +++ b/app/features/scanner-ingest/scanner-ingest-schemas.ts @@ -1,80 +1,17 @@ import { z } from "zod"; -import { - scannerAbilitySchema, - scannerDeathDataSchema, - scannerMapStartDataSchema, - scannerScoreboardDataSchema, - scannerScoreboardPlayerSchema, - scannerScoreboardReplayDataSchema, -} from "~/features/scanner/scanner-schemas"; +import { scannerMatchSchema } from "~/features/scanner/scanner-schemas"; import { id } from "~/utils/zod"; -const INGEST_MAX_EVENTS_PER_REQUEST = 1000; +const MAX_MATCHES_PER_REQUEST = 50; /** - * The event data shapes come from the producer (~/features/scanner/scanner-schemas — - * the single source of truth for the scanner events domain); this module only - * adds the ingest-specific envelope and enrichments. + * The ScannerMatch shape comes from the producer + * (~/features/scanner/scanner-schemas — the single source of truth for the + * scanner domain); this module only adds the ingest-specific envelope. */ - -/** [head, clothes, shoes] ability rows gathered from the match's death screens */ -const scoreboardPlayerSchema = scannerScoreboardPlayerSchema.extend({ - abilities: z.array(z.array(scannerAbilitySchema)).optional(), -}); - -const scoreboardDataSchema = scannerScoreboardDataSchema.extend({ - players: z.array(scoreboardPlayerSchema).length(8), -}); - -const scoreboardReplayDataSchema = scannerScoreboardReplayDataSchema.extend({ - players: z.array(scoreboardPlayerSchema).length(8), -}); - -const eventBaseSchema = z.object({ - /** seconds into the stream/video the event was detected at */ - t: z.number().min(0), - /** wall-clock timestamp (ms) of the detection */ - detectedAt: z.number().int().positive().optional(), - confidence: z.number().min(0).max(1), -}); - -const ingestedEventSchema = z.discriminatedUnion("type", [ - eventBaseSchema.extend({ - type: z.literal("Scoreboard"), - data: scoreboardDataSchema, - }), - eventBaseSchema.extend({ - type: z.literal("ScoreboardReplay"), - /** - * when the replay's game was played (UTC ms), derived client-side from - * the replay browser's on-screen timestamp - */ - recordedAt: z.number().int().positive().optional(), - data: scoreboardReplayDataSchema, - }), - eventBaseSchema.extend({ - type: z.literal("Death"), - data: scannerDeathDataSchema, - }), - eventBaseSchema.extend({ - type: z.literal("MapStart"), - data: scannerMapStartDataSchema, - }), -]); - export const ingestBodySchema = z.object({ - /** the user whose point of view the events were detected from */ + /** the user whose point of view the matches were detected from */ povUserId: id.optional(), tournamentId: id.optional(), - events: z - .array(ingestedEventSchema) - .min(1) - .max(INGEST_MAX_EVENTS_PER_REQUEST), + matches: z.array(scannerMatchSchema).min(1).max(MAX_MATCHES_PER_REQUEST), }); - -export type IngestedEventInput = z.infer; -export type IngestedEventData = IngestedEventInput["data"]; -export type ScoreboardEventInput = Extract< - IngestedEventInput, - { type: "Scoreboard" | "ScoreboardReplay" } ->; diff --git a/app/features/scanner-ingest/scanner-ingest-vod-schemas.ts b/app/features/scanner-ingest/scanner-ingest-vod-schemas.ts index d99fa147c..8e04acd26 100644 --- a/app/features/scanner-ingest/scanner-ingest-vod-schemas.ts +++ b/app/features/scanner-ingest/scanner-ingest-vod-schemas.ts @@ -6,7 +6,7 @@ import { } from "~/features/scanner/scanner-schemas"; import { videoMatchTypes } from "~/features/vods/vods-constants"; -/** One detected match of a scanner VoD scan (~/features/scanner/core/vod-matches.ts). */ +/** One detected match of a scanner VoD scan, projected from a ScannerMatch (~/features/scanner/components/sendou-upload.ts). */ const ingestVodMatchSchema = z.object({ /** whole seconds into the video the match starts at */ startsAt: z.number().int().min(0), diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index 383374591..235c31b39 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -1,11 +1,13 @@ # Scanner — Splatoon match-event detection -Browser app (route `/scanner`, dev-only until promoted) that watches OBS Virtual -Camera footage, VoD files, or screenshots, detects Splatoon 3 UI screens with -OpenCV.js in a Web Worker, parses them into events speaking sendou.ink ids -(`ModeShort`/`StageId`/weapon ids/`Ability`), records them to IndexedDB, and -feeds them to `/ingest` and the `/vods/new` prefill. Imported wholesale from -the emberz repo (kept read-only for archaeology); see `MIGRATION.md` there. +Browser app (route `/scanner`, dev-only until promoted) that watches OBS +Virtual Camera footage, VoD files, or screenshots, detects Splatoon 3 UI +screens with OpenCV.js in a Web Worker, and parses them into events speaking +sendou.ink ids (`ModeShort`/`StageId`/weapon ids/`Ability`). Events are +aggregated client-side into `ScannerMatch` objects (`core/scanner-match.ts`) +— one detected game with everything the scan could read — which feed +`/ingest` (features/scanner-ingest) and the `/vods/new` prefill. Imported +from the emberz repo; see `MIGRATION.md` there. ## Commands @@ -19,141 +21,118 @@ pnpm scanner:build-localized-entries # regen localized closed sets from ../sp pnpm scanner:build-planner-signatures # regen the minimap stage-ID atlas from the assets repo ``` -The scanner scripts run through `vite-node -c scripts/scanner/vite-node.config.ts` — the -root vite config pre-bundles `@techstark/opencv-js` for the browser worker, and -vite-node must not consume that prebundle (it crashes on `__dirname` in Node). -The package itself is pnpm-patched (`patches/`): its CJS export is the -emscripten ready-promise, and a thenable `module.exports` breaks vite-node's -CJS interop; the patch wraps it as `{ cvReadyPromise }`, unwrapped in -`core/cv.ts`. +Scanner scripts run through `vite-node -c scripts/scanner/vite-node.config.ts`: +the root vite config pre-bundles `@techstark/opencv-js` for the browser worker +and vite-node must not consume that prebundle. The package is pnpm-patched +(`patches/`) to wrap its thenable CJS export as `{ cvReadyPromise }`, +unwrapped in `core/cv.ts`. ## Architecture -``` -MediaStream → capture/sampler (rVFC @2fps, ImageBitmap out) [Live tab] -video file → capture/vod-frames (WebCodecs decode, seek fallback) [VoD tab] - → worker/analyzer.worker (OpenCV.js WASM lives here) - core/detectors/* gate(mat) → parse(mat) → events - → core/timeline (dedupe within 30s window, keep highest confidence) - → store/events (IndexedDB) + components/ live feed +```mermaid +sequenceDiagram + participant Cap as capture (sampler / vod-frames) + participant W as analyzer.worker (OpenCV) + participant TL as TimelineBuilder + participant MB as match-builder + participant UI as Live/VoD tab + participant ING as /ingest (scanner-ingest) + participant DB as IngestedMatch / IngestedScoreboard + Cap->>W: frame + t + W->>W: detectors gate() → parse() + W-->>TL: DetectedEvents + TL-->>UI: deduped timeline (IndexedDB on Live) + 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) + Note over UI: VoD "Upload as VoD": ScannerMatch → slim prefill param → /vods/new ``` -- `core/` is pure (mats in, events out) and must stay runnable in three - contexts: the worker, the `/scanner` Screenshot tab, and Node tests. Keep - DOM/browser APIs out of it; Node-only helpers (image IO, fixture loading) - live in `node/`. Importing pure data/type modules from `~/modules` and - `~/features/build-analyzer/data` is fine — zod and the app config graph are - not (schemas live in `scanner-schemas.ts`, consumed by `features/scanner-ingest`; - detectors only `import type` the shapes). -- The route (`routes/scanner.tsx`) is SSR-guarded: everything below it assumes a - browser (worker, IndexedDB, WebCodecs, getUserMedia), so the client tree - loads via `React.lazy` after `useHydrated`. Nothing from - `core/worker/capture/store` may be imported at route-module top level. +- `core/` is pure (mats in, events/matches out) and runs in three contexts: + the worker, the `/scanner` Screenshot tab, and Node tests. No DOM/browser + APIs; Node-only helpers (image IO, fixture loading) live in `node/`. Pure + data/type imports from `~/modules` and `~/features/build-analyzer/data` are + fine — zod and the app config graph are not (schemas live in + `scanner-schemas.ts`, consumed by `features/scanner-ingest`; core only + `import type`s the shapes). +- `core/match-builder.ts` turns a timeline into `ScannerMatch`es: a MapStart + opens a match, a scoreboard closes one (claiming the last 8 min of deaths + when the intro was missed), minimaps group per map by confirmed stage + change and >5 min gap. An event belongs to at most one match. Deaths + reveal enemy builds (`ability-harvest.ts`). + Every field is nullable — partial matches are fine, scanner-ingest merges + them server-side. Senders filter with `isIngestableMatch` (private/unread + lobby only). +- The route (`routes/scanner.tsx`) is SSR-guarded: everything below it + assumes a browser, so the client tree loads via `React.lazy` after + `useHydrated`. Nothing from `core/worker/capture/store` may be imported at + route-module top level. - Six detectors: `scoreboard` (results screen), `scoreboard-replay` - (replay-browser detail screen), `scoreboard-own` (personal results screen), - `death` (respawn overlay), `map-start` (match-intro splash), `minimap` - (in-match map overlay, plus the casted 8-player spectator map screen as a - gated variant). Detector-specific parsing details are documented in each - detector's module header; accuracy-critical matching internals (background - masking, ink-coverage penalty, wide-segment splitting) in the module headers - of `core/glyphs.ts` and `core/detectors/scoreboard/weapons.ts` — read those + (replay-browser detail), `scoreboard-own` (personal results), `death` + (respawn overlay), `map-start` (match intro), `minimap` (in-match overlay, + plus the casted 8-player spectator map as a gated variant). Parsing details + are in each detector's module header; accuracy-critical matching internals + in `core/glyphs.ts` and `core/detectors/scoreboard/weapons.ts` — read those before touching recognition code. -- Ingestion is language-agnostic: OCR output snaps against every game language - at once (`core/localized-entries.ts`, generated) and events always carry the - sendou id. English display names for the UI come from `components/labels.ts`. -- ROI coordinates are in each detector's `rois.ts`, in canonical 1920×1080 - space; every input frame is normalized to that size first. +- Recognition is language-agnostic: OCR output snaps against every game + language at once (`core/localized-entries.ts`, generated) and events carry + sendou ids. English display names come from `components/labels.ts`. +- ROI coordinates live in each detector's `rois.ts`, in canonical 1920×1080 + space; every frame is normalized to that size first. - New event types implement `Detector` (`core/detectors/types.ts`): a cheap `gate(mat)` at sample rate plus `parse(mat, t)` when the gate fires. - Register in `core/detectors/registry.ts`. Event data shapes are pinned to - `scanner-schemas.ts` by compile-time asserts — extend both together. + Register in `core/detectors/registry.ts`. ## Assets (CDN) and fonts -Weapon/ability/special/sub template sources are the site's shared game -icons in the **sendou-ink/assets repo** under `assets/img/**` (`.avif`; ids -come from `~/modules/in-game-lists`, plus the scanner-only `UNKNOWN` ability -badge — `toScannerAbility` narrows template ids back to sendou ids). The -scanner-specific sets — glyph atlases and the planner signature atlas — live in -this repo under `public/scanner/v1/**` (override with `SCANNER_ASSETS_DIR`; the -version segment bumps on breaking atlas-format changes). xxx: the atlases -are in `public/` only while the feature is in development — move them to -the assets repo (and the worker back to the CDN base) later: +Weapon/ability/special/sub template sources are the site's shared game icons +in the **sendou-ink/assets repo** under `assets/img/**` (`.avif`; ids from +`~/modules/in-game-lists`, plus the scanner-only `UNKNOWN` ability badge — +`toScannerAbility` narrows template ids back to sendou ids). Scanner-specific +sets — glyph atlases and the planner signature atlas — live here under +`public/scanner/v1/**` (override with `SCANNER_ASSETS_DIR`; the version +segment bumps on breaking atlas-format changes). xxx: the atlases are in +`public/` only while the feature is in development — move them to the assets +repo (and the worker back to the CDN base) later. -- Browser/worker: icons fetched from `Config.staticAssetsUrl` at `img/**` - (the base URL rides the worker init message; the DO Space needs CORS — - GET, sendou.ink + localhost origins — because the worker `fetch()`es - cross-origin, plain `` consumers don't); atlases fetched same-origin - from `/scanner/v1/**`. For local dev against fresh icon regens, serve the - checkout with CORS — - `npx serve /Users/kalle/Developer/assets/assets -l 9100 --cors` - — and set `VITE_STATIC_ASSETS_URL=http://localhost:9100` in `.env`. +- Browser/worker: icons from `Config.staticAssetsUrl` at `img/**` (base URL + rides the worker init message; the DO Space needs CORS for GET from + sendou.ink + localhost since the worker `fetch()`es cross-origin); atlases + same-origin from `/scanner/v1/**`. Local dev against fresh icon regens: + `npx serve /Users/kalle/Developer/assets/assets -l 9100 --cors` and + `VITE_STATIC_ASSETS_URL=http://localhost:9100` in `.env`. - Node (tests/scripts): atlases from `public/scanner/v1`, icons from the - `../assets` checkout directly, never the CDN. AVIF icons decode through - `sharp` (`node/image-io.ts`) — `@napi-rs/canvas` mis-decodes AVIF - partial-alpha pixels. + `../assets` checkout, never the CDN. AVIF decodes through `sharp` + (`node/image-io.ts`) — `@napi-rs/canvas` mis-decodes AVIF partial-alpha. - Atlas regens overwrite `public/scanner/v1` in place and ship with the app - build; breaking format changes bump `v1`. + build. Fonts are proprietary and gitignored: `BlitzMain.otf`, `BlitzBold.otf`, `FOT-RowdyStd-EB.otf`, `FOT-KurokaneStd-EB.otf` in `assets/fonts/` (repo root; from the splatoon3-fonts repo). Atlas builders fail loudly without -them. Names and row digits use BlitzMain; team totals use BlitzBold; the -replay code line and VICTORY/DEFEAT tags use FOT-RowdyStd-EB; the JP death -message mixes condensed Kurokane and Rowdy (`death-weapon-ja`). Regeneration -order: `scanner:bootstrap-atlas` (fixture crops win via tie-break) → -`scanner:build-glyph-atlas`; localized sets via `scanner:build-localized-entries` -(expects a splat3 checkout at `../splat3`) then the atlas rebuild; planner -atlas via `scanner:build-planner-signatures` (reads the assets repo's -`assets/planner-maps/`, MINI variant). +them. Names and row digits use BlitzMain; team totals BlitzBold; the replay +code line and VICTORY/DEFEAT tags FOT-RowdyStd-EB; the JP death message mixes +condensed Kurokane and Rowdy (`death-weapon-ja`). Regeneration order: +`scanner:bootstrap-atlas` (fixture crops win via tie-break) → +`scanner:build-glyph-atlas`; localized sets via +`scanner:build-localized-entries` (expects a splat3 checkout at `../splat3`) +then the atlas rebuild; planner atlas via `scanner:build-planner-signatures` +(reads the assets repo's `assets/planner-maps/`, MINI variant). -## Fixtures are the workflow +## Fixtures A test case is a directory `tests/fixtures///` with `frame.png|jpg` (raw capture, never re-encoded) and `expected.json` (partial -expectations, sendou ids; informational `stageLabel`/`weaponLabel` fields help -the human corrector — tests compare only ids). Negative cases +expectations, sendou ids; informational `stageLabel`/`weaponLabel` fields +help the human corrector — tests compare only ids). Negative cases (`{ "event": "none" }`) go in the shared `tests/fixtures/negative/`; every detector's suite sweeps them. Every live misread should become a fixture — the live app's "Save fixture" button exports the byte-exact analyzed frame plus a prefilled `expected.json`. **Fixture ground-truth labels are hand-corrected by the user (the Splatoon domain authority) — treat them as definitive over any matcher output.** Fixtures are committed as plain blobs -(deliberately no LFS for now) — keep additions deliberate; the retreat plan -is LFS for future fixtures or an external corpus (fixture IO is isolated in -`node/fixtures.ts`). - -## Gotchas - -- @techstark/opencv-js 5.0.0-release.1: `.data` and `.clone()` are broken on - ROI views — always `view.copyTo(freshMat)` before pixel access. Views are - fine as inputs to cv calls. -- `matchTemplate` silently skips templates larger than the ROI — a weapon ROI - only competes against icon templates that fit its height. The minimap's - template sets are built with `cropToArt` (alpha-bbox-trimmed) or dark-art - weapons would be unmatchable there. -- BlitzMain renders `I`/`l`/`|`/`1` as identical bars; `parseName` resolves - every bar by context, not pixels. Same for `ー`/`-`. -- Scoped vs unscoped charger icons are pixel-indistinguishable; near ties - resolve to unscoped (`SCOPED_TWINS`) and are flagged `twinAmbiguous`. - Near-tied weapons whose kits differ resolve via the row's special icon - (`specials.ts`) or the minimap sub tile (`subResolved`); kits derive - directly from `~/features/build-analyzer/data/weapon-params.ts`. -- Header parsing OCRs the whole lobby/mode/stage line and snaps it to the - localized combos from `core/localized.ts` — new stages get a `StageId` in - `~/modules/in-game-lists` and the localized sets + atlases regenerate; - nothing is added to the OCR itself. -- Death events merge within an 8s timeline window, minimap 5s (see - `mergeWindowByType`); Scoreboard/ScoreboardReplay events carry a content - guard (`core/timeline/same-scoreboard.ts`). -- The minimap stage ID (`core/detectors/minimap/stage.ts`) matches an - ink-invariant structural signature against the planner atlas; **stage** - separates cleanly, **mode** does not (the atlas keeps five renders per - stage only to match whichever mode is on screen). The casted spectator map - screen is a gated minimap *variant* with its own card grid. The minimap - cannot read the mode: VoD matches without a MapStart/Scoreboard default to - SZ, flagged `modeAssumed`. -- A static screen would re-run expensive parses every sampled frame: - `ParseSuppressor` skips `parse()` once a gate keeps passing without - confidence improving. The Screenshot tab inits its worker with - `suppressSteadyFrames: false` — one-shot re-analyses must always parse. +(no LFS for now); keep additions deliberate — fixture IO is isolated in +`node/fixtures.ts` if a retreat to LFS/an external corpus is needed. diff --git a/app/features/scanner/components/LivePage.tsx b/app/features/scanner/components/LivePage.tsx index 824b58c30..22075e381 100644 --- a/app/features/scanner/components/LivePage.tsx +++ b/app/features/scanner/components/LivePage.tsx @@ -6,8 +6,10 @@ import { } from "../capture/sampler"; import { DEATH_EVENT_TYPE } from "../core/detectors/death/index"; import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start/index"; +import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index"; import { SCOREBOARD_EVENT_TYPES } from "../core/detectors/registry"; import type { DetectedEvent, GateResult } from "../core/detectors/types"; +import type { BuiltMatch } from "../core/match-builder"; import { TimelineBuilder } from "../core/timeline/index"; import { clearEvents, @@ -24,19 +26,20 @@ import { downloadEventsCsv } from "./events-csv"; import { type FixtureData, saveFixture } from "./fixture-export"; import { SENDOU_UPLOAD_ENABLED } from "./flags"; import { - batchContaining, + matchContaining, type SendouUser, - sendBatches, - unsentBatches, + sendMatches, + unsentMatches, } from "./sendou-ingest"; import { thumbnailFromBlob } from "./thumbnail"; const SAMPLE_FPS = 2; -/** Event types the /ingest batches carry — the only ones with a send status. */ +/** Event types the ingested matches are built from — the only ones with a send status. */ const INGESTABLE_TYPES = [ MAP_START_EVENT_TYPE, DEATH_EVENT_TYPE, + MINIMAP_EVENT_TYPE, ...SCOREBOARD_EVENT_TYPES, ]; @@ -88,10 +91,10 @@ export function LivePage({ }; }, [refreshFeed]); - /** Sends the batches `include` selects; serialized so sends never overlap. */ + /** Sends the matches `include` selects; serialized so sends never overlap. */ const send = useCallback( async ( - include: (batch: StoredEvent[]) => boolean, + include: (built: BuiltMatch) => boolean, { manual = false } = {}, ) => { if (sendingRef.current) return; @@ -99,15 +102,13 @@ export function LivePage({ if (manual) setSendouError(null); try { const events = await listEvents(); - const { sentBatches, failedBatches } = await sendBatches({ + const { sentMatches, failedMatches } = await sendMatches({ events, include, onStatus: refreshFeed, }); - if (manual && sentBatches + failedBatches === 0) { - setSendouError( - "nothing to send — no complete match (ending in a scoreboard) selected", - ); + if (manual && sentMatches + failedMatches === 0) { + setSendouError("nothing to send — no complete match selected"); } } finally { sendingRef.current = false; @@ -159,11 +160,11 @@ export function LivePage({ INGESTABLE_TYPES.includes(event.type) ) { if (SCOREBOARD_EVENT_TYPES.includes(event.type)) { - // a scoreboard closes its match batch — send it + // a scoreboard closes its match — send it refreshFeed(); await send( - (batch) => - batchContaining(id)(batch) && unsentBatches(batch), + (built) => + matchContaining(id)(built) && unsentMatches(built), ); } else { await updateEventsSend([id], { @@ -205,7 +206,10 @@ export function LivePage({ } setRunning(false); setStatus("idle"); - }, []); + // the scan ending is the last match boundary — flush what's unsent + // (partials are safe: the server merges them into fuller resends) + if (liveSendRef.current) void send(unsentMatches); + }, [send]); return (
@@ -282,7 +286,7 @@ export function LivePage({ @@ -317,7 +321,7 @@ export function LivePage({ sendouUser && e.id !== undefined && INGESTABLE_TYPES.includes(e.type) - ? () => void send(batchContaining(e.id!), { manual: true }) + ? () => void send(matchContaining(e.id!), { manual: true }) : undefined } /> diff --git a/app/features/scanner/components/VodPage.tsx b/app/features/scanner/components/VodPage.tsx index 84bcf03ad..c32001749 100644 --- a/app/features/scanner/components/VodPage.tsx +++ b/app/features/scanner/components/VodPage.tsx @@ -33,7 +33,7 @@ import type { FixtureData } from "./fixture-export"; import { SENDOU_UPLOAD_ENABLED } from "./flags"; import { formatTime } from "./format"; import { - countIngestBatches, + countIngestableMatches, type SendouUser, sendVodResults, } from "./sendou-ingest"; @@ -120,11 +120,13 @@ export function VodPage({ [status, matches], ); - // "Upload as results" — the /ingest counterpart of live sending: private - // match batches (MapStart → deaths → scoreboard) POSTed in one go - const resultsBatchCount = useMemo( + // "Upload as results" — the /ingest counterpart of live sending: the + // scan's ingestable ScannerMatches POSTed in one go + const resultsMatchCount = useMemo( () => - status === "done" ? countIngestBatches(matches.map((m) => m.event)) : 0, + status === "done" + ? countIngestableMatches(matches.map((m) => m.event)) + : 0, [status, matches], ); @@ -133,15 +135,15 @@ export function VodPage({ setResultsSend({ state: "sending", sent: 0, - total: countIngestBatches(events), + total: countIngestableMatches(events), }); const report = await sendVodResults(events, (sent, total) => setResultsSend({ state: "sending", sent, total }), ); setResultsSend({ state: "done", - sent: report.sentBatches, - total: report.totalBatches, + sent: report.sentMatches, + total: report.totalMatches, error: report.error, }); }, []); @@ -444,7 +446,7 @@ export function VodPage({ upload unavailable: {upload.problem} )} - {SENDOU_UPLOAD_ENABLED && resultsBatchCount > 0 && ( + {SENDOU_UPLOAD_ENABLED && resultsMatchCount > 0 && (