Send matches

This commit is contained in:
Kalle
2026-08-05 13:49:52 +03:00
parent 97d13b72a7
commit c2bb3fb3c1
25 changed files with 2339 additions and 1554 deletions

View File

@@ -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<number>;
}
export interface IngestedEvent {
export interface IngestedMatch {
id: GeneratedAlways<number>;
tournamentId: number | null;
povUserId: number | null;
submitterUserId: number | null;
type: string;
t: number;
confidence: number;
data: JSONColumnType<IngestedEventData>;
detectedAt: number | null;
eventHash: string;
/** database timestamp (seconds) the match was played at, when known */
playedAt: number | null;
data: JSONColumnType<ScannerMatch>;
matchHash: string;
createdAt: Generated<number>;
}
@@ -1273,7 +1271,7 @@ export interface DB {
GroupMatchContinueVote: GroupMatchContinueVote;
GroupMatchMap: GroupMatchMap;
GroupMember: GroupMember;
IngestedEvent: IngestedEvent;
IngestedMatch: IngestedMatch;
IngestedScoreboard: IngestedScoreboard;
PrivateUserNote: PrivateUserNote;
LogInLink: LogInLink;

View File

@@ -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<number>`"TournamentMatch"."opponentOne" ->> '$.id'`;
const opponentTwoId = sql<number>`"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<DB>,
{
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");
}

View File

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

View File

@@ -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> = {},
): ScannerMatchPlayer {
return {
name,
weaponId,
paint: null,
ka: null,
d: null,
s: null,
...partial,
};
}
function testMatch(partial: Partial<ScannerMatch> = {}): 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);
});
});

View File

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

View File

@@ -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<number, ScannerAbility[][]>;
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: ["", "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: [

View File

@@ -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<number, IngestableGameWithTournament[]>();
@@ -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,
},
};

View File

@@ -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<typeof ingestedEventSchema>;
export type IngestedEventData = IngestedEventInput["data"];
export type ScoreboardEventInput = Extract<
IngestedEventInput,
{ type: "Scoreboard" | "ScoreboardReplay" }
>;

View File

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

View File

@@ -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 `<img>` 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/<detector>/<case-name>/` 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.

View File

@@ -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<StoredEvent>) => 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 (
<div>
@@ -282,7 +286,7 @@ export function LivePage({
<button
type="button"
disabled={!sendouUser || feed.length === 0}
onClick={() => void send(unsentBatches, { manual: true })}
onClick={() => void send(unsentMatches, { manual: true })}
>
Send unsent to sendou.ink
</button>
@@ -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
}
/>

View File

@@ -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}
</span>
)}
{SENDOU_UPLOAD_ENABLED && resultsBatchCount > 0 && (
{SENDOU_UPLOAD_ENABLED && resultsMatchCount > 0 && (
<button
type="button"
disabled={!sendouUser || resultsSend?.state === "sending"}

View File

@@ -3,23 +3,24 @@
* sendou.ink itself, so requests are same-origin: the session cookie rides
* along automatically and the logged-in user comes from the root loader
* (useUser) instead of an identity probe. sendou.ink authenticates the
* session user and resolves the tournament/match from the events'
* timestamps server-side.
* session user and resolves the tournament/match server-side.
*
* Sending is per match batch (core/batches.ts): the send unit is one
* batch, and every member event's IndexedDB record tracks the outcome (the
* `send` status the feed cards display).
* The send unit is one ScannerMatch (core/match-builder.ts); every source
* event's IndexedDB record tracks the outcome (the `send` status the feed
* cards display). Resends are safe: sendou.ink dedupes matches by content
* hash, merges partials, and scoreboards first-ingest-wins.
*/
import { buildIngestBatches, chunkIngestBatches } from "../core/batches";
import { SCOREBOARD_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-replay/index";
import type { DetectedEvent } from "../core/detectors/types";
import { parseReplayTimestamp } from "../core/replay-time";
import type { BuiltMatch } from "../core/match-builder";
import { buildScannerMatches, isIngestableMatch } from "../core/match-builder";
import type { ScannerMatch } from "../core/scanner-match";
import { type StoredEvent, updateEventsSend } from "../store/events";
const INGEST_URL = "/ingest";
/** /ingest accepts at most 1000 events per request */
const MAX_EVENTS_PER_REQUEST = 1000;
/** /ingest accepts at most 50 matches per request (mirrors the server cap) */
const MAX_MATCHES_PER_REQUEST = 50;
export interface SendouUser {
id: number;
@@ -27,49 +28,46 @@ export interface SendouUser {
}
export interface SendResult {
sentBatches: number;
failedBatches: number;
sentMatches: number;
failedMatches: number;
}
/**
* Groups the stored events into match batches, POSTs the ones `include`
* selects, and records the outcome on every member event's `send` status
* Builds the stored events into matches, POSTs the ingestable ones `include`
* selects, and records the outcome on every source event's `send` status
* (calling `onStatus` after each store write so the feed can refresh).
*
* Resends are safe: sendou.ink dedupes events by content hash and
* scoreboards first-ingest-wins, so a retry always re-sends its whole batch.
*/
export async function sendBatches({
export async function sendMatches({
events,
include,
onStatus,
}: {
events: readonly StoredEvent[];
include: (batch: StoredEvent[]) => boolean;
include: (built: BuiltMatch<StoredEvent>) => boolean;
onStatus: () => void;
}): Promise<SendResult> {
const allBatches = buildIngestBatches(
const allBuilt = buildScannerMatches(
events.filter((e) => e.id !== undefined),
);
const batches = allBatches.filter(include);
await clearOrphanedQueued(events, allBatches);
).filter((built) => isIngestableMatch(built.match));
const selected = allBuilt.filter(include);
await clearOrphanedQueued(events, allBuilt);
const result: SendResult = { sentBatches: 0, failedBatches: 0 };
for (const batch of batches) {
const ids = batch.map((e) => e.id!);
const result: SendResult = { sentMatches: 0, failedMatches: 0 };
for (const built of selected) {
const ids = built.sources.map((e) => e.id!);
await updateEventsSend(ids, { state: "sending", at: Date.now() });
onStatus();
try {
await postIngestBatch(batch);
await postIngestMatches([built.match]);
await updateEventsSend(ids, { state: "sent", at: Date.now() });
result.sentBatches++;
result.sentMatches++;
} catch (err) {
await updateEventsSend(ids, {
state: "failed",
at: Date.now(),
error: err instanceof Error ? err.message : String(err),
});
result.failedBatches++;
result.failedMatches++;
}
onStatus();
}
@@ -77,62 +75,75 @@ export async function sendBatches({
}
export interface VodResultsSendReport {
sentBatches: number;
totalBatches: number;
/** last failure's message; null when every batch went through */
sentMatches: number;
totalMatches: number;
/** last failure's message; null when every request went through */
error: string | null;
}
/**
* One-go sender for the VoD tab's "Upload as results": groups a completed
* scan's events into match batches and POSTs as many batches per request as
* the server cap allows — usually the whole scan in one request, so
* sendou.ink's content-based tournament resolution sees the full scoreboard
* sequence (its mode+stage order plus roster sides is near-unique in the
* user's history). No per-event status bookkeeping — VoD events don't live
* in the live feed store. Resending is safe (server-side dedupe), so a
* partial failure can simply be retried whole.
*
* VoD events carry no per-event wall-clock; `detectedAt` is just the send
* stamp, like the Live tab's — resolution relies on the sequence (and on a
* replay scoreboard's own `recordedAt` where present).
* One-go sender for the VoD tab's "Upload as results": builds a completed
* scan's events into matches and POSTs as many per request as the server
* cap allows — usually the whole scan in one request, so sendou.ink's
* content-based tournament resolution sees the full match sequence (its
* mode+stage order plus roster sides is near-unique in the user's history).
* No per-event status bookkeeping — VoD events don't live in the live feed
* store. Resending is safe (server-side dedupe/merge), so a partial failure
* can simply be retried whole.
*/
export async function sendVodResults(
events: readonly DetectedEvent[],
onProgress?: (sentBatches: number, totalBatches: number) => void,
onProgress?: (sentMatches: number, totalMatches: number) => void,
): Promise<VodResultsSendReport> {
const batches = buildIngestBatches(events);
const requests = chunkIngestBatches(batches, MAX_EVENTS_PER_REQUEST);
const detectedAt = Date.now();
const matches = ingestableMatches(events);
let sentBatches = 0;
let sentMatches = 0;
let error: string | null = null;
for (const request of requests) {
for (let i = 0; i < matches.length; i += MAX_MATCHES_PER_REQUEST) {
const request = matches.slice(i, i + MAX_MATCHES_PER_REQUEST);
try {
await postIngestBatch(request.flat().map((e) => ({ ...e, detectedAt })));
sentBatches += request.length;
onProgress?.(sentBatches, batches.length);
await postIngestMatches(request);
sentMatches += request.length;
onProgress?.(sentMatches, matches.length);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
}
}
return { sentBatches, totalBatches: batches.length, error };
return { sentMatches, totalMatches: matches.length, error };
}
/** The number of /ingest match batches a set of events would produce. */
export function countIngestBatches(events: readonly DetectedEvent[]): number {
return buildIngestBatches(events).length;
/** The number of matches a set of events would send to /ingest. */
export function countIngestableMatches(
events: readonly DetectedEvent[],
): number {
return ingestableMatches(events).length;
}
async function postIngestBatch(
batch: Array<DetectedEvent & { detectedAt?: number }>,
) {
/** Match selector: the match built from the given stored event. */
export function matchContaining(
id: number,
): (built: BuiltMatch<StoredEvent>) => boolean {
return (built) => built.sources.some((e) => e.id === id);
}
/** Match selector: matches not yet sent (nor currently sending). */
export function unsentMatches(built: BuiltMatch<StoredEvent>): boolean {
return !built.sources.some(
(e) => e.send?.state === "sent" || e.send?.state === "sending",
);
}
function ingestableMatches(events: readonly DetectedEvent[]): ScannerMatch[] {
return buildScannerMatches(events)
.filter((built) => isIngestableMatch(built.match))
.map((built) => built.match);
}
async function postIngestMatches(matches: ScannerMatch[]) {
const res = await fetch(INGEST_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
events: batch.slice(0, MAX_EVENTS_PER_REQUEST).map(payloadEvent),
}),
body: JSON.stringify({ matches }),
});
if (!res.ok) {
throw new Error(
@@ -142,74 +153,34 @@ async function postIngestBatch(
}
/**
* Live sending marks events "queued" as they arrive; ones the grouping later
* drops (non-private match, older than the fallback window) would sit
* "queued" forever. Once a scoreboard boundary has passed them they can
* never join a future batch, so clear their status back to "not sent".
* Live sending marks events "queued" as they arrive; ones the match builder
* later leaves out (non-private match, older than the fallback window) would
* sit "queued" forever. Once a match boundary has passed them they can
* never join a future match, so clear their status back to "not sent".
*/
async function clearOrphanedQueued(
events: readonly StoredEvent[],
allBatches: StoredEvent[][],
allBuilt: BuiltMatch<StoredEvent>[],
): Promise<void> {
const lastBoundaryT = Math.max(
...allBatches.map((b) => b.at(-1)!.t),
...allBuilt.map((built) => built.sources.at(-1)!.t),
Number.NEGATIVE_INFINITY,
);
const batchedIds = new Set(allBatches.flat().map((e) => e.id));
const builtIds = new Set(
allBuilt.flatMap((built) => built.sources.map((e) => e.id)),
);
const orphaned = events
.filter(
(e) =>
e.send?.state === "queued" &&
e.id !== undefined &&
!batchedIds.has(e.id) &&
!builtIds.has(e.id) &&
e.t <= lastBoundaryT,
)
.map((e) => e.id!);
if (orphaned.length > 0) await updateEventsSend(orphaned, undefined);
}
/** Batch selector: the batch that carries the given stored event. */
export function batchContaining(id: number): (batch: StoredEvent[]) => boolean {
return (batch) => batch.some((e) => e.id === id);
}
/** Batch selector: batches not yet sent (nor currently sending). */
export function unsentBatches(batch: StoredEvent[]): boolean {
return !batch.some(
(e) => e.send?.state === "sent" || e.send?.state === "sending",
);
}
function payloadEvent(event: DetectedEvent & { detectedAt?: number }) {
const recordedAt =
event.type === SCOREBOARD_REPLAY_EVENT_TYPE
? replayRecordedAt(
event.data as { timestamp: string | null },
event.detectedAt,
)
: null;
return {
type: event.type,
t: event.t,
detectedAt: event.detectedAt,
confidence: event.confidence,
data: event.data, // worker events are persisted without debug
...(recordedAt !== null ? { recordedAt } : null),
};
}
/** The replay's recording time as UTC ms, from the on-screen timestamp. */
function replayRecordedAt(
data: { timestamp: string | null },
detectedAt: number | undefined,
): number | null {
// anchor the day/month recency disambiguation to when the replay screen
// was seen, not to a possibly much later send/retry
return data.timestamp
? parseReplayTimestamp(data.timestamp, { now: detectedAt })
: null;
}
async function errorText(res: Response): Promise<string> {
const text = await res.text().catch(() => "");
return `POST /ingest -> ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`;

View File

@@ -1,24 +1,27 @@
/**
* "Upload to sendou.ink" link for a fully processed VoD: the detected events
* are grouped into per-match rows (src/core/vod-matches.ts) and packed into
* the `ingest` search param of sendou.ink's /vods/new form (an `SP.json`
* param the search-params module compresses), which prefills a new VoD from
* them (the user adds the YouTube URL/title/date and fixes any misreads
* before submitting).
* are built into ScannerMatches (core/match-builder.ts), projected onto the
* slim per-match rows the `ingest` search param of sendou.ink's /vods/new
* form carries (an `SP.json` param the search-params module compresses),
* which prefills a new VoD from them (the user adds the YouTube
* URL/title/date and fixes any misreads before submitting).
*
* The VoD type is auto-detected: footage containing the casted 8-player
* spectator map screen is a CAST VoD; anything else leaves the form's default
* type untouched.
* type untouched. A match without a mode read prefills the form's SZ default,
* flagged `modeAssumed` — the fabricated default lives here, not on
* ScannerMatch.
*/
import type { IngestVodPrefill } from "~/features/scanner-ingest/scanner-ingest-vod-schemas";
import type {
IngestVodMatchInput,
IngestVodPrefill,
} from "~/features/scanner-ingest/scanner-ingest-vod-schemas";
import { vodsNewSearchParams } from "~/features/vods/vods-search-params";
import type { ModeShort } from "~/modules/in-game-lists/types";
import { newVodPage } from "~/utils/urls";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
} from "../core/detectors/minimap/index";
import type { DetectedEvent } from "../core/detectors/types";
import { buildVodMatches } from "../core/vod-matches";
import { buildScannerMatches } from "../core/match-builder";
import type { ScannerMatch } from "../core/scanner-match";
/**
* GET query params ride the request line, and servers/proxies commonly cap
@@ -28,6 +31,9 @@ import { buildVodMatches } from "../core/vod-matches";
*/
const MAX_URL_LENGTH = 8000;
/** The prefill default for a match whose mode no source read. */
const DEFAULT_VOD_MODE = "SZ" satisfies ModeShort;
export interface SendouUpload {
/** prefilled /vods/new path (same-origin); null when nothing usable to send */
url: string | null;
@@ -37,18 +43,17 @@ export interface SendouUpload {
/** Builds the prefilled /vods/new link for a completed scan's events. */
export function sendouUpload(events: readonly DetectedEvent[]): SendouUpload {
const matches = buildVodMatches(events);
const matches = buildScannerMatches(events)
.map((built) => built.match)
.filter((match) => match.teams.some((team) => team.players.length > 0));
if (matches.length === 0) return { url: null, problem: null };
const isCast = events.some(
(event) =>
event.type === MINIMAP_EVENT_TYPE &&
(event.data as MinimapData).spectator,
);
const isCast = matches.some((match) => match.cast);
const payload: IngestVodPrefill = isCast
? { type: "CAST", matches }
: { matches };
const payload: IngestVodPrefill = {
...(isCast ? { type: "CAST" as const } : null),
matches: matches.map(toPrefillMatch),
};
const result = vodsNewSearchParams.href(newVodPage(), { ingest: payload });
if (result.length > MAX_URL_LENGTH) {
return {
@@ -60,3 +65,15 @@ export function sendouUpload(events: readonly DetectedEvent[]): SendouUpload {
}
return { url: result, problem: null };
}
function toPrefillMatch(match: ScannerMatch): IngestVodMatchInput {
return {
startsAt: match.startsAt ?? 0,
mode: match.mode ?? DEFAULT_VOD_MODE,
modeAssumed: match.mode === null,
stage: match.stage,
weapons: match.teams.flatMap((team) =>
team.players.map((player) => player.weaponId),
),
};
}

View File

@@ -7,32 +7,38 @@
* and weapon id.
*/
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import type { ScannerAbility } from "../scanner-types";
import { DEATH_EVENT_TYPE, type DeathData } from "./detectors/death/index";
import { SCOREBOARD_EVENT_TYPES } from "./detectors/registry";
import type {
ScoreboardData,
ScoreboardPlayer,
} from "./detectors/scoreboard/index";
import type { ScoreboardData } from "./detectors/scoreboard/index";
import type { DetectedEvent } from "./detectors/types";
/** player row index (0-7) → [head, clothes, shoes] ability-id rows */
export type PlayerAbilityMap = Map<number, ScannerAbility[][]>;
/** Any player row a death can be attributed against (scoreboard, minimap). */
interface HarvestablePlayer {
name: string | null;
weaponId: MainWeaponId | null;
}
/**
* Match a death's killer to a scoreboard player row. Both signals are OCR
* output, so neither is trusted alone unless it is unambiguous: a combined
* name+weapon hit wins, then a unique name hit, then a unique weapon hit
* (two players on the same weapon with a misread name stay unattributed).
* Match a death's killer to a player row. Both signals are OCR output, so
* neither is trusted alone unless it is unambiguous: a combined name+weapon
* hit wins, then a unique name hit, then a unique weapon hit (two players on
* the same weapon with a misread name stay unattributed).
*/
function matchPlayer(
players: ScoreboardPlayer[],
players: readonly HarvestablePlayer[],
death: DeathData,
): number | null {
const name = death.name?.trim().toLowerCase() || null;
const indices = players.map((_, i) => i);
const byName = name
? indices.filter((i) => players[i]!.name.trim().toLowerCase() === name)
? indices.filter(
(i) => (players[i]!.name ?? "").trim().toLowerCase() === name,
)
: [];
// scoreboard rows carry main-weapon ids; a sub/special credit says
// nothing about which main the killer holds
@@ -52,7 +58,7 @@ function matchPlayer(
* readable ability grid is attributed to a scoreboard player row.
*/
export function harvestAbilities(
players: ScoreboardPlayer[],
players: readonly HarvestablePlayer[],
deaths: readonly DeathData[],
): PlayerAbilityMap {
const abilities: PlayerAbilityMap = new Map();

View File

@@ -1,126 +0,0 @@
/**
* Group a detected-event timeline into per-match batches for sendou.ink's
* /ingest endpoint: a batch starts at a MapStart event and ends at the next
* scoreboard-type event, carrying the match's death events in between. When
* a scoreboard arrives with no preceding MapStart (the intro was missed),
* the deaths since the previous scoreboard that fall within the last 10
* minutes are taken as its match instead — anything older belongs to no
* known match and is dropped, as is a match whose results screen was never
* detected. Scoreboards whose lobby is readable and not "Private Battle"
* are dropped together with their batch — only tournament lobbies are worth
* sending. The batch's death events reveal enemy builds; they are attached
* to the terminating scoreboard's player rows as `abilities` before
* sending.
*/
import { harvestAbilities } from "./ability-harvest";
import { DEATH_EVENT_TYPE, type DeathData } from "./detectors/death/index";
import { MAP_START_EVENT_TYPE } from "./detectors/map-start/index";
import { SCOREBOARD_EVENT_TYPES } from "./detectors/registry";
import type {
ScoreboardData,
ScoreboardPlayer,
} from "./detectors/scoreboard/index";
import type { DetectedEvent } from "./detectors/types";
/** The lobby header value private battles (tournament games) carry. */
const TOURNAMENT_LOBBY = "PRIVATE";
/**
* How far back a scoreboard with no preceding MapStart claims deaths as its
* match — matches run well under 10 minutes, so anything older is another
* (undelimited) match's.
*/
const FALLBACK_WINDOW_SECONDS = 600;
export interface IngestScoreboardPlayer extends ScoreboardPlayer {
/** [head, clothes, shoes] ability rows harvested from this match's death screens */
abilities?: string[][];
}
/**
* Splits a timeline into ingest batches. Only event types the /ingest
* endpoint accepts are included (MapStart, Death, Scoreboard,
* ScoreboardReplay); each batch's scoreboard players carry the abilities
* harvested from that batch's deaths.
*
* Generic so callers with richer event records (the UI's StoredEvent) keep
* their extra fields — batch members are the input objects themselves,
* except the terminating scoreboard, which is shallow-copied for
* enrichment.
*/
export function buildIngestBatches<E extends DetectedEvent>(
events: readonly E[],
): E[][] {
const sorted = [...events].sort((a, b) => a.t - b.t);
const batches: E[][] = [];
let open: E[] | null = null;
// deaths since the last boundary with no MapStart to anchor them yet
let orphans: E[] = [];
for (const event of sorted) {
if (event.type === MAP_START_EVENT_TYPE) {
// a new match intro abandons any match whose scoreboard was missed
open = [event];
orphans = [];
} else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) {
const data = event.data as ScoreboardData;
if (!data.lobby || data.lobby === TOURNAMENT_LOBBY) {
const matchEvents =
open ??
orphans.filter((e) => event.t - e.t <= FALLBACK_WINDOW_SECONDS);
batches.push([...matchEvents, enrichScoreboard(event, matchEvents)]);
}
open = null;
orphans = [];
} else if (event.type === DEATH_EVENT_TYPE) {
(open ?? orphans).push(event);
}
}
return batches;
}
/**
* Groups whole batches into request-sized chunks of at most `maxEvents`
* events. Sending as many batches as fit in one request lets sendou.ink's
* content-based tournament resolution see the scoreboard *sequence* — a
* single match batch (one scoreboard) can't resolve by content. A batch is
* never split across chunks; an oversized lone batch gets its own chunk.
*/
export function chunkIngestBatches<E extends DetectedEvent>(
batches: readonly E[][],
maxEvents: number,
): E[][][] {
const chunks: E[][][] = [];
let current: E[][] = [];
let eventCount = 0;
for (const batch of batches) {
if (current.length > 0 && eventCount + batch.length > maxEvents) {
chunks.push(current);
current = [];
eventCount = 0;
}
current.push(batch);
eventCount += batch.length;
}
if (current.length > 0) chunks.push(current);
return chunks;
}
function enrichScoreboard<E extends DetectedEvent>(
scoreboard: E,
matchEvents: readonly E[],
): E {
const deaths = matchEvents
.filter((e) => e.type === DEATH_EVENT_TYPE)
.map((e) => e.data as DeathData);
const data = scoreboard.data as ScoreboardData;
const abilities = harvestAbilities(data.players, deaths);
if (abilities.size === 0) return scoreboard;
const players: IngestScoreboardPlayer[] = data.players.map((player, i) => {
const build = abilities.get(i);
return build ? { ...player, abilities: build } : player;
});
return { ...scoreboard, data: { ...data, players } };
}

View File

@@ -4,6 +4,10 @@
*
* Everything in core/ obtains the cv namespace through getCV(); callers must
* await loadOpenCV() once at startup (worker bootstrap, test setup, tool entry).
*
* Gotcha of this build (5.0.0-release.1): `.data` and `.clone()` are broken
* on ROI views — `view.copyTo(freshMat)` before pixel access. Views are fine
* as inputs to cv calls.
*/
import cvModule from "@techstark/opencv-js";

View File

@@ -0,0 +1,360 @@
/**
* Group a detected-event timeline into ScannerMatch objects (scanner-match.ts).
*
* A MapStart opens a match and a scoreboard-type event closes one; deaths in
* between belong to it. A scoreboard with no preceding MapStart claims the
* deaths of the last 8 minutes as its match. Between delimiters (casted
* footage has none) minimaps are grouped per map by stage change and time gap:
* a Splatoon game runs a few minutes, so minimaps far apart are different
* maps, and a confirmed stage read change is a new map. A match is emitted
* only when a scoreboard or minimaps back it — a MapStart plus deaths whose
* results screen was missed identifies no game.
*
* Matches are emitted regardless of lobby (the vods prefill wants every
* match); senders filter with `isIngestableMatch`. Death events reveal enemy
* builds and are harvested onto the match's player rows (ability-harvest.ts).
*/
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
import { harvestAbilities } from "./ability-harvest";
import { DEATH_EVENT_TYPE, type DeathData } from "./detectors/death/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "./detectors/map-start/index";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
} from "./detectors/minimap/index";
import { SCOREBOARD_EVENT_TYPES } from "./detectors/registry";
import type { ScoreboardData } from "./detectors/scoreboard/index";
import {
SCOREBOARD_REPLAY_EVENT_TYPE,
type ScoreboardReplayData,
} from "./detectors/scoreboard-replay/index";
import type { DetectedEvent } from "./detectors/types";
import { parseReplayTimestamp } from "./replay-time";
import type {
ScannerMatch,
ScannerMatchPlayer,
ScannerMatchTeam,
} from "./scanner-match";
/** The lobby header value private battles (tournament games) carry. */
const TOURNAMENT_LOBBY = "PRIVATE";
/**
* How far back a scoreboard with no preceding MapStart claims deaths as its
* match — matches run well under 8 minutes, so anything older is another
* (undelimited) match's.
*/
const FALLBACK_WINDOW_SECONDS = 480;
/**
* Two minimaps more than this far apart cannot be the same game, so they
* open separate matches even on the same stage.
*/
const MATCH_GAP_SECONDS = 300;
const PLAYERS_PER_TEAM = 4;
export interface BuiltMatch<E extends DetectedEvent> {
match: ScannerMatch;
/**
* the input events the match was built from, chronological — the
* send-status unit for callers with richer event records (StoredEvent)
*/
sources: E[];
}
/**
* Splits a timeline into ScannerMatch objects, chronological. Event types
* that identify no match (ScoreboardOwn) are ignored. Matches never
* overlap: every input event ends up in at most one match's `sources` —
* each event is placed in exactly one accumulator (or dropped), and the
* orphan-death pool is emptied the moment a boundary claims or invalidates
* it.
*/
export function buildScannerMatches<E extends DetectedEvent>(
events: readonly E[],
): BuiltMatch<E>[] {
const sorted = [...events].sort((a, b) => a.t - b.t);
const built: BuiltMatch<E>[] = [];
const nextStage = buildNextStageMap(sorted);
let open: OpenMatch<E> | null = null;
// deaths seen with no match open to anchor them yet
let orphanDeaths: E[] = [];
const finalize = (): void => {
if (!open) return;
if (open.scoreboard || open.minimaps.length > 0) {
built.push(toBuiltMatch(open));
}
open = null;
};
for (const event of sorted) {
if (event.type === MAP_START_EVENT_TYPE) {
// a new match intro abandons any match whose scoreboard was missed
finalize();
open = startMatch();
open.mapStart = event;
vote(open.stageVotes, (event.data as MapStartData).stage);
orphanDeaths = [];
} else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) {
if (!open) {
open = startMatch();
open.deaths = orphanDeaths.filter(
(death) => event.t - death.t <= FALLBACK_WINDOW_SECONDS,
);
}
open.scoreboard = event;
vote(open.stageVotes, (event.data as ScoreboardData).stage);
finalize();
orphanDeaths = [];
} else if (event.type === MINIMAP_EVENT_TYPE) {
const stage = (event.data as MinimapData).stage;
if (open) {
// a stage change only splits when the next read doesn't refute
// it: a lone disagreeing frame is a misread to fold in as a
// minority vote, not a match boundary
const current = leadingStage(open.stageVotes);
const stageChanged =
current !== null &&
stage !== null &&
stage !== current &&
(nextStage.get(event) ?? stage) === stage;
const gapTooBig =
open.lastMinimapT !== null &&
event.t - open.lastMinimapT > MATCH_GAP_SECONDS;
if (stageChanged || gapTooBig) finalize();
}
open ??= startMatch();
open.minimaps.push(event);
open.lastMinimapT = event.t;
vote(open.stageVotes, stage);
} else if (event.type === DEATH_EVENT_TYPE) {
(open?.deaths ?? orphanDeaths).push(event);
}
}
finalize();
return built;
}
/**
* Whether a match is worth sending to /ingest: only tournament (Private
* Battle) games are; an unreadable lobby gets the benefit of the doubt.
*/
export function isIngestableMatch(match: ScannerMatch): boolean {
return match.lobby === null || match.lobby === TOURNAMENT_LOBBY;
}
/** A match being accumulated as the timeline is walked. */
interface OpenMatch<E extends DetectedEvent> {
mapStart: E | null;
minimaps: E[];
deaths: E[];
scoreboard: E | null;
/**
* per-stage read counts (a MapStart's stage seeds it); the plurality
* winner delimits same-vs-next map so one misread frame can't poison
* the whole match
*/
stageVotes: Map<StageId, number>;
/** t of the last minimap added, for the gap check */
lastMinimapT: number | null;
}
function startMatch<E extends DetectedEvent>(): OpenMatch<E> {
return {
mapStart: null,
minimaps: [],
deaths: [],
scoreboard: null,
stageVotes: new Map(),
lastMinimapT: null,
};
}
/**
* For each minimap event, the next minimap's non-null stage read (walked
* backwards) — the refutation signal for the stage-change split.
*/
function buildNextStageMap<E extends DetectedEvent>(
sorted: readonly E[],
): Map<E, StageId | null> {
const nextStage = new Map<E, StageId | null>();
let carry: StageId | null = null;
for (let i = sorted.length - 1; i >= 0; i--) {
const event = sorted[i]!;
if (event.type !== MINIMAP_EVENT_TYPE) continue;
nextStage.set(event, carry);
carry = (event.data as MinimapData).stage ?? carry;
}
return nextStage;
}
function vote(votes: Map<StageId, number>, stage: StageId | null): void {
if (stage !== null) votes.set(stage, (votes.get(stage) ?? 0) + 1);
}
/** Plurality stage of the reads so far; insertion order breaks ties. */
function leadingStage(votes: Map<StageId, number>): StageId | null {
let winner: StageId | null = null;
let best = 0;
for (const [stage, count] of votes) {
if (count > best) {
winner = stage;
best = count;
}
}
return winner;
}
function toBuiltMatch<E extends DetectedEvent>(
open: OpenMatch<E>,
): BuiltMatch<E> {
const sources = [
...(open.mapStart ? [open.mapStart] : []),
...open.minimaps,
...open.deaths,
...(open.scoreboard ? [open.scoreboard] : []),
].sort((a, b) => a.t - b.t);
const board = open.scoreboard?.data as ScoreboardData | undefined;
const start = open.mapStart?.data as MapStartData | undefined;
const replay =
open.scoreboard?.type === SCOREBOARD_REPLAY_EVENT_TYPE
? (open.scoreboard.data as ScoreboardReplayData)
: undefined;
const deaths = open.deaths.map((event) => event.data as DeathData);
const match: ScannerMatch = {
startsAt:
sources.length > 0 ? Math.max(0, Math.floor(sources[0]!.t)) : null,
endsAt: floorOrNull(open.scoreboard?.t ?? open.minimaps.at(-1)?.t),
playedAt: playedAt(open.scoreboard, replay),
lobby: board?.lobby ?? null,
mode: board?.mode ?? start?.mode ?? null,
stage: board?.stage ?? start?.stage ?? leadingStage(open.stageVotes),
matchScores: replay?.matchScores ?? null,
replayCode: replay?.replayCode ?? null,
cast: open.minimaps.some((event) => (event.data as MinimapData).spectator),
teams: board
? teamsFromScoreboard(board, deaths)
: teamsFromMinimaps(
open.minimaps.map((event) => event.data as MinimapData),
deaths,
),
winner: board ? 0 : null,
pov:
board && board.povIndex !== null
? {
team: board.povIndex < PLAYERS_PER_TEAM ? 0 : 1,
index: board.povIndex % PLAYERS_PER_TEAM,
}
: null,
};
return { match, sources };
}
function floorOrNull(t: number | undefined): number | null {
return t === undefined ? null : Math.max(0, Math.floor(t));
}
/**
* The wall-clock time the match was played: a replay scoreboard's on-screen
* recording timestamp (anchored to when the screen was seen, not a possibly
* much later send), else the closing scoreboard's detection time. Detection
* times ride richer event records (StoredEvent) and are read structurally so
* the builder stays generic.
*/
function playedAt(
scoreboard: DetectedEvent | null,
replay: ScoreboardReplayData | undefined,
): number | null {
if (!scoreboard) return null;
const detectedAt = (scoreboard as { detectedAt?: number }).detectedAt ?? null;
if (replay?.timestamp) {
const recorded = parseReplayTimestamp(replay.timestamp, {
now: detectedAt ?? undefined,
});
if (recorded !== null) return recorded;
}
return detectedAt;
}
function teamsFromScoreboard(
board: ScoreboardData,
deaths: readonly DeathData[],
): [ScannerMatchTeam, ScannerMatchTeam] {
const abilities = harvestAbilities(board.players, deaths);
const players = board.players.map((player, i): ScannerMatchPlayer => {
const build = abilities.get(i);
return {
name: player.name.trim() || null,
weaponId: player.weaponId,
paint: player.paint,
ka: player.ka,
d: player.d,
s: player.s,
...(build ? { abilities: build } : null),
};
});
return [
{ score: board.scores[0], players: players.slice(0, PLAYERS_PER_TEAM) },
{ score: board.scores[1], players: players.slice(PLAYERS_PER_TEAM) },
];
}
/**
* Players merged across a match's minimap frames, alpha side then bravo:
* weapons and names are fixed for a match, so a slot missed in one frame is
* filled from another (first frame that read it wins).
*/
function teamsFromMinimaps(
frames: readonly MinimapData[],
deaths: readonly DeathData[],
): [ScannerMatchTeam, ScannerMatchTeam] {
const alpha = mergeSlots(frames.map((frame) => frame.teammates));
const bravo = mergeSlots(frames.map((frame) => frame.enemies));
const players = [...alpha, ...bravo];
const abilities = harvestAbilities(players, deaths);
const withAbilities = players.map((player, i) => {
const build = abilities.get(i);
return build ? { ...player, abilities: build } : player;
});
return [
{ score: null, players: withAbilities.slice(0, alpha.length) },
{ score: null, players: withAbilities.slice(alpha.length) },
];
}
/** For each slot index, the first frame's non-null read of each field. */
function mergeSlots(
frames: Array<Array<{ name: string | null; weaponId: MainWeaponId | null }>>,
): ScannerMatchPlayer[] {
const width = Math.max(0, ...frames.map((frame) => frame.length));
const out: ScannerMatchPlayer[] = [];
for (let i = 0; i < width; i++) {
const reads = frames
.map((frame) => frame[i])
.filter((read) => read !== undefined);
out.push({
name:
reads
.map((read) => read.name?.trim() || null)
.find((n) => n !== null) ?? null,
weaponId:
reads.map((read) => read.weaponId).find((id) => id !== null) ?? null,
paint: null,
ka: null,
d: null,
s: null,
});
}
return out;
}

View File

@@ -0,0 +1,62 @@
/**
* ScannerMatch — the unit the scanner hands to the rest of sendou.ink: one
* detected game with everything the scan could read. Built from the event
* timeline by core/match-builder.ts; validated at the boundaries by
* scannerMatchSchema (../scanner-schemas.ts). Every field is nullable —
* a match may be partial (features/scanner-ingest merges partials).
*/
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import type { ScannerAbility, ScannerLobby } from "../scanner-types";
export interface ScannerMatchPlayer {
/** in-game name; null when unread (e.g. minimap POV enemy rows show none) */
name: string | null;
/** sendou main-weapon id; null when no frame read the slot */
weaponId: MainWeaponId | null;
paint: number | null;
ka: number | null;
d: number | null;
s: number | null;
/** [head, clothes, shoes] ability rows harvested from death screens */
abilities?: ScannerAbility[][];
}
export interface ScannerMatchTeam {
/** the team's game score; null when no results screen was seen */
score: number | null;
/** up to 4 players; slots the scan never saw are absent */
players: ScannerMatchPlayer[];
}
export interface ScannerMatch {
/** whole seconds into the video/stream the match starts at */
startsAt: number | null;
/** whole seconds into the video/stream the match was last seen at */
endsAt: number | null;
/**
* wall-clock ms the game was played: a replay scoreboard's recording
* time, else the closing scoreboard's detection time; null on VoD scans
*/
playedAt: number | null;
lobby: ScannerLobby | null;
mode: ModeShort | null;
stage: StageId | null;
/** set score from the replay screen, in `teams` order */
matchScores: [number | null, number | null] | null;
replayCode: string | null;
/** spectator/casted footage (the 8-player spectator map screen was seen) */
cast: boolean;
/**
* on-screen order: scoreboard rows 0-3 are teams[0] (the winners),
* minimap alpha/own side is teams[0]
*/
teams: [ScannerMatchTeam, ScannerMatchTeam];
/** scoreboard-sourced matches know it (0); minimap-only matches don't */
winner: 0 | 1 | null;
/** the POV player's seat, when a scoreboard identified it */
pov: { team: 0 | 1; index: number } | null;
}

View File

@@ -1,239 +0,0 @@
/**
* Group a detected-event timeline into per-match rows for sendou.ink's
* /ingest/vod endpoint (contract: sendou-ingest-endpoint.md), which builds a
* CAST-type VoD on /vods out of them.
*
* A VoD match becomes a `VideoMatch`: it needs a mode, a stage, a start
* timestamp to jump to in the YouTube embed, and the two teams' weapons.
* Casted broadcasts run their own between-map graphics (caster desk, stage
* pick, set score) instead of the native results/map-intro screens, so — apart
* from a POV VoD that happens to show them — the only native Splatoon UI is the
* in-match **spectator map screen**. Matches are therefore built primarily from
* the minimap, which shows all eight players' weapons and (via the planner
* signature) the stage.
*
* Because such footage carries no MapStart/Scoreboard events to delimit
* matches, minimaps are split into per-map matches by **stage change** and a
* **time gap** (a Splatoon game is only a few minutes, so minimaps far apart
* belong to different maps). A MapStart still opens a match and a scoreboard
* still closes one when present, and either supplies the mode/weapons then.
*
* The minimap cannot read the **mode**; for this PoC it is hard-coded to Splat
* Zones when no MapStart/Scoreboard supplied one. Weapons are left as the
* detector read them (sendou main-weapon ids, or null for a slot that never
* read); the endpoint validates them and skips any match missing a mode,
* stage, or a full set of weapons.
*/
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "./detectors/map-start/index";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
} from "./detectors/minimap/index";
import { SCOREBOARD_EVENT_TYPES } from "./detectors/registry";
import type { ScoreboardData } from "./detectors/scoreboard/index";
import type { DetectedEvent } from "./detectors/types";
/**
* PoC: casted broadcasts never expose the mode to any detector, so minimap-only
* matches default to Splat Zones — flagged via `modeAssumed` so downstream can
* tell the guess from a real read. Replace with real mode detection later.
*/
const DEFAULT_MODE = "SZ" satisfies ModeShort;
/**
* Two minimaps more than this far apart cannot be the same game (a Splatoon
* match runs a few minutes), so they open separate matches even on the same
* stage — the map-open the caster shows near a game's start and end still fall
* inside it.
*/
const MATCH_GAP_SECONDS = 300;
/** One VoD match as prefilled into sendou.ink's /vods/new form. */
export interface VodMatch {
/** whole seconds into the video the match starts at */
startsAt: number;
/** null when no source read it */
mode: ModeShort | null;
/**
* true when `mode` is the fabricated PoC default rather than a real
* read — lets the endpoint/form treat it as a guess, not a detection
*/
modeAssumed: boolean;
/** null when no source read it */
stage: StageId | null;
/**
* the match's weapons, alpha team then bravo team: sendou main-weapon
* ids, or null for a slot that never read
*/
weapons: (MainWeaponId | null)[];
}
/** A match being accumulated as the timeline is walked. */
interface OpenMatch {
mapStart: DetectedEvent | null;
firstMinimap: DetectedEvent | null;
minimaps: DetectedEvent[];
scoreboard: DetectedEvent | null;
/**
* per-stage read counts across the match's minimaps (a MapStart's stage
* seeds it); the plurality winner delimits same-vs-next map and is the
* reported stage, so one misread frame can't poison the whole match
*/
stageVotes: Map<StageId, number>;
/** t of the last minimap added, for the gap check */
lastMinimapT: number | null;
}
/** Plurality stage of the reads so far; insertion order breaks ties. */
function leadingStage(votes: Map<StageId, number>): StageId | null {
let winner: StageId | null = null;
let best = 0;
for (const [stage, count] of votes) {
if (count > best) {
winner = stage;
best = count;
}
}
return winner;
}
/**
* Splits a timeline into VoD matches. MapStart opens a match and a scoreboard
* closes one; between them (or with neither) minimaps are grouped per map by
* stage and time gap.
*/
export function buildVodMatches(events: readonly DetectedEvent[]): VodMatch[] {
const sorted = [...events].sort((a, b) => a.t - b.t);
const matches: VodMatch[] = [];
// For each minimap event, the next minimap's non-null stage read (walked
// backwards). A stage change only splits when the next read doesn't refute
// it: a lone frame disagreeing with both its match's running stage and the
// following read is a misread to fold in as a minority vote, not a match
// boundary. With no later read the change stands.
const nextStage = new Map<DetectedEvent, StageId | null>();
let carry: StageId | null = null;
for (let i = sorted.length - 1; i >= 0; i--) {
const event = sorted[i]!;
if (event.type !== MINIMAP_EVENT_TYPE) continue;
nextStage.set(event, carry);
carry = (event.data as MinimapData).stage ?? carry;
}
let open: OpenMatch | null = null;
const start = (): OpenMatch => ({
mapStart: null,
firstMinimap: null,
minimaps: [],
scoreboard: null,
stageVotes: new Map(),
lastMinimapT: null,
});
const vote = (votes: Map<StageId, number>, stage: StageId | null): void => {
if (stage !== null) votes.set(stage, (votes.get(stage) ?? 0) + 1);
};
const finalize = (): void => {
if (!open) return;
const match = toVodMatch(open);
if (match) matches.push(match);
open = null;
};
for (const event of sorted) {
if (event.type === MAP_START_EVENT_TYPE) {
finalize();
open = start();
open.mapStart = event;
vote(open.stageVotes, (event.data as MapStartData).stage ?? null);
} else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) {
open ??= start();
open.scoreboard = event;
vote(open.stageVotes, (event.data as ScoreboardData).stage ?? null);
finalize();
} else if (event.type === MINIMAP_EVENT_TYPE) {
const stage = (event.data as MinimapData).stage;
if (open) {
const current = leadingStage(open.stageVotes);
const stageChanged =
current !== null &&
stage !== null &&
stage !== current &&
(nextStage.get(event) ?? stage) === stage;
const gapTooBig =
open.lastMinimapT !== null &&
event.t - open.lastMinimapT > MATCH_GAP_SECONDS;
if (stageChanged || gapTooBig) finalize();
}
open ??= start();
open.minimaps.push(event);
open.firstMinimap ??= event;
open.lastMinimapT = event.t;
vote(open.stageVotes, stage);
}
}
finalize();
return matches;
}
/** Builds a match, or null when it carries no weapons to show. */
function toVodMatch(open: OpenMatch): VodMatch | null {
const start = open.mapStart?.data as MapStartData | undefined;
const board = open.scoreboard?.data as ScoreboardData | undefined;
const weapons = board
? board.players.map((player) => player.weaponId)
: weaponsFromMinimaps(open.minimaps);
if (weapons.length === 0) return null;
const anchorT =
open.mapStart?.t ?? open.firstMinimap?.t ?? open.scoreboard?.t ?? 0;
const readMode = start?.mode ?? board?.mode ?? null;
return {
startsAt: Math.max(0, Math.floor(anchorT)),
mode: readMode ?? DEFAULT_MODE,
modeAssumed: readMode === null,
stage: start?.stage ?? board?.stage ?? leadingStage(open.stageVotes),
weapons,
};
}
/**
* Merges the eight weapon slots (four alpha then four bravo) across a match's
* minimaps, taking the first frame that read each slot — weapons are fixed for
* a match, so a slot missed in one frame is filled from another. Empty when
* there were no minimaps.
*/
function weaponsFromMinimaps(
minimaps: DetectedEvent[],
): (MainWeaponId | null)[] {
if (minimaps.length === 0) return [];
const datas = minimaps.map((event) => event.data as MinimapData);
const alpha = mergeSlots(
datas.map((d) => d.teammates.map((t) => t.weaponId)),
);
const bravo = mergeSlots(datas.map((d) => d.enemies.map((e) => e.weaponId)));
return [...alpha, ...bravo];
}
/** For each slot index, the first non-null id across frames, else null. */
function mergeSlots(
frames: (MainWeaponId | null)[][],
): (MainWeaponId | null)[] {
const width = Math.max(0, ...frames.map((frame) => frame.length));
const out: (MainWeaponId | null)[] = [];
for (let i = 0; i < width; i++) {
out.push(frames.map((frame) => frame[i]).find((id) => id != null) ?? null);
}
return out;
}

View File

@@ -1,31 +1,25 @@
/**
* Zod schemas for the scanner events domain — the single source of truth shared
* by the producer (the scanner detectors/UI in this feature) and the validator
* (features/scanner-ingest). Every domain field is a sendou.ink id type; the
* compile-time asserts at the bottom pin each schema to the corresponding
* detector output interface so producer and validator cannot drift.
* Zod schemas for the scanner domain — the single source of truth shared by
* the producer (the scanner match builder/UI in this feature) and the
* validator (features/scanner-ingest). Every domain field is a sendou.ink id
* type; the compile-time asserts at the bottom pin each schema to the
* corresponding core interface so producer and validator cannot drift.
*
* The detectors/worker consume only the *types* (type-only imports point
* the other way), so zod never enters the worker bundle; runtime
* validation happens at the boundaries (ingest action, prefill loader).
* The core/worker modules consume only the *types* (type-only imports point
* the other way), so zod never enters the worker bundle; runtime validation
* happens at the boundaries (ingest action, prefill loader).
*/
import { z } from "zod";
import { abilities } from "~/modules/in-game-lists/abilities";
import { modesShort } from "~/modules/in-game-lists/modes";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import type { Ability } from "~/modules/in-game-lists/types";
import {
mainWeaponIds,
specialWeaponIds,
subWeaponIds,
} from "~/modules/in-game-lists/weapon-ids";
import type { DeathData } from "./core/detectors/death/index";
import type { MapStartData } from "./core/detectors/map-start/index";
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import type {
ScoreboardData,
ScoreboardPlayer,
} from "./core/detectors/scoreboard/index";
import type { ScoreboardReplayData } from "./core/detectors/scoreboard-replay/index";
ScannerMatch,
ScannerMatchPlayer,
ScannerMatchTeam,
} from "./core/scanner-match";
import { SCANNER_LOBBIES } from "./scanner-types";
const detectionText = z.string().max(500);
@@ -34,58 +28,53 @@ const scannerLobbySchema = z.enum(SCANNER_LOBBIES);
export const modeShortSchema = z.enum(modesShort);
export const stageIdSchema = z.literal(stageIds);
export const mainWeaponIdSchema = z.literal(mainWeaponIds);
const subWeaponIdSchema = z.literal(subWeaponIds);
const specialWeaponIdSchema = z.literal(specialWeaponIds);
const abilityNames = abilities.map((ability) => ability.name) as Ability[];
/** a sendou ability id, or the detectors' explicit unrecognized marker */
export const scannerAbilitySchema = z.union([
const scannerAbilitySchema = z.union([
z.literal(abilityNames),
z.literal("UNKNOWN"),
]);
export const scannerScoreboardPlayerSchema = z.object({
name: detectionText,
/** sendou main-weapon id; null when the row's weapon was unreadable */
const scannerMatchPlayerSchema = z.object({
name: detectionText.nullable(),
weaponId: mainWeaponIdSchema.nullable(),
paint: z.number().nullable(),
ka: z.number().nullable(),
d: z.number().nullable(),
s: z.number().nullable(),
/** [head, clothes, shoes] ability rows harvested from death screens */
abilities: z.array(z.array(scannerAbilitySchema)).optional(),
});
export const scannerScoreboardDataSchema = z.object({
const scannerMatchTeamSchema = z.object({
score: z.number().nullable(),
players: z.array(scannerMatchPlayerSchema).max(4),
});
const teamIndexSchema = z.union([z.literal(0), z.literal(1)]);
export const scannerMatchSchema = z.object({
startsAt: z.number().int().min(0).nullable(),
endsAt: z.number().int().min(0).nullable(),
/** wall-clock ms the game was played */
playedAt: z.number().int().positive().nullable(),
lobby: scannerLobbySchema.nullable(),
mode: modeShortSchema.nullable(),
stage: stageIdSchema.nullable(),
scores: z.tuple([z.number().nullable(), z.number().nullable()]),
players: z.array(scannerScoreboardPlayerSchema).length(8),
povIndex: z.number().int().min(0).max(7).nullable(),
});
export const scannerScoreboardReplayDataSchema =
scannerScoreboardDataSchema.extend({
timestamp: detectionText.nullable(),
replayCode: detectionText.nullable(),
matchScores: z.tuple([z.number().nullable(), z.number().nullable()]),
});
export const scannerDeathDataSchema = z.object({
/** sendou weapon id (main/sub/special id space per weaponType) */
weaponId: z
.union([mainWeaponIdSchema, subWeaponIdSchema, specialWeaponIdSchema])
matchScores: z
.tuple([z.number().nullable(), z.number().nullable()])
.nullable(),
replayCode: detectionText.nullable(),
cast: z.boolean(),
teams: z.tuple([scannerMatchTeamSchema, scannerMatchTeamSchema]),
winner: teamIndexSchema.nullable(),
pov: z
.object({ team: teamIndexSchema, index: z.number().int().min(0).max(3) })
.nullable(),
weaponType: z.enum(["MAIN", "SUB", "SPECIAL"]).nullable(),
abilities: z.array(z.array(scannerAbilitySchema)),
name: detectionText.nullable(),
});
export const scannerMapStartDataSchema = z.object({
mode: modeShortSchema.nullable(),
stage: stageIdSchema.nullable(),
});
// ---- compile-time drift protection: schema output <-> detector output ----
// ---- compile-time drift protection: schema output <-> core interface ----
type MutuallyAssignable<A, B> = [A] extends [B]
? [B] extends [A]
@@ -93,25 +82,17 @@ type MutuallyAssignable<A, B> = [A] extends [B]
: never
: never;
// `true satisfies …` fails to compile the moment a schema and its detector
// `true satisfies …` fails to compile the moment a schema and its core
// interface disagree in either direction.
true satisfies MutuallyAssignable<
z.infer<typeof scannerScoreboardPlayerSchema>,
ScoreboardPlayer
z.infer<typeof scannerMatchPlayerSchema>,
ScannerMatchPlayer
>;
true satisfies MutuallyAssignable<
z.infer<typeof scannerScoreboardDataSchema>,
ScoreboardData
z.infer<typeof scannerMatchTeamSchema>,
ScannerMatchTeam
>;
true satisfies MutuallyAssignable<
z.infer<typeof scannerScoreboardReplayDataSchema>,
ScoreboardReplayData
>;
true satisfies MutuallyAssignable<
z.infer<typeof scannerDeathDataSchema>,
DeathData
>;
true satisfies MutuallyAssignable<
z.infer<typeof scannerMapStartDataSchema>,
MapStartData
z.infer<typeof scannerMatchSchema>,
ScannerMatch
>;

View File

@@ -1,235 +0,0 @@
import assert from "node:assert/strict";
import {
buildIngestBatches,
chunkIngestBatches,
type IngestScoreboardPlayer,
} from "../core/batches";
import type { DeathData } from "../core/detectors/death/index";
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
import type { DetectedEvent } from "../core/detectors/types";
import type { ScannerAbility, ScannerLobby } from "../scanner-types";
import test from "./node-test-compat";
const NAMES = ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"];
function mapStart(t: number): DetectedEvent {
return {
type: "MapStart",
t,
confidence: 0.9,
data: { mode: "SZ", stage: 0 },
};
}
function death(
t: number,
name: string,
abilities: ScannerAbility[][] = [["ISM", "ISS", "ISS", "ISS"]],
): DetectedEvent {
const data: DeathData = {
weaponId: null,
weaponType: "MAIN",
abilities,
name,
};
return { type: "Death", t, confidence: 0.9, data };
}
function scoreboard(
t: number,
{ lobby = "PRIVATE" as ScannerLobby | null } = {},
): DetectedEvent {
const data: ScoreboardData = {
lobby,
mode: "SZ",
stage: 0,
scores: [100, 47],
players: NAMES.map((name) => ({
name,
weaponId: 40,
paint: 1000,
ka: 10,
d: 5,
s: 2,
})),
povIndex: 0,
};
return { type: "Scoreboard", t, confidence: 0.9, data };
}
function scoreboardPlayers(batch: DetectedEvent[]): IngestScoreboardPlayer[] {
return (batch.at(-1)!.data as ScoreboardData).players;
}
test("groups map start, deaths and scoreboard into one batch", () => {
const batches = buildIngestBatches([
mapStart(0),
death(60, "l2"),
death(120, "l3"),
scoreboard(300),
]);
assert.equal(batches.length, 1);
assert.deepEqual(
batches[0]!.map((e) => e.type),
["MapStart", "Death", "Death", "Scoreboard"],
);
});
test("enriches the scoreboard players with abilities from the batch's deaths", () => {
const build: ScannerAbility[][] = [
["ISM", "ISS", "ISS", "ISS"],
["QR", "QSJ", "QSJ", "QSJ"],
["SSU", "RSU", "RSU", "RSU"],
];
const batches = buildIngestBatches([
mapStart(0),
death(60, "l2", build),
scoreboard(300),
]);
const players = scoreboardPlayers(batches[0]!);
assert.deepEqual(players[5]!.abilities, build);
assert.equal(players[0]!.abilities, undefined);
});
test("deaths from an earlier match do not leak into the next batch", () => {
const batches = buildIngestBatches([
mapStart(0),
death(60, "l2"),
scoreboard(300),
mapStart(400),
scoreboard(700),
]);
assert.equal(batches.length, 2);
assert.equal(scoreboardPlayers(batches[1]!)[5]!.abilities, undefined);
});
test("a scoreboard without a preceding map start claims the last 10 minutes of deaths", () => {
const batches = buildIngestBatches([death(60, "l2"), scoreboard(300)]);
assert.equal(batches.length, 1);
assert.deepEqual(
batches[0]!.map((e) => e.type),
["Death", "Scoreboard"],
);
assert.notEqual(scoreboardPlayers(batches[0]!)[5]!.abilities, undefined);
});
test("deaths older than 10 minutes do not join a map-start-less batch", () => {
const batches = buildIngestBatches([
death(60, "l2"),
death(700, "l3"),
scoreboard(1000),
]);
assert.equal(batches.length, 1);
assert.deepEqual(
batches[0]!.map((e) => e.t),
[700, 1000],
);
});
test("the fallback window does not reach past the previous scoreboard", () => {
const batches = buildIngestBatches([
mapStart(0),
death(60, "l2"),
scoreboard(300),
death(400, "l3"),
scoreboard(700),
]);
assert.equal(batches.length, 2);
assert.deepEqual(
batches[1]!.map((e) => e.t),
[400, 700],
);
});
test("non-private-battle scoreboards are dropped together with their batch", () => {
const batches = buildIngestBatches([
mapStart(0),
death(60, "l2"),
scoreboard(300, { lobby: "X" }),
mapStart(400),
scoreboard(700),
]);
assert.equal(batches.length, 1);
assert.deepEqual(
batches[0]!.map((e) => e.type),
["MapStart", "Scoreboard"],
);
});
test("an unreadable lobby is kept", () => {
const batches = buildIngestBatches([scoreboard(300, { lobby: null })]);
assert.equal(batches.length, 1);
});
test("a match whose scoreboard was missed is dropped on the next map start", () => {
const batches = buildIngestBatches([
mapStart(0),
death(60, "l2"),
mapStart(400),
death(460, "l3"),
scoreboard(700),
]);
assert.equal(batches.length, 1);
assert.deepEqual(
batches[0]!.map((e) => e.t),
[400, 460, 700],
);
});
test("a trailing match without a scoreboard is not sent", () => {
const batches = buildIngestBatches([mapStart(0), death(60, "l2")]);
assert.equal(batches.length, 0);
});
test("event types the endpoint does not accept are excluded", () => {
const own: DetectedEvent = {
type: "ScoreboardOwn",
t: 310,
confidence: 0.9,
data: {
lobby: "PRIVATE",
mode: null,
stage: null,
weaponId: null,
abilities: [],
},
};
const batches = buildIngestBatches([mapStart(0), own, scoreboard(300), own]);
assert.equal(batches.length, 1);
assert.deepEqual(
batches[0]!.map((e) => e.type),
["MapStart", "Scoreboard"],
);
});
test("chunkIngestBatches: packs whole batches up to the event cap", () => {
const batches = [
[mapStart(0), death(60, "l1"), scoreboard(300)],
[mapStart(400), scoreboard(700)],
[mapStart(800), scoreboard(1100)],
];
const chunks = chunkIngestBatches(batches, 5);
assert.deepEqual(
chunks.map((chunk) => chunk.map((batch) => batch.length)),
[[3, 2], [2]],
);
// batches are kept whole and in order
assert.deepEqual(chunks.flat(), batches);
});
test("chunkIngestBatches: everything fits in one request", () => {
const batches = [
[mapStart(0), scoreboard(300)],
[mapStart(400), scoreboard(700)],
];
assert.deepEqual(chunkIngestBatches(batches, 1000), [batches]);
});
test("chunkIngestBatches: an oversized lone batch still gets a chunk", () => {
const big = [mapStart(0), death(1, "l1"), death(2, "l2"), scoreboard(300)];
assert.deepEqual(chunkIngestBatches([big], 2), [[big]]);
});
test("chunkIngestBatches: no batches, no requests", () => {
assert.deepEqual(chunkIngestBatches([], 1000), []);
});

View File

@@ -0,0 +1,494 @@
import assert from "node:assert/strict";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import type { DeathData } from "../core/detectors/death/index";
import type {
MinimapData,
MinimapEnemy,
MinimapTeammate,
} from "../core/detectors/minimap/index";
import { SPECTATOR_SLOTS } from "../core/detectors/minimap/rois";
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
import type { ScoreboardReplayData } from "../core/detectors/scoreboard-replay/index";
import type { DetectedEvent } from "../core/detectors/types";
import { buildScannerMatches, isIngestableMatch } from "../core/match-builder";
import type { ScannerAbility, ScannerLobby } from "../scanner-types";
import test from "./node-test-compat";
const NAMES = ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"];
const ALPHA: MainWeaponId[] = [40, 1001, 2010, 3030];
const BRAVO: MainWeaponId[] = [50, 210, 4010, 8000];
const ALL = [...ALPHA, ...BRAVO];
function mapStart(
t: number,
{ mode = "SZ" as ModeShort | null, stage = 0 as StageId | null } = {},
): DetectedEvent {
return { type: "MapStart", t, confidence: 0.9, data: { mode, stage } };
}
function death(
t: number,
name: string,
abilities: ScannerAbility[][] = [["ISM", "ISS", "ISS", "ISS"]],
): DetectedEvent {
const data: DeathData = {
weaponId: null,
weaponType: "MAIN",
abilities,
name,
};
return { type: "Death", t, confidence: 0.9, data };
}
function scoreboard(
t: number,
{
lobby = "PRIVATE" as ScannerLobby | null,
mode = "SZ" as ModeShort | null,
stage = 0 as StageId | null,
weapons = ALL as (MainWeaponId | null)[],
povIndex = 0 as number | null,
} = {},
): DetectedEvent {
const data: ScoreboardData = {
lobby,
mode,
stage,
scores: [100, 47],
players: weapons.map((weaponId, i) => ({
name: NAMES[i] ?? `p${i}`,
weaponId,
paint: 1000,
ka: 10,
d: 5,
s: 2,
})),
povIndex,
};
return { type: "Scoreboard", t, confidence: 0.9, data };
}
function replayScoreboard(
t: number,
{
timestamp = null as string | null,
replayCode = "RABC-DEFG-HIJK-LMNO" as string | null,
} = {},
): DetectedEvent & { detectedAt?: number } {
const base = scoreboard(t).data as ScoreboardData;
const data: ScoreboardReplayData = {
...base,
timestamp,
replayCode,
matchScores: [3, 1],
};
return { type: "ScoreboardReplay", t, confidence: 0.9, data };
}
function teammate(weaponId: MainWeaponId | null, i: number): MinimapTeammate {
return {
slot: SPECTATOR_SLOTS[i]!,
name: null,
weaponId,
abilities: [],
};
}
function enemy(weaponId: MainWeaponId | null): MinimapEnemy {
return {
name: null,
weaponId,
abilities: [],
};
}
function minimap(
t: number,
{
stage = 0 as StageId | null,
alpha = ALPHA as (MainWeaponId | null)[],
bravo = BRAVO as (MainWeaponId | null)[],
spectator = true,
} = {},
): DetectedEvent {
const data: MinimapData = {
stage,
spectator,
teammates: alpha.map(teammate),
enemies: bravo.map(enemy),
};
return { type: "Minimap", t, confidence: 0.8, data };
}
function weapons(match: {
teams: [
{ players: { weaponId: MainWeaponId | null }[] },
{ players: { weaponId: MainWeaponId | null }[] },
];
}): (MainWeaponId | null)[] {
return match.teams.flatMap((team) =>
team.players.map((player) => player.weaponId),
);
}
test("groups map start, deaths and scoreboard into one match", () => {
const events = [
mapStart(0),
death(60, "l2"),
death(120, "l3"),
scoreboard(300),
];
const built = buildScannerMatches(events);
assert.equal(built.length, 1);
assert.deepEqual(
built[0]!.sources.map((e) => e.type),
["MapStart", "Death", "Death", "Scoreboard"],
);
// sources are the exact input objects
assert.equal(built[0]!.sources[0], events[0]);
});
test("scoreboard fields land on the match", () => {
const built = buildScannerMatches([mapStart(0), scoreboard(300)]);
const match = built[0]!.match;
assert.equal(match.startsAt, 0);
assert.equal(match.endsAt, 300);
assert.equal(match.lobby, "PRIVATE");
assert.equal(match.mode, "SZ");
assert.equal(match.stage, 0);
assert.equal(match.winner, 0);
assert.deepEqual(match.pov, { team: 0, index: 0 });
assert.deepEqual(
match.teams.map((team) => team.score),
[100, 47],
);
assert.deepEqual(
match.teams.map((team) => team.players.map((p) => p.name)),
[
["w1", "w2", "w3", "w4"],
["l1", "l2", "l3", "l4"],
],
);
assert.deepEqual(weapons(match), ALL);
assert.equal(match.cast, false);
assert.equal(match.matchScores, null);
assert.equal(match.replayCode, null);
});
test("a losing-side pov index maps to the second team", () => {
const built = buildScannerMatches([scoreboard(300, { povIndex: 6 })]);
assert.deepEqual(built[0]!.match.pov, { team: 1, index: 2 });
});
test("enriches players with abilities from the match's deaths", () => {
const build: ScannerAbility[][] = [
["ISM", "ISS", "ISS", "ISS"],
["QR", "QSJ", "QSJ", "QSJ"],
["SSU", "RSU", "RSU", "RSU"],
];
const built = buildScannerMatches([
mapStart(0),
death(60, "l2", build),
scoreboard(300),
]);
const teams = built[0]!.match.teams;
assert.deepEqual(teams[1].players[1]!.abilities, build);
assert.equal(teams[0].players[0]!.abilities, undefined);
});
test("deaths from an earlier match do not leak into the next match", () => {
const built = buildScannerMatches([
mapStart(0),
death(60, "l2"),
scoreboard(300),
mapStart(400),
scoreboard(700),
]);
assert.equal(built.length, 2);
assert.equal(built[1]!.match.teams[1].players[1]!.abilities, undefined);
});
test("a scoreboard without a preceding map start claims the last 8 minutes of deaths", () => {
const built = buildScannerMatches([death(60, "l2"), scoreboard(300)]);
assert.equal(built.length, 1);
assert.deepEqual(
built[0]!.sources.map((e) => e.type),
["Death", "Scoreboard"],
);
assert.notEqual(built[0]!.match.teams[1].players[1]!.abilities, undefined);
});
test("deaths older than 8 minutes do not join a map-start-less match", () => {
const built = buildScannerMatches([
death(60, "l2"),
death(700, "l3"),
scoreboard(1000),
]);
assert.equal(built.length, 1);
assert.deepEqual(
built[0]!.sources.map((e) => e.t),
[700, 1000],
);
});
test("every event belongs to at most one match", () => {
const events = [
death(10, "l2"), // orphan invalidated by the map start
mapStart(30),
death(60, "l3"),
minimap(90),
scoreboard(300),
death(320, "l4"), // orphan claimed by the next scoreboard
scoreboard(700),
minimap(800),
minimap(1200), // gap-splits into its own match
scoreboard(1300),
];
const built = buildScannerMatches(events);
assert.equal(built.length, 4);
const seen = new Set<DetectedEvent>();
for (const b of built) {
for (const source of b.sources) {
assert.equal(seen.has(source), false);
seen.add(source);
}
}
});
test("the fallback window does not reach past the previous scoreboard", () => {
const built = buildScannerMatches([
mapStart(0),
death(60, "l2"),
scoreboard(300),
death(400, "l3"),
scoreboard(700),
]);
assert.equal(built.length, 2);
assert.deepEqual(
built[1]!.sources.map((e) => e.t),
[400, 700],
);
});
test("non-private lobbies are recorded and filtered by isIngestableMatch", () => {
const built = buildScannerMatches([
mapStart(0),
scoreboard(300, { lobby: "X" }),
mapStart(400),
scoreboard(700),
]);
assert.equal(built.length, 2);
assert.equal(built[0]!.match.lobby, "X");
assert.equal(isIngestableMatch(built[0]!.match), false);
assert.equal(isIngestableMatch(built[1]!.match), true);
});
test("an unreadable lobby is ingestable", () => {
const built = buildScannerMatches([scoreboard(300, { lobby: null })]);
assert.equal(built.length, 1);
assert.equal(isIngestableMatch(built[0]!.match), true);
});
test("a match whose scoreboard was missed is dropped on the next map start", () => {
const built = buildScannerMatches([
mapStart(0),
death(60, "l2"),
mapStart(400),
death(460, "l3"),
scoreboard(700),
]);
assert.equal(built.length, 1);
assert.deepEqual(
built[0]!.sources.map((e) => e.t),
[400, 460, 700],
);
});
test("a trailing map start with deaths but no scoreboard identifies no match", () => {
assert.deepEqual(buildScannerMatches([mapStart(0), death(60, "l2")]), []);
});
test("event types that identify no match are ignored", () => {
const own: DetectedEvent = {
type: "ScoreboardOwn",
t: 310,
confidence: 0.9,
data: {
lobby: "PRIVATE",
mode: null,
stage: null,
weaponId: null,
abilities: [],
},
};
const built = buildScannerMatches([mapStart(0), own, scoreboard(300), own]);
assert.equal(built.length, 1);
assert.deepEqual(
built[0]!.sources.map((e) => e.type),
["MapStart", "Scoreboard"],
);
});
test("a replay scoreboard supplies replay code, set score and recording time", () => {
const event = replayScoreboard(300, { timestamp: "25.12.2025 21:30" });
event.detectedAt = Date.UTC(2025, 11, 26, 12, 0);
const built = buildScannerMatches([event]);
const match = built[0]!.match;
assert.equal(match.replayCode, "RABC-DEFG-HIJK-LMNO");
assert.deepEqual(match.matchScores, [3, 1]);
assert.equal(match.playedAt, new Date(2025, 11, 25, 21, 30).getTime());
});
test("without a replay timestamp, playedAt falls back to the scoreboard's detection time", () => {
const event = scoreboard(300) as DetectedEvent & { detectedAt?: number };
event.detectedAt = 1_700_000_000_000;
const built = buildScannerMatches([event]);
assert.equal(built[0]!.match.playedAt, 1_700_000_000_000);
});
test("a minimap-only match has no playedAt and no winner", () => {
const built = buildScannerMatches([minimap(70), minimap(120)]);
const match = built[0]!.match;
assert.equal(match.playedAt, null);
assert.equal(match.winner, null);
assert.equal(match.pov, null);
assert.deepEqual(
match.teams.map((team) => team.score),
[null, null],
);
});
test("a spectator map's minimaps become one cast match: weapons + stage from the minimap, mode unread", () => {
const built = buildScannerMatches([minimap(70), minimap(120)]);
assert.equal(built.length, 1);
const match = built[0]!.match;
assert.equal(match.startsAt, 70);
assert.equal(match.endsAt, 120);
assert.equal(match.mode, null);
assert.equal(match.stage, 0);
assert.equal(match.cast, true);
assert.deepEqual(weapons(match), ALL);
});
test("a pov overlay minimap is not flagged as cast", () => {
const built = buildScannerMatches([minimap(70, { spectator: false })]);
assert.equal(built[0]!.match.cast, false);
});
test("a lone misread stage neither splits the match nor poisons its stage", () => {
const built = buildScannerMatches([
minimap(70, { stage: 0 }),
minimap(90, { stage: 1 }),
minimap(110, { stage: 0 }),
minimap(130, { stage: 0 }),
]);
assert.equal(built.length, 1);
assert.equal(built[0]!.match.stage, 0);
});
test("a confirmed stage change splits even when the misread-looking frame is mid-stream", () => {
const built = buildScannerMatches([
minimap(70, { stage: 0 }),
minimap(90, { stage: 1 }),
minimap(110, { stage: 1 }),
]);
assert.equal(built.length, 2);
assert.deepEqual(
built.map((b) => b.match.stage),
[0, 1],
);
assert.deepEqual(
built.map((b) => b.match.startsAt),
[70, 90],
);
});
// KNOWN LIMITATION (documented, not desired): two consecutive games on the
// SAME stage with a between-games break shorter than MATCH_GAP_SECONDS merge
// into one match — no native UI delimits them on casted footage and the
// simplified minimap carries no signal to split on. Real mode/game detection
// should replace this.
test("same-stage rematch within the gap window merges into one match (known limitation)", () => {
const game1 = [minimap(70), minimap(150)];
const game2 = [minimap(380), minimap(460)];
assert.equal(buildScannerMatches([...game1, ...game2]).length, 1);
});
test("a stage change splits minimaps into separate per-map matches", () => {
const built = buildScannerMatches([
minimap(70, { stage: 0 }),
minimap(120, { stage: 0 }),
minimap(400, { stage: 1 }),
]);
assert.equal(built.length, 2);
assert.deepEqual(
built.map((b) => b.match.stage),
[0, 1],
);
assert.deepEqual(
built.map((b) => b.match.startsAt),
[70, 400],
);
});
test("a large time gap splits even same-stage minimaps (different games)", () => {
const built = buildScannerMatches([minimap(70), minimap(90), minimap(600)]);
assert.equal(built.length, 2);
assert.deepEqual(
built.map((b) => b.match.startsAt),
[70, 600],
);
});
test("minimaps of one game (close in time, same stage) stay one match", () => {
const built = buildScannerMatches([minimap(70), minimap(90), minimap(250)]);
assert.equal(built.length, 1);
assert.equal(built[0]!.match.startsAt, 70);
});
test("weapon slots are merged across a match's minimap frames", () => {
const frame1 = minimap(70, { alpha: [null, 1001, null, 3030] });
const frame2 = minimap(90, { alpha: [40, null, 2010, 3030] });
const built = buildScannerMatches([frame1, frame2]);
assert.deepEqual(weapons(built[0]!.match), ALL);
});
test("a slot no frame read stays null for consumers to skip on", () => {
const built = buildScannerMatches([
minimap(70, { alpha: [40, 1001, 2010, null] }),
]);
assert.deepEqual(weapons(built[0]!.match), [40, 1001, 2010, null, ...BRAVO]);
});
test("a MapStart supplies the real mode and opens a match", () => {
const built = buildScannerMatches([
mapStart(30, { mode: "RM", stage: 6 }),
minimap(70, { stage: 6 }),
]);
assert.equal(built.length, 1);
assert.equal(built[0]!.match.mode, "RM");
assert.equal(built[0]!.match.startsAt, 30);
});
test("a scoreboard is the preferred weapon/mode source and closes a match", () => {
const boardWeapons: (MainWeaponId | null)[] = [
10, 10, 10, 10, 20, 20, 20, 20,
];
const built = buildScannerMatches([
minimap(70),
scoreboard(330, { mode: "TC", weapons: boardWeapons }),
]);
assert.equal(built.length, 1);
assert.equal(built[0]!.match.mode, "TC");
assert.deepEqual(weapons(built[0]!.match), boardWeapons);
assert.equal(built[0]!.match.startsAt, 70);
});
test("no minimaps and no scoreboard means no match", () => {
assert.deepEqual(buildScannerMatches([mapStart(30), mapStart(400)]), []);
});

View File

@@ -1,222 +0,0 @@
import assert from "node:assert/strict";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import type {
MinimapData,
MinimapEnemy,
MinimapTeammate,
} from "../core/detectors/minimap/index";
import { SPECTATOR_SLOTS } from "../core/detectors/minimap/rois";
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
import type { DetectedEvent } from "../core/detectors/types";
import { buildVodMatches } from "../core/vod-matches";
import test from "./node-test-compat";
const ALPHA: MainWeaponId[] = [40, 1001, 2010, 3030];
const BRAVO: MainWeaponId[] = [50, 210, 4010, 8000];
const ALL = [...ALPHA, ...BRAVO];
function teammate(weaponId: MainWeaponId | null, i: number): MinimapTeammate {
return {
slot: SPECTATOR_SLOTS[i]!,
name: null,
weaponId,
abilities: [],
};
}
function enemy(weaponId: MainWeaponId | null): MinimapEnemy {
return {
name: null,
weaponId,
abilities: [],
};
}
function minimap(
t: number,
{
stage = 0 as StageId | null,
alpha = ALPHA as (MainWeaponId | null)[],
bravo = BRAVO as (MainWeaponId | null)[],
} = {},
): DetectedEvent {
const data: MinimapData = {
stage,
spectator: true,
teammates: alpha.map(teammate),
enemies: bravo.map(enemy),
};
return { type: "Minimap", t, confidence: 0.8, data };
}
function mapStart(
t: number,
{ mode = "SZ" as ModeShort | null, stage = 0 as StageId | null } = {},
): DetectedEvent {
return { type: "MapStart", t, confidence: 0.9, data: { mode, stage } };
}
function scoreboard(
t: number,
{
mode = "SZ" as ModeShort | null,
stage = 0 as StageId | null,
weapons = ALL as (MainWeaponId | null)[],
} = {},
): DetectedEvent {
const data: ScoreboardData = {
lobby: "PRIVATE",
mode,
stage,
scores: [100, 47],
players: weapons.map((weaponId, i) => ({
name: `p${i}`,
weaponId,
paint: 1000,
ka: 10,
d: 5,
s: 2,
})),
povIndex: null,
};
return { type: "Scoreboard", t, confidence: 0.9, data };
}
test("a spectator map's minimaps become one match: weapons + stage from the minimap, mode defaulted", () => {
const matches = buildVodMatches([minimap(70), minimap(120)]);
assert.equal(matches.length, 1);
assert.deepEqual(matches[0], {
startsAt: 70,
mode: "SZ", // PoC default — the minimap can't read mode
modeAssumed: true,
stage: 0,
weapons: ALL,
});
});
test("a real mode read is not flagged as assumed", () => {
const matches = buildVodMatches([mapStart(30, { mode: "RM" }), minimap(70)]);
assert.equal(matches[0]!.mode, "RM");
assert.equal(matches[0]!.modeAssumed, false);
});
test("a lone misread stage neither splits the match nor poisons its stage", () => {
// the Eeltail frame disagrees with the running stage AND is refuted by
// the next read, so it folds in as a minority vote: one match, majority
// stage, its weapons still contributing to the slot merge
const matches = buildVodMatches([
minimap(70, { stage: 0 }),
minimap(90, { stage: 1 }),
minimap(110, { stage: 0 }),
minimap(130, { stage: 0 }),
]);
assert.equal(matches.length, 1);
assert.equal(matches[0]!.stage, 0);
});
test("a confirmed stage change splits even when the misread-looking frame is mid-stream", () => {
const matches = buildVodMatches([
minimap(70, { stage: 0 }),
minimap(90, { stage: 1 }),
minimap(110, { stage: 1 }),
]);
assert.equal(matches.length, 2);
assert.deepEqual(
matches.map((m) => m.stage),
[0, 1],
);
assert.deepEqual(
matches.map((m) => m.startsAt),
[70, 90],
);
});
// KNOWN LIMITATION (documented, not desired): two consecutive games on the
// SAME stage with a between-games break shorter than MATCH_GAP_SECONDS merge
// into one match — no native UI delimits them on casted footage and the
// simplified minimap carries no signal to split on. Real mode/game detection
// should replace this.
test("same-stage rematch within the gap window merges into one match (known limitation)", () => {
const game1 = [minimap(70), minimap(150)];
const game2 = [minimap(380), minimap(460)]; // 230s after game 1's last open
const matches = buildVodMatches([...game1, ...game2]);
assert.equal(matches.length, 1);
});
test("a stage change splits minimaps into separate per-map matches", () => {
const matches = buildVodMatches([
minimap(70, { stage: 0 }),
minimap(120, { stage: 0 }),
minimap(400, { stage: 1 }),
]);
assert.equal(matches.length, 2);
assert.deepEqual(
matches.map((m) => m.stage),
[0, 1],
);
assert.deepEqual(
matches.map((m) => m.startsAt),
[70, 400],
);
});
test("a large time gap splits even same-stage minimaps (different games)", () => {
const matches = buildVodMatches([minimap(70), minimap(90), minimap(600)]);
assert.equal(matches.length, 2);
assert.deepEqual(
matches.map((m) => m.startsAt),
[70, 600],
);
});
test("minimaps of one game (close in time, same stage) stay one match", () => {
const matches = buildVodMatches([minimap(70), minimap(90), minimap(250)]);
assert.equal(matches.length, 1);
assert.equal(matches[0]!.startsAt, 70);
});
test("weapon slots are merged across a match's minimap frames", () => {
const frame1 = minimap(70, { alpha: [null, 1001, null, 3030] });
const frame2 = minimap(90, { alpha: [40, null, 2010, 3030] });
const matches = buildVodMatches([frame1, frame2]);
assert.deepEqual(matches[0]!.weapons, ALL);
});
test("a slot no frame read stays null for the endpoint to skip on", () => {
const matches = buildVodMatches([
minimap(70, { alpha: [40, 1001, 2010, null] }),
]);
assert.deepEqual(matches[0]!.weapons, [40, 1001, 2010, null, ...BRAVO]);
});
test("a MapStart supplies the real mode and opens a match", () => {
const matches = buildVodMatches([
mapStart(30, { mode: "RM", stage: 6 }),
minimap(70, { stage: 6 }),
]);
assert.equal(matches.length, 1);
assert.equal(matches[0]!.mode, "RM");
assert.equal(matches[0]!.startsAt, 30);
});
test("a scoreboard is the preferred weapon/mode source and closes a match", () => {
const boardWeapons: (MainWeaponId | null)[] = [
10, 10, 10, 10, 20, 20, 20, 20,
];
const matches = buildVodMatches([
minimap(70),
scoreboard(330, { mode: "TC", weapons: boardWeapons }),
]);
assert.equal(matches.length, 1);
assert.equal(matches[0]!.mode, "TC");
assert.deepEqual(matches[0]!.weapons, boardWeapons);
assert.equal(matches[0]!.startsAt, 70); // first minimap open
});
test("no minimaps and no scoreboard means no match", () => {
assert.deepEqual(buildVodMatches([mapStart(30), mapStart(400)]), []);
});

View File

@@ -1,11 +1,11 @@
import { type Kysely, sql } from "kysely";
/** Tables for CV-ingested match events and end-of-game scoreboards */
/** Tables for scanner-ingested matches and end-of-game scoreboards */
export async function up(db: Kysely<any>): Promise<void> {
// kysely does not wrap sqlite migrations in a transaction, so do it here
await db.transaction().execute(async (trx) => {
await trx.schema
.createTable("IngestedEvent")
.createTable("IngestedMatch")
.addColumn("id", "integer", (col) => col.primaryKey())
.addColumn("tournamentId", "integer", (col) =>
col.references("Tournament.id").onDelete("cascade"),
@@ -16,12 +16,9 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn("submitterUserId", "integer", (col) =>
col.references("User.id").onDelete("set null"),
)
.addColumn("type", "text", (col) => col.notNull())
.addColumn("t", "real", (col) => col.notNull())
.addColumn("confidence", "real", (col) => col.notNull())
.addColumn("playedAt", "integer")
.addColumn("data", "text", (col) => col.notNull())
.addColumn("detectedAt", "integer")
.addColumn("eventHash", "text", (col) => col.unique().notNull())
.addColumn("matchHash", "text", (col) => col.unique().notNull())
.addColumn("createdAt", "integer", (col) =>
col.notNull().defaultTo(sql`(strftime('%s', 'now'))`),
)
@@ -30,15 +27,15 @@ export async function up(db: Kysely<any>): Promise<void> {
.execute();
await trx.schema
.createIndex("ingested_event_tournament_id")
.on("IngestedEvent")
.createIndex("ingested_match_tournament_id")
.on("IngestedMatch")
.column("tournamentId")
.execute();
await trx.schema
.createIndex("ingested_event_pov_user_id")
.on("IngestedEvent")
.column("povUserId")
.createIndex("ingested_match_pov_user_id_played_at")
.on("IngestedMatch")
.columns(["povUserId", "playedAt"])
.execute();
await trx.schema