mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-10 05:06:15 -05:00
Send matches
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
288
app/features/scanner-ingest/core/Matches.test.ts
Normal file
288
app/features/scanner-ingest/core/Matches.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
339
app/features/scanner-ingest/core/Matches.ts
Normal file
339
app/features/scanner-ingest/core/Matches.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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: ["W1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"],
|
||||
}),
|
||||
],
|
||||
@@ -366,8 +384,8 @@ describe("matchedScoreboards", () => {
|
||||
|
||||
it("keeps players whose name appears twice on the same side", () => {
|
||||
const scoreboards = Scoreboards.matchedScoreboards({
|
||||
events: [
|
||||
testScoreboard({
|
||||
matches: [
|
||||
testMatch({
|
||||
names: ["dupe", "dupe", "w3", "w4", "l1", "l2", "l3", "dupe"],
|
||||
}),
|
||||
],
|
||||
@@ -381,9 +399,9 @@ describe("matchedScoreboards", () => {
|
||||
|
||||
it("does not assign a game played before the previously assigned one", () => {
|
||||
const scoreboards = Scoreboards.matchedScoreboards({
|
||||
events: [
|
||||
testScoreboard({ t: 60, mode: "RM", stage: 1 }),
|
||||
testScoreboard({ t: 1000, mode: "SZ", stage: 0 }),
|
||||
matches: [
|
||||
testMatch({ t: 60, mode: "RM", stage: 1 }),
|
||||
testMatch({ t: 1000, mode: "SZ", stage: 0 }),
|
||||
],
|
||||
games: [
|
||||
testGame({
|
||||
@@ -427,13 +445,13 @@ describe("resolveTournamentId", () => {
|
||||
}
|
||||
|
||||
const seenSequence = [
|
||||
testScoreboard({ t: 60, mode: "SZ", stage: 0 }),
|
||||
testScoreboard({ t: 600, mode: "TC", stage: 1 }),
|
||||
testMatch({ t: 60, mode: "SZ", stage: 0 }),
|
||||
testMatch({ t: 600, mode: "TC", stage: 1 }),
|
||||
];
|
||||
|
||||
it("resolves the tournament whose games match the scoreboard sequence", () => {
|
||||
it("resolves the tournament whose games match the seen sequence", () => {
|
||||
const tournamentId = Scoreboards.resolveTournamentId({
|
||||
events: seenSequence,
|
||||
matches: seenSequence,
|
||||
games: [
|
||||
...tournamentGames(1, [
|
||||
["SZ", 0],
|
||||
@@ -449,9 +467,9 @@ describe("resolveTournamentId", () => {
|
||||
expect(tournamentId).toBe(1);
|
||||
});
|
||||
|
||||
it("does not resolve from a single matching scoreboard", () => {
|
||||
it("does not resolve from a single matching match", () => {
|
||||
const tournamentId = Scoreboards.resolveTournamentId({
|
||||
events: [seenSequence[0]!],
|
||||
matches: [seenSequence[0]!],
|
||||
games: tournamentGames(1, [
|
||||
["SZ", 0],
|
||||
["TC", 1],
|
||||
@@ -467,13 +485,13 @@ describe("resolveTournamentId", () => {
|
||||
["TC", 1],
|
||||
];
|
||||
const tournamentId = Scoreboards.resolveTournamentId({
|
||||
events: seenSequence,
|
||||
matches: seenSequence,
|
||||
games: [
|
||||
...tournamentGames(1, sharedMaplist, {
|
||||
winnerInGameNames: ["w1", "w2", "w3", "w4"],
|
||||
loserInGameNames: ["l1", "l2", "l3", "l4"],
|
||||
}),
|
||||
// the other tournament's rosters contradict the scoreboard sides
|
||||
// the other tournament's rosters contradict the match sides
|
||||
...tournamentGames(2, sharedMaplist, {
|
||||
winnerInGameNames: ["l1", "l2", "l3", "l4"],
|
||||
loserInGameNames: ["w1", "w2", "w3", "w4"],
|
||||
@@ -484,11 +502,11 @@ describe("resolveTournamentId", () => {
|
||||
expect(tournamentId).toBe(1);
|
||||
});
|
||||
|
||||
it("skips unreadable scoreboards but resolves from the rest", () => {
|
||||
it("skips unreadable matches but resolves from the rest", () => {
|
||||
const tournamentId = Scoreboards.resolveTournamentId({
|
||||
events: [
|
||||
matches: [
|
||||
seenSequence[0]!,
|
||||
testScoreboard({ t: 300, stage: null }),
|
||||
testMatch({ t: 300, stage: null }),
|
||||
seenSequence[1]!,
|
||||
],
|
||||
games: [
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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" }
|
||||
>;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)}` : ""}`;
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
360
app/features/scanner/core/match-builder.ts
Normal file
360
app/features/scanner/core/match-builder.ts
Normal 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;
|
||||
}
|
||||
62
app/features/scanner/core/scanner-match.ts
Normal file
62
app/features/scanner/core/scanner-match.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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), []);
|
||||
});
|
||||
494
app/features/scanner/tests/match-builder.test.ts
Normal file
494
app/features/scanner/tests/match-builder.test.ts
Normal 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)]), []);
|
||||
});
|
||||
@@ -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)]), []);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user