Adjust ingest logic

This commit is contained in:
Kalle
2026-08-07 13:25:52 +03:00
parent 691c9a9ccf
commit 07d5664bd9
8 changed files with 1397 additions and 667 deletions

View File

@@ -35,7 +35,6 @@ import type { LFGType } from "~/features/lfg/lfg-constants";
import type { SkillTeamIdentifier } from "~/features/mmr/mmr-utils";
import type { Notification as NotificationValue } from "~/features/notifications/notifications-types";
import type { ScannerMatch } from "~/features/scanner/core/scanner-match";
import type { IngestedScoreboardData } from "~/features/scanner-ingest/core/Scoreboards";
import type { SplatoonRotationType } from "~/features/splatoon-rotations/splatoon-rotations-constants";
import type {
MemberRole,
@@ -480,20 +479,25 @@ export interface ReportedWeapon {
export interface IngestedMatch {
id: GeneratedAlways<number>;
tournamentId: number | null;
povUserId: number | null;
submitterUserId: number | null;
/** database timestamp (seconds) the match was played at, when known */
playedAt: number | null;
data: JSONColumnType<ScannerMatch>;
matchHash: string;
/** server-resolved tournament the match probably belongs to; aids future linking */
tournamentIdHint: number | null;
/** server-resolved SendouQ match the match probably belongs to; aids future linking */
groupMatchIdHint: number | null;
createdAt: Generated<number>;
}
export interface IngestedScoreboard {
/** Links an ingested match to the game result it describes (exactly one target). */
export interface IngestedMatchLink {
id: GeneratedAlways<number>;
matchGameResultId: number;
data: JSONColumnType<IngestedScoreboardData>;
ingestedMatchId: number;
tournamentMatchGameResultId: number | null;
groupMatchMapId: number | null;
createdAt: Generated<number>;
}
@@ -1272,7 +1276,7 @@ export interface DB {
GroupMatchMap: GroupMatchMap;
GroupMember: GroupMember;
IngestedMatch: IngestedMatch;
IngestedScoreboard: IngestedScoreboard;
IngestedMatchLink: IngestedMatchLink;
PrivateUserNote: PrivateUserNote;
LogInLink: LogInLink;
LFGPost: LFGPost;

View File

@@ -6,10 +6,11 @@ import type { DB } from "~/db/tables";
import type { ScannerMatch } from "~/features/scanner/core/scanner-match";
import * as Matches from "./core/Matches";
import type {
IngestableGameWithTournament,
IngestedScoreboardData,
MatchedScoreboard,
IngestableGame,
IngestableGameWithContext,
IngestContext,
} from "./core/Scoreboards";
import * as Scoreboards from "./core/Scoreboards";
const opponentOneId = sql<number>`"TournamentMatch"."opponentOne" ->> '$.id'`;
const opponentTwoId = sql<number>`"TournamentMatch"."opponentTwo" ->> '$.id'`;
@@ -26,42 +27,52 @@ const MERGE_CANDIDATE_LIMIT = 50;
/**
* Stores ingested matches, merging partials: a match that
* `Matches.isSameMatch` recognizes as an already stored one (same
* tournament + POV user scope) enriches that row instead of inserting.
* Identical resends are no-ops via the content hash.
* `Matches.isSameMatch` recognizes as an already stored one (same POV user
* scope) enriches that row instead of inserting. Identical resends are
* no-ops via the content hash. The resolved context is stamped onto the
* rows as tournamentIdHint/groupMatchIdHint (existing hints win; missing
* ones are backfilled even on no-op resends).
*
* @returns counts plus the post-merge matches (a partial arriving after an
* earlier richer send attaches downstream with the merged, fuller data)
* @returns counts plus the post-merge rows (a partial arriving after an
* earlier richer send links downstream with the merged, fuller data)
*/
export async function addOrMergeMatches({
tournamentId,
povUserId,
submitterUserId,
matches,
context,
}: {
tournamentId: number | null;
povUserId: number | null;
submitterUserId: number | null;
matches: ScannerMatch[];
context: IngestContext | null;
}) {
const hints = {
tournamentIdHint:
context?.type === "tournament" ? context.tournamentId : null,
groupMatchIdHint: context?.type === "sendouq" ? context.groupMatchId : null,
};
let insertedCount = 0;
let mergedCount = 0;
const effectiveMatches: ScannerMatch[] = [];
const effectiveMatches: Array<{ id: number; data: ScannerMatch }> = [];
for (const match of matches) {
const canonical = Matches.canonicalMatch(match);
const hash = matchHash({ tournamentId, povUserId, match: canonical });
const hash = matchHash({ povUserId, match: canonical });
const effective = await db.transaction().execute(async (trx) => {
const identical = await trx
.selectFrom("IngestedMatch")
.select("data")
.select(["id", "data", "tournamentIdHint", "groupMatchIdHint"])
.where("matchHash", "=", hash)
.executeTakeFirst();
if (identical) return identical.data;
if (identical) {
await backfillHints(trx, identical, hints);
return { id: identical.id, data: identical.data };
}
const stored = await findMergeCandidate(trx, {
tournamentId,
povUserId,
match: canonical,
});
@@ -69,23 +80,24 @@ export async function addOrMergeMatches({
const inserted = await trx
.insertInto("IngestedMatch")
.values({
tournamentId,
povUserId,
submitterUserId,
playedAt: toDbTimestamp(canonical.playedAt),
data: JSON.stringify(canonical),
matchHash: hash,
...hints,
})
.onConflict((oc) => oc.column("matchHash").doNothing())
.executeTakeFirst();
if (Number(inserted.numInsertedOrUpdatedRows ?? 0) > 0) {
insertedCount++;
}
return canonical;
.returning("id")
.executeTakeFirstOrThrow();
insertedCount++;
return { id: inserted.id, data: canonical };
}
const { merged, changed } = Matches.mergeMatches(stored.data, canonical);
if (!changed) return stored.data;
if (!changed) {
await backfillHints(trx, stored, hints);
return { id: stored.id, data: stored.data };
}
const mergedCanonical = Matches.canonicalMatch(merged);
await trx
@@ -93,16 +105,14 @@ export async function addOrMergeMatches({
.set({
playedAt: toDbTimestamp(mergedCanonical.playedAt),
data: JSON.stringify(mergedCanonical),
matchHash: matchHash({
tournamentId,
povUserId,
match: mergedCanonical,
}),
matchHash: matchHash({ povUserId, match: mergedCanonical }),
tournamentIdHint: stored.tournamentIdHint ?? hints.tournamentIdHint,
groupMatchIdHint: stored.groupMatchIdHint ?? hints.groupMatchIdHint,
})
.where("id", "=", stored.id)
.execute();
mergedCount++;
return mergedCanonical;
return { id: stored.id, data: mergedCanonical };
});
effectiveMatches.push(effective);
@@ -111,19 +121,42 @@ export async function addOrMergeMatches({
return { insertedCount, mergedCount, effectiveMatches };
}
async function backfillHints(
trx: Transaction<DB>,
stored: {
id: number;
tournamentIdHint: number | null;
groupMatchIdHint: number | null;
},
hints: { tournamentIdHint: number | null; groupMatchIdHint: number | null },
) {
const tournamentIdHint = stored.tournamentIdHint ?? hints.tournamentIdHint;
const groupMatchIdHint = stored.groupMatchIdHint ?? hints.groupMatchIdHint;
if (
tournamentIdHint === stored.tournamentIdHint &&
groupMatchIdHint === stored.groupMatchIdHint
) {
return;
}
await trx
.updateTable("IngestedMatch")
.set({ tournamentIdHint, groupMatchIdHint })
.where("id", "=", stored.id)
.execute();
}
/**
* The stored match the incoming one describes the same game as, if any:
* rows in the same tournament + POV user scope, near in play time (or
* recent when either side has none), content-checked by Matches.isSameMatch.
* rows in the same POV user scope, near in play time (or recent when either
* side has none), content-checked by Matches.isSameMatch.
*/
async function findMergeCandidate(
trx: Transaction<DB>,
{
tournamentId,
povUserId,
match,
}: {
tournamentId: number | null;
povUserId: number | null;
match: ScannerMatch;
},
@@ -135,11 +168,7 @@ async function findMergeCandidate(
const candidates = await trx
.selectFrom("IngestedMatch")
.select(["id", "data"])
.$if(tournamentId === null, (qb) => qb.where("tournamentId", "is", null))
.$if(tournamentId !== null, (qb) =>
qb.where("tournamentId", "=", tournamentId!),
)
.select(["id", "data", "tournamentIdHint", "groupMatchIdHint"])
.$if(povUserId === null, (qb) => qb.where("povUserId", "is", null))
.$if(povUserId !== null, (qb) => qb.where("povUserId", "=", povUserId!))
.$if(match.playedAt !== null, (qb) =>
@@ -190,16 +219,14 @@ function toDbTimestamp(ms: number | null): number | null {
}
function matchHash({
tournamentId,
povUserId,
match,
}: {
tournamentId: number | null;
povUserId: number | null;
match: ScannerMatch;
}) {
return createHash("sha256")
.update(JSON.stringify([tournamentId, povUserId, match]))
.update(JSON.stringify([povUserId, match]))
.digest("hex");
}
@@ -208,38 +235,63 @@ export function gamesPlayedByUserInTournament(params: {
userId: number;
tournamentId: number;
}) {
return gamesPlayedByUser(params);
return tournamentGames(params);
}
/**
* Returns the games a user played in any tournament since the given
* database timestamp, in chronological order — the candidate set for
* content-based tournament resolution (Scoreboards.resolveTournamentId).
* database timestamp, in chronological order — tournament candidates for
* content-based context resolution (Scoreboards.resolveContext).
*/
export function gamesPlayedByUserSince(params: {
userId: number;
/** database timestamp (seconds) */
since: number;
}) {
return gamesPlayedByUser(params);
return tournamentGames(params);
}
async function gamesPlayedByUser({
/**
* Returns the games of a tournament's casted sets (currently streamed ones
* plus the cast history), in chronological order — the candidate set for
* cast footage, whose submitter is staff rather than a player of the games.
*/
export async function castedGamesInTournament(tournamentId: number) {
const tournament = await db
.selectFrom("Tournament")
.select("castedMatchesInfo")
.where("Tournament.id", "=", tournamentId)
.executeTakeFirst();
const castedMatchesInfo = tournament?.castedMatchesInfo;
const tournamentMatchIds = [
...new Set([
...(castedMatchesInfo?.castedMatches ?? []).map(
(casted) => casted.matchId,
),
...(castedMatchesInfo?.castedMatchHistory ?? []).map(
(casted) => casted.matchId,
),
]),
];
if (tournamentMatchIds.length === 0) return [];
return tournamentGames({ tournamentId, tournamentMatchIds });
}
async function tournamentGames({
userId,
tournamentId,
tournamentMatchIds,
since,
}: {
userId: number;
userId?: number;
tournamentId?: number;
tournamentMatchIds?: number[];
since?: number;
}): Promise<IngestableGameWithTournament[]> {
}): Promise<IngestableGameWithContext[]> {
const rows = await db
.selectFrom("TournamentMatchGameResultParticipant")
.innerJoin(
"TournamentMatchGameResult",
"TournamentMatchGameResult.id",
"TournamentMatchGameResultParticipant.matchGameResultId",
)
.selectFrom("TournamentMatchGameResult")
.innerJoin(
"TournamentMatch",
"TournamentMatch.id",
@@ -250,11 +302,6 @@ async function gamesPlayedByUser({
"TournamentStage.id",
"TournamentMatch.stageId",
)
.leftJoin(
"IngestedScoreboard",
"IngestedScoreboard.matchGameResultId",
"TournamentMatchGameResult.id",
)
.select([
"TournamentMatchGameResult.id as matchGameResultId",
"TournamentMatchGameResult.matchId as tournamentMatchId",
@@ -264,14 +311,30 @@ async function gamesPlayedByUser({
"TournamentMatchGameResult.winnerTeamId",
"TournamentMatchGameResult.createdAt as playedAt",
"TournamentStage.tournamentId",
"IngestedScoreboard.data as storedScoreboardData",
opponentOneId.as("opponentOneId"),
opponentTwoId.as("opponentTwoId"),
])
.where("TournamentMatchGameResultParticipant.userId", "=", userId)
.$if(userId !== undefined, (qb) =>
qb.where((eb) =>
eb.exists(
eb
.selectFrom("TournamentMatchGameResultParticipant")
.select("TournamentMatchGameResultParticipant.userId")
.whereRef(
"TournamentMatchGameResultParticipant.matchGameResultId",
"=",
"TournamentMatchGameResult.id",
)
.where("TournamentMatchGameResultParticipant.userId", "=", userId!),
),
),
)
.$if(tournamentId !== undefined, (qb) =>
qb.where("TournamentStage.tournamentId", "=", tournamentId!),
)
.$if(tournamentMatchIds !== undefined, (qb) =>
qb.where("TournamentMatchGameResult.matchId", "in", tournamentMatchIds!),
)
.$if(since !== undefined, (qb) =>
qb.where("TournamentMatchGameResult.createdAt", ">=", since!),
)
@@ -282,6 +345,10 @@ async function gamesPlayedByUser({
const inGameNamesByTeamId = await teamInGameNames(
rows.flatMap((row) => [row.opponentOneId, row.opponentTwoId]),
);
const linkedNames = await linkedPlayerNamesByTarget(
"tournamentMatchGameResultId",
rows.map((row) => row.matchGameResultId),
);
return rows.map((row) => {
const loserTeamId =
@@ -292,22 +359,22 @@ async function gamesPlayedByUser({
: null;
return {
matchGameResultId: row.matchGameResultId,
tournamentMatchId: row.tournamentMatchId,
tournamentId: row.tournamentId,
target: {
type: "tournament",
matchGameResultId: row.matchGameResultId,
tournamentMatchId: row.tournamentMatchId,
},
context: { type: "tournament", tournamentId: row.tournamentId },
mapIndex: row.number - 1,
mode: row.mode,
stageId: row.stageId,
winnerTeamId: row.winnerTeamId,
loserTeamId,
winnerInGameNames: inGameNamesByTeamId.get(row.winnerTeamId) ?? [],
loserInGameNames:
(loserTeamId !== null
? inGameNamesByTeamId.get(loserTeamId)
: undefined) ?? [],
playedAt: row.playedAt,
storedScoreboardPlayerNames:
row.storedScoreboardData?.players.map((player) => player.name) ?? null,
linkedPlayerNames: linkedNames.get(row.matchGameResultId) ?? null,
};
});
}
@@ -343,6 +410,180 @@ async function teamInGameNames(teamIds: Array<number | null>) {
return result;
}
/** Returns a SendouQ match's games (its whole map list), in map order. */
export function gamesInGroupMatch(groupMatchId: number) {
return sendouqGames({ groupMatchId });
}
/**
* Returns the reported games of SendouQ matches a user played in since the
* given database timestamp, in chronological order — SendouQ candidates for
* content-based context resolution (Scoreboards.resolveContext).
*/
export function sendouqGamesPlayedByUserSince(params: {
userId: number;
/** database timestamp (seconds) */
since: number;
}) {
return sendouqGames(params);
}
async function sendouqGames({
groupMatchId,
userId,
since,
}: {
groupMatchId?: number;
userId?: number;
since?: number;
}): Promise<IngestableGameWithContext[]> {
const rows = await db
.selectFrom("GroupMatchMap")
.innerJoin("GroupMatch", "GroupMatch.id", "GroupMatchMap.matchId")
.select([
"GroupMatchMap.id as groupMatchMapId",
"GroupMatchMap.matchId as groupMatchId",
"GroupMatchMap.index as mapIndex",
"GroupMatchMap.mode",
"GroupMatchMap.stageId",
"GroupMatchMap.winnerGroupId",
"GroupMatch.alphaGroupId",
"GroupMatch.bravoGroupId",
"GroupMatch.createdAt as playedAt",
])
.$if(groupMatchId !== undefined, (qb) =>
qb.where("GroupMatchMap.matchId", "=", groupMatchId!),
)
.$if(userId !== undefined, (qb) =>
qb.where((eb) =>
eb.exists(
eb
.selectFrom("GroupMember")
.select("GroupMember.userId")
.where("GroupMember.userId", "=", userId!)
.where((memberEb) =>
memberEb.or([
memberEb(
"GroupMember.groupId",
"=",
memberEb.ref("GroupMatch.alphaGroupId"),
),
memberEb(
"GroupMember.groupId",
"=",
memberEb.ref("GroupMatch.bravoGroupId"),
),
]),
),
),
),
)
// content resolution walks played games only; a current match's
// pre-generated unplayed maps would flood the candidate sequence
.$if(since !== undefined, (qb) =>
qb
.where("GroupMatch.createdAt", ">=", since!)
.where("GroupMatchMap.winnerGroupId", "is not", null),
)
.orderBy("GroupMatch.createdAt", "asc")
.orderBy("GroupMatchMap.index", "asc")
.execute();
const inGameNamesByGroupId = await groupInGameNames(
rows.flatMap((row) => [row.alphaGroupId, row.bravoGroupId]),
);
const linkedNames = await linkedPlayerNamesByTarget(
"groupMatchMapId",
rows.map((row) => row.groupMatchMapId),
);
return rows.map((row) => {
const loserGroupId =
row.winnerGroupId === row.alphaGroupId
? row.bravoGroupId
: row.winnerGroupId === row.bravoGroupId
? row.alphaGroupId
: null;
return {
target: {
type: "sendouq",
groupMatchMapId: row.groupMatchMapId,
groupMatchId: row.groupMatchId,
},
context: { type: "sendouq", groupMatchId: row.groupMatchId },
mapIndex: row.mapIndex,
mode: row.mode,
stageId: row.stageId,
winnerInGameNames:
(row.winnerGroupId !== null
? inGameNamesByGroupId.get(row.winnerGroupId)
: undefined) ?? [],
loserInGameNames:
(loserGroupId !== null
? inGameNamesByGroupId.get(loserGroupId)
: undefined) ?? [],
playedAt: row.playedAt,
linkedPlayerNames: linkedNames.get(row.groupMatchMapId) ?? null,
};
});
}
async function groupInGameNames(groupIds: number[]) {
const uniqueGroupIds = [...new Set(groupIds)];
if (uniqueGroupIds.length === 0) return new Map<number, string[]>();
const members = await db
.selectFrom("GroupMember")
.innerJoin("User", "User.id", "GroupMember.userId")
.select(["GroupMember.groupId", "User.inGameName"])
.where("GroupMember.groupId", "in", uniqueGroupIds)
.execute();
const result = new Map<number, string[]>();
for (const member of members) {
if (!member.inGameName) continue;
const names = result.get(member.groupId) ?? [];
names.push(member.inGameName);
result.set(member.groupId, names);
}
return result;
}
/**
* The winner-first player names of each game's earliest linked ingested
* match, keyed by the given link target column's value.
*/
async function linkedPlayerNamesByTarget(
column: "tournamentMatchGameResultId" | "groupMatchMapId",
targetIds: number[],
) {
const result = new Map<number, string[]>();
if (targetIds.length === 0) return result;
const rows = await db
.selectFrom("IngestedMatchLink")
.innerJoin(
"IngestedMatch",
"IngestedMatch.id",
"IngestedMatchLink.ingestedMatchId",
)
.select([`IngestedMatchLink.${column} as targetId`, "IngestedMatch.data"])
.where(`IngestedMatchLink.${column}`, "in", targetIds)
.orderBy("IngestedMatchLink.createdAt", "asc")
.orderBy("IngestedMatchLink.id", "asc")
.execute();
for (const row of rows) {
if (row.targetId === null || result.has(row.targetId)) continue;
const names = Scoreboards.winnerFirstPlayerNames(row.data);
if (names) result.set(row.targetId, names);
}
return result;
}
/** How long before the events' timestamp their match may have started (long sets, swiss rounds get startedAt at creation). */
const MATCH_WINDOW_BEFORE_SECONDS = 4 * 60 * 60;
/** Event timestamps come from client clocks, so allow the match to have "started" a little after them. */
@@ -405,157 +646,296 @@ export async function tournamentIdAt({
return row?.tournamentId ?? null;
}
/** Returns the tournament's start time as a database timestamp. */
export async function tournamentStartTime(tournamentId: number) {
/** SendouQ sets run well under this long; matches created further before the events cannot be theirs. */
const GROUP_MATCH_WINDOW_BEFORE_SECONDS = 2 * 60 * 60;
/** Event timestamps come from client clocks, so allow the match to have been created a little after them. */
const GROUP_MATCH_WINDOW_AFTER_SECONDS = 60 * 60;
/**
* The SendouQ match the user was (probably) playing at the given wall-clock
* time: a group they are a member of is in a non-canceled match created
* close enough before `at`. When several qualify the latest-created wins.
*/
export async function groupMatchIdAt({
userId,
at,
}: {
userId: number;
/** wall-clock ms */
at: number;
}) {
const atSeconds = Math.floor(at / 1000);
const row = await db
.selectFrom("CalendarEvent")
.innerJoin(
"CalendarEventDate",
"CalendarEventDate.eventId",
"CalendarEvent.id",
.selectFrom("GroupMatch")
.select("GroupMatch.id")
.where((eb) =>
eb.exists(
eb
.selectFrom("GroupMember")
.select("GroupMember.userId")
.where("GroupMember.userId", "=", userId)
.where((memberEb) =>
memberEb.or([
memberEb(
"GroupMember.groupId",
"=",
memberEb.ref("GroupMatch.alphaGroupId"),
),
memberEb(
"GroupMember.groupId",
"=",
memberEb.ref("GroupMatch.bravoGroupId"),
),
]),
),
),
)
.select(({ fn }) => fn.min("CalendarEventDate.startsAt").as("startTime"))
.where("CalendarEvent.tournamentId", "=", tournamentId)
.where(
"GroupMatch.createdAt",
"<=",
atSeconds + GROUP_MATCH_WINDOW_AFTER_SECONDS,
)
.where(
"GroupMatch.createdAt",
">=",
atSeconds - GROUP_MATCH_WINDOW_BEFORE_SECONDS,
)
.where("GroupMatch.cancelAcceptedByUserId", "is", null)
.orderBy("GroupMatch.createdAt", "desc")
.executeTakeFirst();
return row?.startTime ?? null;
return row?.id ?? null;
}
/**
* Stores matched scoreboards. A game that already has a stored scoreboard
* keeps it (first ingest wins). When the scoreboard's POV player is known
* (via povIndex + povUserId), their row is attributed to the user and their
* weapon is reported as a regular ReportedWeapon, unless the user already
* has one for that game.
*
* @returns count of newly stored scoreboards
* Tournaments running a match around the given wall-clock time that the
* user helps run: they authored the event, are on its staff (organizer or
* streamer), or hold an admin/organizer/streamer role in its organization.
* The candidate contexts for cast footage.
*/
export async function addScoreboards({
scoreboards,
povUserId,
export async function staffTournamentIdsAt({
userId,
at,
}: {
scoreboards: MatchedScoreboard[];
povUserId: number | null;
}) {
let storedCount = 0;
userId: number;
/** wall-clock ms */
at: number;
}): Promise<number[]> {
const atSeconds = Math.floor(at / 1000);
for (const scoreboard of scoreboards) {
const wasInserted = await db.transaction().execute(async (trx) => {
const povPlayer =
povUserId !== null && scoreboard.povIndex !== null
? scoreboard.data.players[scoreboard.povIndex]
: undefined;
const rows = await db
.selectFrom("TournamentMatch")
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.innerJoin(
"CalendarEvent",
"CalendarEvent.tournamentId",
"TournamentStage.tournamentId",
)
.select("TournamentStage.tournamentId")
.distinct()
.where(
"TournamentMatch.startedAt",
"<=",
atSeconds + MATCH_WINDOW_AFTER_SECONDS,
)
.where(
"TournamentMatch.startedAt",
">=",
atSeconds - MATCH_WINDOW_BEFORE_SECONDS,
)
.where((eb) =>
eb.or([
eb("CalendarEvent.authorId", "=", userId),
eb.exists(
eb
.selectFrom("TournamentStaff")
.select("TournamentStaff.userId")
.whereRef(
"TournamentStaff.tournamentId",
"=",
"TournamentStage.tournamentId",
)
.where("TournamentStaff.userId", "=", userId),
),
eb.exists(
eb
.selectFrom("TournamentOrganizationMember")
.select("TournamentOrganizationMember.userId")
.whereRef(
"TournamentOrganizationMember.organizationId",
"=",
"CalendarEvent.organizationId",
)
.where("TournamentOrganizationMember.userId", "=", userId)
.where("TournamentOrganizationMember.role", "in", [
"ADMIN",
"ORGANIZER",
"STREAMER",
]),
),
]),
)
.execute();
const data: IngestedScoreboardData = povPlayer
? {
...scoreboard.data,
players: scoreboard.data.players.map((player, playerIdx) =>
playerIdx === scoreboard.povIndex
? { ...player, userId: povUserId! }
: player,
),
}
: scoreboard.data;
const insertResult = await trx
.insertInto("IngestedScoreboard")
.values({
matchGameResultId: scoreboard.matchGameResultId,
data: JSON.stringify(data),
})
.onConflict((oc) => oc.column("matchGameResultId").doNothing())
.executeTakeFirst();
const inserted = Number(insertResult.numInsertedOrUpdatedRows ?? 0) > 0;
if (!povPlayer) return inserted;
if (!inserted) {
await attributePovUser({
trx,
matchGameResultId: scoreboard.matchGameResultId,
povIndex: scoreboard.povIndex!,
userId: povUserId!,
});
}
if (povPlayer.weaponSplId !== null) {
await trx
.insertInto("ReportedWeapon")
.values({
tournamentMatchId: scoreboard.tournamentMatchId,
mapIndex: scoreboard.mapIndex,
userId: povUserId!,
weaponSplId: povPlayer.weaponSplId,
})
.onConflict((oc) =>
oc.columns(["tournamentMatchId", "mapIndex", "userId"]).doNothing(),
)
.execute();
}
return inserted;
});
if (wasInserted) storedCount++;
}
return storedCount;
return rows.map((row) => row.tournamentId);
}
async function attributePovUser({
trx,
matchGameResultId,
povIndex,
userId,
/**
* Links ingested matches to the game results they were matched to. A row
* links to at most one game (re-sends are no-ops); one game may collect
* links from many rows (each POV's scan of it). When the row's POV player
* is known, their weapon is reported as a regular ReportedWeapon, unless
* the user already has one for that game.
*
* @returns count of newly created links
*/
export async function addLinks({
links,
povUserId,
}: {
trx: Transaction<DB>;
matchGameResultId: number;
povIndex: number;
userId: number;
links: Array<{
ingestedMatchId: number;
match: ScannerMatch;
game: IngestableGame;
}>;
povUserId: number | null;
}) {
const existing = await trx
.selectFrom("IngestedScoreboard")
.select(["id", "data"])
.where("matchGameResultId", "=", matchGameResultId)
.executeTakeFirst();
if (!existing) return;
let linkedCount = 0;
if (existing.data.players.some((player) => player.userId === userId)) {
return;
for (const link of links) {
const wasInserted = await db.transaction().execute(async (trx) => {
const insertResult = await trx
.insertInto("IngestedMatchLink")
.values({
ingestedMatchId: link.ingestedMatchId,
tournamentMatchGameResultId:
link.game.target.type === "tournament"
? link.game.target.matchGameResultId
: null,
groupMatchMapId:
link.game.target.type === "sendouq"
? link.game.target.groupMatchMapId
: null,
})
.onConflict((oc) => oc.column("ingestedMatchId").doNothing())
.executeTakeFirst();
await reportPovWeapon(trx, link, povUserId);
return Number(insertResult.numInsertedOrUpdatedRows ?? 0) > 0;
});
if (wasInserted) linkedCount++;
}
const player = existing.data.players[povIndex];
if (!player || player.userId !== undefined) return;
return linkedCount;
}
const players = existing.data.players.map((other, playerIdx) =>
playerIdx === povIndex ? { ...other, userId } : other,
);
async function reportPovWeapon(
trx: Transaction<DB>,
{ match, game }: { match: ScannerMatch; game: IngestableGame },
povUserId: number | null,
) {
if (povUserId === null || match.pov === null) return;
const weaponSplId =
match.teams[match.pov.team]?.players[match.pov.index]?.weaponId ?? null;
if (weaponSplId === null) return;
await trx
.updateTable("IngestedScoreboard")
.set({ data: JSON.stringify({ ...existing.data, players }) })
.where("id", "=", existing.id)
.insertInto("ReportedWeapon")
.values({
tournamentMatchId:
game.target.type === "tournament"
? game.target.tournamentMatchId
: null,
groupMatchId:
game.target.type === "sendouq" ? game.target.groupMatchId : null,
mapIndex: game.mapIndex,
userId: povUserId,
weaponSplId,
})
.onConflict((oc) =>
oc
.columns(
game.target.type === "tournament"
? ["tournamentMatchId", "mapIndex", "userId"]
: ["groupMatchId", "mapIndex", "userId"],
)
.doNothing(),
)
.execute();
}
/** Returns a tournament match's ingested scoreboards with their 0-based map indexes. */
/**
* Returns a tournament match's ingested scoreboards with their 0-based map
* indexes, each derived from the game's linked ingested matches.
*/
export async function findScoreboardsByTournamentMatchId(
tournamentMatchId: number,
) {
const rows = await db
.selectFrom("IngestedScoreboard")
.selectFrom("IngestedMatchLink")
.innerJoin(
"IngestedMatch",
"IngestedMatch.id",
"IngestedMatchLink.ingestedMatchId",
)
.innerJoin(
"TournamentMatchGameResult",
"TournamentMatchGameResult.id",
"IngestedScoreboard.matchGameResultId",
"IngestedMatchLink.tournamentMatchGameResultId",
)
.select(["TournamentMatchGameResult.number", "IngestedScoreboard.data"])
.innerJoin(
"TournamentMatch",
"TournamentMatch.id",
"TournamentMatchGameResult.matchId",
)
.select([
"TournamentMatchGameResult.id as matchGameResultId",
"TournamentMatchGameResult.number",
"TournamentMatchGameResult.winnerTeamId",
opponentOneId.as("opponentOneId"),
opponentTwoId.as("opponentTwoId"),
"IngestedMatch.data",
"IngestedMatch.povUserId",
])
.where("TournamentMatchGameResult.matchId", "=", tournamentMatchId)
.orderBy("TournamentMatchGameResult.number", "asc")
.orderBy("IngestedMatchLink.createdAt", "asc")
.orderBy("IngestedMatchLink.id", "asc")
.execute();
return rows.map((row) => ({
mapIndex: row.number - 1,
data: row.data,
}));
const byGame = new Map<number, typeof rows>();
for (const row of rows) {
const gameRows = byGame.get(row.matchGameResultId) ?? [];
gameRows.push(row);
byGame.set(row.matchGameResultId, gameRows);
}
return [...byGame.values()].flatMap((gameRows) => {
const first = gameRows[0]!;
const loserTeamId =
first.winnerTeamId === first.opponentOneId
? first.opponentTwoId
: first.winnerTeamId === first.opponentTwoId
? first.opponentOneId
: null;
const data = Scoreboards.deriveScoreboardData({
linked: gameRows.map((row) => ({
data: row.data,
povUserId: row.povUserId,
})),
winnerTeamId: first.winnerTeamId,
loserTeamId,
});
if (!data) return [];
return [{ mapIndex: first.number - 1, data }];
});
}

View File

@@ -1,3 +1,4 @@
import { subDays } from "date-fns";
import type { ActionFunction } from "react-router";
import { Config } from "~/config";
import { requireUser } from "~/features/auth/core/user.server";
@@ -29,114 +30,220 @@ export const action: ActionFunction = async ({ request }) => {
badRequestIfFalsy(await UserRepository.findLeanById(povUserId));
}
// xxx: also pass if the ingestion is live footage, if so then check users current activity and use that info instead (can/should also be persisted?)
let tournamentId = data.tournamentId ?? null;
// the resolving content walk's candidate games, kept so the scoreboard
// matching below doesn't re-query them
let candidateGames: Scoreboards.IngestableGameWithTournament[] | null = null;
if (tournamentId) {
badRequestIfFalsy(
await ScannerIngestRepository.tournamentStartTime(tournamentId),
);
} else if (povUserId) {
// no explicit tournament: resolve from the matches' content first (the
// mode+stage sequence plus roster sides is near-unique in a user's
// history), then from when the match was played (a replay scoreboard
// carries the original recording time). Single-match requests (live
// sends) skip straight to the timestamp — content resolution needs a
// sequence to be decisive.
if (countAttachableMatches(data.matches) >= 2) {
const games = await ScannerIngestRepository.gamesPlayedByUserSince({
userId: povUserId,
since:
// xxx: use date-fns
Math.floor(Date.now() / 1000) - CONTENT_RESOLUTION_WINDOW_SECONDS,
});
tournamentId = Scoreboards.resolveTournamentId({
matches: data.matches,
games,
});
if (tournamentId) {
candidateGames = games;
logger.debug(
`ingest: resolved tournament ${tournamentId} for user ${povUserId} from match contents ` +
`(${games.length} candidate games)`,
);
}
}
if (!tournamentId) {
const at = anchorTime(data.matches);
tournamentId = await ScannerIngestRepository.tournamentIdAt({
userId: povUserId,
at,
});
logger.debug(
tournamentId
? `ingest: resolved tournament ${tournamentId} for user ${povUserId} from timestamp ${new Date(at).toISOString()}`
: `ingest: no tournament for user ${povUserId} at ${new Date(at).toISOString()} (no tournament match of theirs started around then)`,
);
}
const matches = data.matches.filter(
(match) => match.lobby === null || match.lobby === "PRIVATE",
);
if (matches.length === 0) {
return {
storedMatchesCount: 0,
mergedMatchesCount: 0,
linkedGamesCount: 0,
};
}
const resolved = await resolveIngestContext({
matches,
povUserId,
casterUserId: user?.id ?? null,
});
const { insertedCount, mergedCount, effectiveMatches } =
await ScannerIngestRepository.addOrMergeMatches({
tournamentId,
povUserId,
submitterUserId: user?.id ?? null,
matches: data.matches,
matches,
context: resolved?.context ?? null,
});
let storedScoreboardsCount = 0;
if (tournamentId && povUserId) {
const resolvedTournamentId = tournamentId;
const games = candidateGames
? candidateGames.filter(
(game) => game.tournamentId === resolvedTournamentId,
)
: await ScannerIngestRepository.gamesPlayedByUserInTournament({
userId: povUserId,
tournamentId,
});
const matched = Scoreboards.matchedScoreboards({
matches: effectiveMatches,
games,
let linkedGamesCount = 0;
if (resolved) {
const matched = Scoreboards.matchedGames({
matches: effectiveMatches.map((effective) => effective.data),
games: resolved.games,
});
storedScoreboardsCount = await ScannerIngestRepository.addScoreboards({
scoreboards: matched,
linkedGamesCount = await ScannerIngestRepository.addLinks({
links: matched.map(({ matchIndex, game }) => ({
ingestedMatchId: effectiveMatches[matchIndex]!.id,
match: effectiveMatches[matchIndex]!.data,
game,
})),
povUserId,
});
logger.debug(
matched.length > 0
? `ingest: matched ${matched.length} scoreboards in tournament ${tournamentId} to ` +
`[${matched.map((m) => `match ${m.tournamentMatchId} map ${m.mapIndex + 1}`).join(", ")}], ` +
`${storedScoreboardsCount} newly stored`
: `ingest: no scoreboards matched in tournament ${tournamentId} — user ${povUserId} has ` +
`${games.length} reported games there`,
`ingest: ${Scoreboards.contextKey(resolved.context)} matched ${matched.length} games, ` +
`${linkedGamesCount} newly linked (stored ${insertedCount}, merged ${mergedCount})`,
);
} else {
logger.debug(
`ingest: stored ${insertedCount} matches (${mergedCount} merged) without a match context ` +
`(tournamentId=${tournamentId}, povUserId=${povUserId})`,
`ingest: stored ${insertedCount} matches (${mergedCount} merged) without a resolved context ` +
`(povUserId=${povUserId})`,
);
}
return {
storedMatchesCount: insertedCount,
mergedMatchesCount: mergedCount,
storedScoreboardsCount,
linkedGamesCount,
};
};
/**
* How far back the POV user's reported games are considered as content-
* resolution candidates (365 days)
* resolution candidates
*/
const CONTENT_RESOLUTION_WINDOW_SECONDS = 365 * 24 * 60 * 60;
const CONTENT_RESOLUTION_WINDOW_DAYS = 365;
/** Matches that could attach to a tournament game: their winner is known. */
interface ResolvedIngestContext {
context: Scoreboards.IngestContext;
games: Scoreboards.IngestableGameWithContext[];
}
interface IngestContextCandidate {
context: Scoreboards.IngestContext;
loadGames: () => Promise<Scoreboards.IngestableGameWithContext[]>;
}
/**
* Resolves the context (tournament or SendouQ match) a request's matches
* belong to.
*
* The user's activity around the time the matches were played is the strong
* signal: the SendouQ match resp. tournament match of theirs running then
* (for cast footage, the casted sets of tournaments the submitter helps
* run as author/organizer/streamer). Candidates are scored by how many
* matches would link to their games; a candidate is kept even when nothing links
* yet (a live minimap-only match still gets its hint). With no activity,
* the matches' content decides: the mode+stage sequence plus roster sides
* is near-unique in a user's reported-game history.
*/
async function resolveIngestContext({
matches,
povUserId,
casterUserId,
}: {
matches: ScannerMatch[];
povUserId: number | null;
casterUserId: number | null;
}): Promise<ResolvedIngestContext | null> {
const at = anchorTime(matches);
const hasPovMatches = matches.some((match) => !match.cast);
const hasCastMatches = matches.some((match) => match.cast);
const candidates: IngestContextCandidate[] = [];
const seenContexts = new Set<string>();
const addCandidate = (candidate: IngestContextCandidate) => {
const key = Scoreboards.contextKey(candidate.context);
if (seenContexts.has(key)) return;
seenContexts.add(key);
candidates.push(candidate);
};
if (povUserId && hasPovMatches) {
const groupMatchId = await ScannerIngestRepository.groupMatchIdAt({
userId: povUserId,
at,
});
if (groupMatchId) {
addCandidate({
context: { type: "sendouq", groupMatchId },
loadGames: () =>
ScannerIngestRepository.gamesInGroupMatch(groupMatchId),
});
}
const tournamentId = await ScannerIngestRepository.tournamentIdAt({
userId: povUserId,
at,
});
if (tournamentId) {
addCandidate({
context: { type: "tournament", tournamentId },
loadGames: () =>
ScannerIngestRepository.gamesPlayedByUserInTournament({
userId: povUserId,
tournamentId,
}),
});
}
}
if (casterUserId && hasCastMatches) {
const staffTournamentIds =
await ScannerIngestRepository.staffTournamentIdsAt({
userId: casterUserId,
at,
});
for (const tournamentId of staffTournamentIds) {
addCandidate({
context: { type: "tournament", tournamentId },
loadGames: () =>
ScannerIngestRepository.castedGamesInTournament(tournamentId),
});
}
}
let best: {
candidate: IngestContextCandidate;
games: Scoreboards.IngestableGameWithContext[];
matched: number;
} | null = null;
for (const candidate of candidates) {
const games = await candidate.loadGames();
const matched = Scoreboards.matchedGames({ matches, games }).length;
if (!best || matched > best.matched) {
best = { candidate, games, matched };
}
}
if (best) {
logger.debug(
`ingest: resolved ${Scoreboards.contextKey(best.candidate.context)} for user ${povUserId} ` +
`from activity at ${new Date(at).toISOString()} (${best.matched} matches aligned, ${candidates.length} candidates)`,
);
return {
context: best.candidate.context,
games: best.games,
};
}
if (povUserId && hasPovMatches && countAttachableMatches(matches) >= 2) {
const since = Math.floor(
subDays(new Date(), CONTENT_RESOLUTION_WINDOW_DAYS).getTime() / 1000,
);
const games = [
...(await ScannerIngestRepository.gamesPlayedByUserSince({
userId: povUserId,
since,
})),
...(await ScannerIngestRepository.sendouqGamesPlayedByUserSince({
userId: povUserId,
since,
})),
];
const context = Scoreboards.resolveContext({ matches, games });
if (context) {
const key = Scoreboards.contextKey(context);
logger.debug(
`ingest: resolved ${key} for user ${povUserId} from match contents ` +
`(${games.length} candidate games)`,
);
return {
context,
games: games.filter(
(game) => Scoreboards.contextKey(game.context) === key,
),
};
}
}
logger.debug(
`ingest: no context for user ${povUserId} at ${new Date(at).toISOString()}`,
);
return null;
}
/** Matches that could link to a reported game: their winner is known. */
function countAttachableMatches(matches: ScannerMatch[]): number {
return matches.filter((match) => match.winner !== null).length;
}

View File

@@ -19,24 +19,37 @@ const WINNER_TEAM_ID = 100;
const LOSER_TEAM_ID = 200;
function testGame(
partial: Partial<Scoreboards.IngestableGame> = {},
partial: Partial<Scoreboards.IngestableGame> & {
matchGameResultId?: number;
tournamentMatchId?: number;
} = {},
): Scoreboards.IngestableGame {
const { matchGameResultId = 11, tournamentMatchId = 1, ...rest } = partial;
return {
matchGameResultId: 11,
tournamentMatchId: 1,
target: { type: "tournament", matchGameResultId, tournamentMatchId },
mapIndex: 0,
mode: "SZ",
stageId: 0 as StageId,
winnerTeamId: WINNER_TEAM_ID,
loserTeamId: LOSER_TEAM_ID,
winnerInGameNames: [],
loserInGameNames: [],
playedAt: 1000,
storedScoreboardPlayerNames: null,
...partial,
linkedPlayerNames: null,
...rest,
};
}
function gameResultId(matched: Scoreboards.MatchedGame): number | null {
return matched.game.target.type === "tournament"
? matched.game.target.matchGameResultId
: null;
}
function tournamentMatchIdOf(matched: Scoreboards.MatchedGame): number | null {
return matched.game.target.type === "tournament"
? matched.game.target.tournamentMatchId
: null;
}
function testMatch({
t = 60,
mode = "SZ",
@@ -140,190 +153,85 @@ function swapSides(match: ScannerMatch): ScannerMatch {
};
}
describe("matchedScoreboards", () => {
it("turns a matching game's match into stored scoreboard data", () => {
const scoreboards = Scoreboards.matchedScoreboards({
matches: [testMatch({ povIndex: 2 })],
games: [testGame()],
});
expect(scoreboards).toHaveLength(1);
expect(scoreboards[0]).toEqual({
matchGameResultId: 11,
tournamentMatchId: 1,
mapIndex: 0,
povIndex: 2,
data: {
scores: [100, 52],
players: ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"].map(
(name, i) => ({
name,
tournamentTeamId: i < 4 ? WINNER_TEAM_ID : LOSER_TEAM_ID,
weaponSplId: i < 4 ? 10 : 20,
ka: 10,
d: 5,
s: 2,
paint: 1000,
}),
),
},
});
});
it("a winner-1 match stores identically to its winner-0 mirror", () => {
const straight = Scoreboards.matchedScoreboards({
matches: [testMatch({ povIndex: 6 })],
games: [testGame()],
});
const swapped = Scoreboards.matchedScoreboards({
matches: [swapSides(testMatch({ povIndex: 6 }))],
games: [testGame()],
});
expect(swapped).toEqual(straight);
expect(swapped[0]!.povIndex).toBe(6);
});
it("stores counter samples rebased to the game's first read", () => {
const scoreboards = Scoreboards.matchedScoreboards({
matches: [testMatch({ objective: testObjective() })],
games: [testGame()],
});
expect(scoreboards[0]!.data.objective).toEqual({
mode: "SZ",
samples: [
{
t: 0,
time: 300,
score: [100, 100],
penalty: [null, null],
control: [false, false],
},
{
t: 30,
time: 270,
score: [80, 100],
penalty: [null, 12],
control: [true, false],
},
],
});
});
it("stores counter samples winner-first", () => {
const straight = Scoreboards.matchedScoreboards({
matches: [testMatch({ objective: testObjective() })],
games: [testGame()],
});
const swapped = Scoreboards.matchedScoreboards({
matches: [swapSides(testMatch({ objective: testObjective() }))],
games: [testGame()],
});
expect(swapped[0]!.data.objective).toEqual(straight[0]!.data.objective);
});
it("leaves out the objective of a match with no counter reads", () => {
const scoreboards = Scoreboards.matchedScoreboards({
describe("matchedGames", () => {
it("matches a game's match and reports its index", () => {
const matched = Scoreboards.matchedGames({
matches: [testMatch()],
games: [testGame()],
});
expect(scoreboards[0]!.data.objective).toBeUndefined();
expect(matched).toHaveLength(1);
expect(matched[0]!.matchIndex).toBe(0);
expect(gameResultId(matched[0]!)).toBe(11);
});
it("skips matches without a known winner", () => {
const scoreboards = Scoreboards.matchedScoreboards({
const matched = Scoreboards.matchedGames({
matches: [{ ...testMatch(), winner: null }],
games: [testGame()],
});
expect(scoreboards).toHaveLength(0);
expect(matched).toHaveLength(0);
});
it("skips matches whose teams were not fully seen", () => {
const partial = testMatch();
partial.teams[1].players.pop();
const scoreboards = Scoreboards.matchedScoreboards({
const matched = Scoreboards.matchedGames({
matches: [partial],
games: [testGame()],
});
expect(scoreboards).toHaveLength(0);
expect(matched).toHaveLength(0);
});
it("carries ingested player abilities through to the stored scoreboard", () => {
const build: ScannerAbility[][] = [
["ISM", "ISS", "ISS", "ISS"],
["QR", "QSJ", "QSJ", "QSJ"],
["SSU", "RSU", "RSU", "RSU"],
];
const scoreboards = Scoreboards.matchedScoreboards({
matches: [testMatch({ abilities: { 5: build } })],
games: [testGame()],
});
expect(scoreboards[0]!.data.players[5]!.abilities).toEqual(build);
expect(scoreboards[0]!.data.players[0]!.abilities).toBeUndefined();
});
it("skips a game whose stored scoreboard has different players", () => {
const scoreboards = Scoreboards.matchedScoreboards({
it("skips a game whose linked scoreboard has different players", () => {
const matched = Scoreboards.matchedGames({
matches: [testMatch()],
games: [
testGame({
matchGameResultId: 11,
storedScoreboardPlayerNames: ["a", "b", "c", "d", "e", "f", "g", "h"],
linkedPlayerNames: ["a", "b", "c", "d", "e", "f", "g", "h"],
}),
testGame({ matchGameResultId: 12, playedAt: 2000 }),
],
});
expect(scoreboards.map((s) => s.matchGameResultId)).toEqual([12]);
expect(matched.map(gameResultId)).toEqual([12]);
});
it("matches a re-detection of a stored scoreboard to the same game despite misread names", () => {
const scoreboards = Scoreboards.matchedScoreboards({
it("matches a re-detection of a linked scoreboard to the same game despite misread names", () => {
const matched = Scoreboards.matchedGames({
matches: [testMatch()],
games: [
testGame({
matchGameResultId: 11,
storedScoreboardPlayerNames: [
"w1",
"w2",
"w3",
"wA",
"l1",
"l2",
"l3",
"lB",
],
linkedPlayerNames: ["w1", "w2", "w3", "wA", "l1", "l2", "l3", "lB"],
}),
testGame({ matchGameResultId: 12, playedAt: 2000 }),
],
});
expect(scoreboards.map((s) => s.matchGameResultId)).toEqual([11]);
expect(matched.map(gameResultId)).toEqual([11]);
});
it("does not count unreadable names towards stored scoreboard re-detection", () => {
const scoreboards = Scoreboards.matchedScoreboards({
it("does not count unreadable names towards linked scoreboard re-detection", () => {
const matched = Scoreboards.matchedGames({
matches: [testMatch({ names: ["", "", "", "", "l1", "l2", "l3", "l4"] })],
games: [
testGame({
matchGameResultId: 11,
storedScoreboardPlayerNames: ["", "", "", "", "l1", "l2", "l3", "l4"],
linkedPlayerNames: ["", "", "", "", "l1", "l2", "l3", "l4"],
}),
testGame({ matchGameResultId: 12, playedAt: 2000 }),
],
});
expect(scoreboards.map((s) => s.matchGameResultId)).toEqual([12]);
expect(matched.map(gameResultId)).toEqual([12]);
});
it("matches matches to games by mode and stage", () => {
const scoreboards = Scoreboards.matchedScoreboards({
const matched = Scoreboards.matchedGames({
matches: [testMatch({ mode: "RM", stage: 1, t: 60 })],
games: [
testGame({ mapIndex: 0, mode: "SZ", stageId: 0 as StageId }),
@@ -331,11 +239,11 @@ describe("matchedScoreboards", () => {
],
});
expect(scoreboards.map((s) => s.mapIndex)).toEqual([1]);
expect(matched.map((m) => m.game.mapIndex)).toEqual([1]);
});
it("assigns two games on the same mode and stage in chronological order", () => {
const scoreboards = Scoreboards.matchedScoreboards({
const matched = Scoreboards.matchedGames({
matches: [
testMatch({
t: 60,
@@ -352,18 +260,14 @@ describe("matchedScoreboards", () => {
],
});
expect(
scoreboards.find((s) => s.data.players[0]!.name === "a")
?.tournamentMatchId,
).toBe(1);
expect(
scoreboards.find((s) => s.data.players[0]!.name === "i")
?.tournamentMatchId,
).toBe(2);
expect(matched.map((m) => [m.matchIndex, tournamentMatchIdOf(m)])).toEqual([
[0, 1],
[1, 2],
]);
});
it("skips duplicate detections of the same game", () => {
const scoreboards = Scoreboards.matchedScoreboards({
const matched = Scoreboards.matchedGames({
matches: [testMatch({ t: 60 }), testMatch({ t: 65 })],
games: [
testGame({ tournamentMatchId: 1, playedAt: 1000 }),
@@ -371,49 +275,30 @@ describe("matchedScoreboards", () => {
],
});
expect(scoreboards).toHaveLength(1);
expect(scoreboards[0]!.tournamentMatchId).toBe(1);
expect(matched).toHaveLength(1);
expect(tournamentMatchIdOf(matched[0]!)).toBe(1);
});
it("skips matches from other lobbies", () => {
const scoreboards = Scoreboards.matchedScoreboards({
const matched = Scoreboards.matchedGames({
matches: [testMatch({ lobby: "X" })],
games: [testGame()],
});
expect(scoreboards).toHaveLength(0);
expect(matched).toHaveLength(0);
});
it("skips matches with unreadable mode or stage", () => {
const scoreboards = Scoreboards.matchedScoreboards({
const matched = Scoreboards.matchedGames({
matches: [testMatch({ mode: null }), testMatch({ stage: null })],
games: [testGame()],
});
expect(scoreboards).toHaveLength(0);
});
it("keeps players with unread weapon or empty name", () => {
const scoreboards = Scoreboards.matchedScoreboards({
matches: [
testMatch({
names: ["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"],
weapons: [10, 10, null, 10, 20, 20, 20, 20],
}),
],
games: [testGame()],
});
const players = scoreboards[0]!.data.players;
expect(players).toHaveLength(8);
expect(players[1]!.name).toBe("");
expect(players[1]!.weaponSplId).toBe(10);
expect(players[2]!.weaponSplId).toBe(null);
expect(players[2]!.ka).toBe(10);
expect(matched).toHaveLength(0);
});
it("skips matches that have no matching game left", () => {
const scoreboards = Scoreboards.matchedScoreboards({
const matched = Scoreboards.matchedGames({
matches: [
testMatch({ t: 60 }),
testMatch({
@@ -424,11 +309,11 @@ describe("matchedScoreboards", () => {
games: [testGame()],
});
expect(scoreboards).toHaveLength(1);
expect(matched).toHaveLength(1);
});
it("skips a game whose known rosters contradict the match sides", () => {
const scoreboards = Scoreboards.matchedScoreboards({
const matched = Scoreboards.matchedGames({
matches: [testMatch()],
games: [
testGame({
@@ -447,11 +332,11 @@ describe("matchedScoreboards", () => {
],
});
expect(scoreboards.map((s) => s.tournamentMatchId)).toEqual([2]);
expect(matched.map(tournamentMatchIdOf)).toEqual([2]);
});
it("matches known in-game names ignoring discriminator, case and unicode width", () => {
const scoreboards = Scoreboards.matchedScoreboards({
const matched = Scoreboards.matchedGames({
matches: [
testMatch({
names: ["", "w2", "w3", "w4", "l1", "l2", "l3", "l4"],
@@ -467,26 +352,11 @@ describe("matchedScoreboards", () => {
// "" matches winner roster "w1#1234" straight (1) but "w3" on the
// winning side would match the loser roster flipped (1); straight wins ties
expect(scoreboards).toHaveLength(1);
});
it("keeps players whose name appears twice on the same side", () => {
const scoreboards = Scoreboards.matchedScoreboards({
matches: [
testMatch({
names: ["dupe", "dupe", "w3", "w4", "l1", "l2", "l3", "dupe"],
}),
],
games: [testGame()],
});
expect(
scoreboards[0]!.data.players.filter((p) => p.name === "dupe"),
).toHaveLength(3);
expect(matched).toHaveLength(1);
});
it("does not assign a game played before the previously assigned one", () => {
const scoreboards = Scoreboards.matchedScoreboards({
const matched = Scoreboards.matchedGames({
matches: [
testMatch({ t: 60, mode: "RM", stage: 1 }),
testMatch({ t: 1000, mode: "SZ", stage: 0 }),
@@ -507,17 +377,218 @@ describe("matchedScoreboards", () => {
],
});
expect(scoreboards.map((s) => s.tournamentMatchId)).toEqual([2]);
expect(matched.map(tournamentMatchIdOf)).toEqual([2]);
});
});
describe("resolveTournamentId", () => {
describe("deriveScoreboardData", () => {
function derive(
linked: Array<{ data: ScannerMatch; povUserId: number | null }>,
) {
return Scoreboards.deriveScoreboardData({
linked,
winnerTeamId: WINNER_TEAM_ID,
loserTeamId: LOSER_TEAM_ID,
});
}
it("projects a match winner-first into scoreboard data", () => {
const data = derive([{ data: testMatch(), povUserId: null }]);
expect(data).toEqual({
scores: [100, 52],
players: ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"].map(
(name, i) => ({
name,
tournamentTeamId: i < 4 ? WINNER_TEAM_ID : LOSER_TEAM_ID,
weaponSplId: i < 4 ? 10 : 20,
ka: 10,
d: 5,
s: 2,
paint: 1000,
}),
),
});
});
it("a winner-1 match derives identically to its winner-0 mirror", () => {
const straight = derive([{ data: testMatch(), povUserId: null }]);
const swapped = derive([{ data: swapSides(testMatch()), povUserId: null }]);
expect(swapped).toEqual(straight);
});
it("returns null for a match that cannot form a scoreboard", () => {
expect(derive([])).toBe(null);
expect(
derive([{ data: { ...testMatch(), winner: null }, povUserId: null }]),
).toBe(null);
});
it("rebases counter samples to the game's first read", () => {
const data = derive([
{ data: testMatch({ objective: testObjective() }), povUserId: null },
]);
expect(data!.objective).toEqual({
mode: "SZ",
samples: [
{
t: 0,
time: 300,
score: [100, 100],
penalty: [null, null],
control: [false, false],
},
{
t: 30,
time: 270,
score: [80, 100],
penalty: [null, 12],
control: [true, false],
},
],
});
});
it("derives counter samples winner-first", () => {
const straight = derive([
{ data: testMatch({ objective: testObjective() }), povUserId: null },
]);
const swapped = derive([
{
data: swapSides(testMatch({ objective: testObjective() })),
povUserId: null,
},
]);
expect(swapped!.objective).toEqual(straight!.objective);
});
it("leaves out the objective of a match with no counter reads", () => {
const data = derive([{ data: testMatch(), povUserId: null }]);
expect(data!.objective).toBeUndefined();
});
it("carries ingested player abilities through", () => {
const build: ScannerAbility[][] = [
["ISM", "ISS", "ISS", "ISS"],
["QR", "QSJ", "QSJ", "QSJ"],
["SSU", "RSU", "RSU", "RSU"],
];
const data = derive([
{ data: testMatch({ abilities: { 5: build } }), povUserId: null },
]);
expect(data!.players[5]!.abilities).toEqual(build);
expect(data!.players[0]!.abilities).toBeUndefined();
});
it("keeps players with unread weapon or empty name", () => {
const data = derive([
{
data: testMatch({
names: ["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"],
weapons: [10, 10, null, 10, 20, 20, 20, 20],
}),
povUserId: null,
},
]);
expect(data!.players).toHaveLength(8);
expect(data!.players[1]!.name).toBe("");
expect(data!.players[1]!.weaponSplId).toBe(10);
expect(data!.players[2]!.weaponSplId).toBe(null);
expect(data!.players[2]!.ka).toBe(10);
});
it("keeps players whose name appears twice on the same side", () => {
const data = derive([
{
data: testMatch({
names: ["dupe", "dupe", "w3", "w4", "l1", "l2", "l3", "dupe"],
}),
povUserId: null,
},
]);
expect(data!.players.filter((p) => p.name === "dupe")).toHaveLength(3);
});
it("attributes the POV seat's row to the POV user", () => {
const data = derive([{ data: testMatch({ povIndex: 2 }), povUserId: 42 }]);
expect(data!.players[2]!.userId).toBe(42);
expect(data!.players.filter((p) => p.userId !== undefined)).toHaveLength(1);
});
it("attributes a losing-side POV of a winner-1 match to the right row", () => {
const data = derive([
{ data: swapSides(testMatch({ povIndex: 6 })), povUserId: 42 },
]);
expect(data!.players[6]!.userId).toBe(42);
});
it("attributes each linked POV onto the merged scoreboard", () => {
const data = derive([
{ data: testMatch({ povIndex: 0 }), povUserId: 42 },
{ data: swapSides(testMatch({ povIndex: 5 })), povUserId: 43 },
]);
expect(data!.players[0]!.userId).toBe(42);
expect(data!.players[5]!.userId).toBe(43);
});
it("does not attribute the same row twice", () => {
const data = derive([
{ data: testMatch({ povIndex: 2 }), povUserId: 42 },
{ data: testMatch({ povIndex: 2 }), povUserId: 43 },
]);
expect(data!.players[2]!.userId).toBe(42);
});
it("merges a later partial's fields under the first link's values", () => {
const withoutScores: ScannerMatch = {
...testMatch(),
matchScores: null,
};
const data = derive([
{ data: withoutScores, povUserId: null },
{ data: testMatch(), povUserId: null },
]);
expect(data!.scores).toEqual([100, 52]);
});
});
describe("winnerFirstPlayerNames", () => {
it("returns names winner-first with unread names empty", () => {
const names = Scoreboards.winnerFirstPlayerNames(
swapSides(
testMatch({ names: ["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"] }),
),
);
expect(names).toEqual(["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"]);
});
it("returns null for a match without a linkable scoreboard", () => {
expect(
Scoreboards.winnerFirstPlayerNames({ ...testMatch(), winner: null }),
).toBe(null);
});
});
describe("resolveContext", () => {
/** A tournament's reported games as an ordered (mode, stageId) sequence. */
function tournamentGames(
tournamentId: number,
sequence: [ModeShort, number][],
partial: Partial<Scoreboards.IngestableGame> = {},
): Scoreboards.IngestableGameWithTournament[] {
): Scoreboards.IngestableGameWithContext[] {
return sequence.map(([mode, stageId], i) => ({
...testGame({
matchGameResultId: tournamentId * 1000 + i,
@@ -528,7 +599,28 @@ describe("resolveTournamentId", () => {
playedAt: 1000 + i,
...partial,
}),
tournamentId,
context: { type: "tournament", tournamentId },
}));
}
/** A SendouQ match's reported games as an ordered (mode, stageId) sequence. */
function sendouqGames(
groupMatchId: number,
sequence: [ModeShort, number][],
): Scoreboards.IngestableGameWithContext[] {
return sequence.map(([mode, stageId], i) => ({
...testGame({
mapIndex: i,
mode,
stageId: stageId as StageId,
playedAt: 1000 + i,
}),
target: {
type: "sendouq",
groupMatchMapId: groupMatchId * 1000 + i,
groupMatchId,
},
context: { type: "sendouq", groupMatchId },
}));
}
@@ -538,7 +630,7 @@ describe("resolveTournamentId", () => {
];
it("resolves the tournament whose games match the seen sequence", () => {
const tournamentId = Scoreboards.resolveTournamentId({
const context = Scoreboards.resolveContext({
matches: seenSequence,
games: [
...tournamentGames(1, [
@@ -552,11 +644,29 @@ describe("resolveTournamentId", () => {
],
});
expect(tournamentId).toBe(1);
expect(context).toEqual({ type: "tournament", tournamentId: 1 });
});
it("resolves a SendouQ match over a tournament when its games match better", () => {
const context = Scoreboards.resolveContext({
matches: seenSequence,
games: [
...tournamentGames(1, [
["SZ", 3],
["TC", 2],
]),
...sendouqGames(7, [
["SZ", 0],
["TC", 1],
]),
],
});
expect(context).toEqual({ type: "sendouq", groupMatchId: 7 });
});
it("does not resolve from a single matching match", () => {
const tournamentId = Scoreboards.resolveTournamentId({
const context = Scoreboards.resolveContext({
matches: [seenSequence[0]!],
games: tournamentGames(1, [
["SZ", 0],
@@ -564,7 +674,7 @@ describe("resolveTournamentId", () => {
]),
});
expect(tournamentId).toBe(null);
expect(context).toBe(null);
});
it("lets roster sides break a map-sequence tie", () => {
@@ -572,7 +682,7 @@ describe("resolveTournamentId", () => {
["SZ", 0],
["TC", 1],
];
const tournamentId = Scoreboards.resolveTournamentId({
const context = Scoreboards.resolveContext({
matches: seenSequence,
games: [
...tournamentGames(1, sharedMaplist, {
@@ -587,11 +697,11 @@ describe("resolveTournamentId", () => {
],
});
expect(tournamentId).toBe(1);
expect(context).toEqual({ type: "tournament", tournamentId: 1 });
});
it("skips unreadable matches but resolves from the rest", () => {
const tournamentId = Scoreboards.resolveTournamentId({
const context = Scoreboards.resolveContext({
matches: [
seenSequence[0]!,
testMatch({ t: 300, stage: null }),
@@ -609,6 +719,6 @@ describe("resolveTournamentId", () => {
],
});
expect(tournamentId).toBe(1);
expect(context).toEqual({ type: "tournament", tournamentId: 1 });
});
});

View File

@@ -11,39 +11,49 @@ import type {
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import { normalizeInGameName } from "./Matches";
import * as Matches from "./Matches";
/** Lobby header value scoreboards of tournament games are expected to have. */
/** Lobby header value scoreboards of tournament/SendouQ games are expected to have. */
const TOURNAMENT_LOBBY = "PRIVATE";
/**
* How many of the 8 player rows must carry the same readable name in the
* same position for a match to count as a re-detection of a game's
* already stored scoreboard (allows a couple of OCR misreads).
* already linked scoreboard (allows a couple of OCR misreads).
*/
const MIN_STORED_DUPLICATE_NAME_MATCHES = 6;
const MIN_LINKED_DUPLICATE_NAME_MATCHES = 6;
/** How many players on the winning (first) resp. losing side of a scoreboard. */
const PLAYERS_PER_TEAM = 4;
/**
* How many matches must align with one tournament's games for content
* How many matches must align with one context's games for content
* resolution to trust it. A single game's (mode, stage, sides) is common
* across a user's history; two already carry order.
*/
const MIN_RESOLVED_SCOREBOARDS = 2;
/** A game of a tournament match that ingested scoreboards can be matched against. */
/** The match context an ingest request was resolved to belong to. */
export type IngestContext =
| { type: "tournament"; tournamentId: number }
| { type: "sendouq"; groupMatchId: number };
/** The reported game result an ingested match can link to. */
export type IngestableGameTarget =
| {
type: "tournament";
matchGameResultId: number;
tournamentMatchId: number;
}
| { type: "sendouq"; groupMatchMapId: number; groupMatchId: number };
/** A game of a tournament or SendouQ match that ingested matches can be linked to. */
export interface IngestableGame {
matchGameResultId: number;
tournamentMatchId: number;
target: IngestableGameTarget;
/** 0-based index of the game within its match */
mapIndex: number;
mode: ModeShort;
stageId: StageId;
winnerTeamId: number;
loserTeamId: number | null;
// xxx: this should not be needed? we get winner or not from the ingested event
/** known in-game names of the winning team's roster, used to validate scoreboard sides */
winnerInGameNames: string[];
/** known in-game names of the losing team's roster, used to validate scoreboard sides */
@@ -51,12 +61,134 @@ export interface IngestableGame {
/** database timestamp used to order games chronologically across matches */
playedAt: number;
/**
* player names (in scoreboard row order) of the game's already stored
* scoreboard; null when the game has none yet. Lets matching skip taken
* games across requests while recognizing re-detections of the same
* scoreboard.
* player names (winner-first, in scoreboard row order) of an already
* linked ingested match of the game; null when the game has none yet.
* Lets matching skip taken games across requests while recognizing
* re-detections of the same scoreboard.
*/
storedScoreboardPlayerNames: string[] | null;
linkedPlayerNames: string[] | null;
}
/** A candidate game for content resolution, tagged with its context. */
export interface IngestableGameWithContext extends IngestableGame {
context: IngestContext;
}
export interface MatchedGame {
/** index into the input `matches` array */
matchIndex: number;
game: IngestableGame;
}
/** Stable grouping/equality key for an {@link IngestContext}. */
export function contextKey(context: IngestContext): string {
return context.type === "tournament"
? `tournament:${context.tournamentId}`
: `sendouq:${context.groupMatchId}`;
}
/**
* Resolves which context (tournament or SendouQ match) a request's matches
* belong to from their content alone: the candidate games (the POV user's
* reported games) are grouped by context and each context is scored by how
* many matches `matchedGames` aligns with its games — the same mode+stage
* sequence walk and roster-side validation that decides what would actually
* be linked.
*/
export function resolveContext({
matches,
games,
}: {
matches: ScannerMatch[];
games: IngestableGameWithContext[];
}): IngestContext | null {
const byContext = new Map<string, IngestableGameWithContext[]>();
for (const game of games) {
const key = contextKey(game.context);
const contextGames = byContext.get(key) ?? [];
contextGames.push(game);
byContext.set(key, contextGames);
}
let best: { context: IngestContext; matched: number } | null = null;
for (const contextGames of byContext.values()) {
const matched = matchedGames({
matches,
games: contextGames,
}).length;
if (!best || matched > best.matched) {
best = { context: contextGames[0]!.context, matched };
}
}
if (!best || best.matched < MIN_RESOLVED_SCOREBOARDS) return null;
return best.context;
}
/**
* Matches ingested matches against a context's games, deciding which game
* result each match should link to.
*
* Only matches whose winner is known with two full teams qualify (a
* minimap-only match can never link — its winner and stats are unread).
* Matches and games are both walked in chronological order: each match is
* assigned to the next not-yet-assigned game with the same mode and stage
* whose sides don't contradict the teams' known in-game names (the winning
* rows should overlap the game winner's roster, not the loser's). Matches
* from other lobbies, with unreadable mode/stage or duplicated detections
* of the same game are skipped.
*
* One session's matches may arrive over many requests (one per game), so
* games another ingest already linked to are skipped — unless the incoming
* match is a re-detection of the linked one, which is matched to the same
* game so re-sends stay idempotent and another POV's scan of the same game
* lands on it too.
*/
export function matchedGames({
matches,
games,
}: {
matches: ScannerMatch[];
games: IngestableGame[];
}): MatchedGame[] {
const views = dedupeViews(
matches
.map((match, matchIndex) => {
const view = winnerFirstView(match, matchIndex);
return view ? { ...view, matchIndex } : null;
})
.filter((view): view is IndexedView => view !== null)
.filter((view) => !view.lobby || view.lobby === TOURNAMENT_LOBBY)
.sort((a, b) => a.order - b.order),
);
const orderedGames = games.toSorted(
(a, b) => a.playedAt - b.playedAt || a.mapIndex - b.mapIndex,
);
const result: MatchedGame[] = [];
let nextGameIdx = 0;
for (const view of views) {
if (view.mode === null || view.stage === null) continue;
for (let i = nextGameIdx; i < orderedGames.length; i++) {
const game = orderedGames[i]!;
if (game.mode !== view.mode || game.stageId !== view.stage) continue;
if (game.linkedPlayerNames) {
if (!isLinkedDuplicate(view, game.linkedPlayerNames)) {
continue;
}
} else if (!sidesMatchKnownPlayers(view, game)) {
continue;
}
result.push({ matchIndex: view.matchIndex, game });
nextGameIdx = i + 1;
break;
}
}
return result;
}
export interface IngestedScoreboardPlayer {
@@ -69,10 +201,14 @@ export interface IngestedScoreboardPlayer {
paint: number | null;
/** [head, clothes, shoes] ability rows gathered from the match's death screens */
abilities?: ScannerAbility[][];
/** set only via povIndex attribution */
/** set via POV attribution of a linked ingested match */
userId?: number;
}
/**
* The scoreboard of a game derived from its linked ingested matches — the
* shape match pages render. Derived at read time, not stored.
*/
export interface IngestedScoreboardData {
/** game scores [winner, loser] (0-100; a knockout's winner is 100) */
scores: [number | null, number | null];
@@ -87,122 +223,72 @@ export interface IngestedScoreboardData {
objective?: ScannerMatchObjective;
}
export interface MatchedScoreboard {
matchGameResultId: number;
tournamentMatchId: number;
mapIndex: number;
povIndex: number | null;
data: IngestedScoreboardData;
}
/** A candidate game for content resolution, tagged with its tournament. */
export interface IngestableGameWithTournament extends IngestableGame {
tournamentId: number;
}
/**
* Resolves which tournament a request's matches belong to from their content
* alone: the candidate games (the POV user's reported games across
* tournaments) are grouped by tournament and each tournament is scored by
* how many matches `matchedScoreboards` aligns with its games — the same
* mode+stage sequence walk and roster-side validation that decides what
* would actually be stored.
* Derives a game's scoreboard from its linked ingested matches: the earliest
* link is the base and later ones enrich it (first-ingest-wins field-wise,
* via Matches.mergeMatches), the merged match is projected winner-first, and
* every linked POV seat attributes its player row to the POV user.
*
* `winnerTeamId`/`loserTeamId` are the game result's sides (tournament team
* or SendouQ group ids), stamped onto the rows for the reader.
*/
export function resolveTournamentId({
matches,
games,
export function deriveScoreboardData({
linked,
winnerTeamId,
loserTeamId,
}: {
matches: ScannerMatch[];
games: IngestableGameWithTournament[];
}): number | null {
const byTournament = new Map<number, IngestableGameWithTournament[]>();
for (const game of games) {
const tournamentGames = byTournament.get(game.tournamentId) ?? [];
tournamentGames.push(game);
byTournament.set(game.tournamentId, tournamentGames);
/** in link order, earliest first */
linked: Array<{ data: ScannerMatch; povUserId: number | null }>;
winnerTeamId: number;
loserTeamId: number | null;
}): IngestedScoreboardData | null {
const [first, ...rest] = linked;
if (!first) return null;
let merged = first.data;
for (const other of rest) {
merged = Matches.mergeMatches(merged, other.data).merged;
}
let best: { tournamentId: number; matched: number } | null = null;
for (const [tournamentId, tournamentGames] of byTournament) {
const matched = matchedScoreboards({
matches,
games: tournamentGames,
}).length;
if (!best || matched > best.matched) {
best = { tournamentId, matched };
}
}
const view = winnerFirstView(merged, 0);
if (!view) return null;
if (!best || best.matched < MIN_RESOLVED_SCOREBOARDS) return null;
return best.tournamentId;
}
/**
* Matches ingested matches against the games the POV user played and turns
* them into insertable scoreboard rows.
*
* Only matches whose winner is known with two full teams qualify (a
* minimap-only match can never attach — its winner and stats are unread).
* Matches and games are both walked in chronological order: each match is
* assigned to the next not-yet-assigned game with the same mode and stage
* whose sides don't contradict the teams' known in-game names (the winning
* rows should overlap the game winner's roster, not the loser's). Matches
* from other lobbies, with unreadable mode/stage or duplicated detections
* of the same game are skipped.
*
* One session's matches may arrive over many requests (one per game), so
* games whose scoreboard was stored by an earlier request are skipped —
* unless the incoming match is a re-detection of the stored one, which is
* matched to the same game so re-sends stay idempotent and another POV's
* attribution still lands.
*/
export function matchedScoreboards({
matches,
games,
}: {
matches: ScannerMatch[];
games: IngestableGame[];
}): MatchedScoreboard[] {
const views = dedupeViews(
matches
.map(winnerFirstView)
.filter((view): view is WinnerFirstView => view !== null)
.filter((view) => !view.lobby || view.lobby === TOURNAMENT_LOBBY)
.sort((a, b) => a.order - b.order),
);
const orderedGames = games.toSorted(
(a, b) => a.playedAt - b.playedAt || a.mapIndex - b.mapIndex,
const players = view.players.map(
(player, playerIdx): IngestedScoreboardPlayer => ({
name: player.name.trim(),
tournamentTeamId:
playerIdx < PLAYERS_PER_TEAM ? winnerTeamId : loserTeamId,
weaponSplId: player.weaponId,
ka: player.ka,
d: player.d,
s: player.s,
paint: player.paint,
...(player.abilities ? { abilities: player.abilities } : null),
}),
);
const result: MatchedScoreboard[] = [];
attributePovUsers(players, linked);
let nextGameIdx = 0;
for (const view of views) {
if (view.mode === null || view.stage === null) continue;
for (let i = nextGameIdx; i < orderedGames.length; i++) {
const game = orderedGames[i]!;
if (game.mode !== view.mode || game.stageId !== view.stage) continue;
if (game.storedScoreboardPlayerNames) {
if (!isStoredDuplicate(view, game.storedScoreboardPlayerNames)) {
continue;
}
} else if (!sidesMatchKnownPlayers(view, game)) {
continue;
}
result.push(viewToMatchedScoreboard({ view, game }));
nextGameIdx = i + 1;
break;
}
}
return result;
return {
scores: view.scores,
players,
...(view.objective ? { objective: view.objective } : null),
};
}
/**
* A match's players in stored-scoreboard order — winning team's rows first —
* with unread names as empty strings. Null when the match can't attach: its
* A match's players winner-first in scoreboard row order (unread names as
* empty strings), or null when the match has no such view — the
* `linkedPlayerNames` a game's already linked ingest contributes.
*/
export function winnerFirstPlayerNames(match: ScannerMatch): string[] | null {
const view = winnerFirstView(match, 0);
return view ? view.players.map((player) => player.name.trim()) : null;
}
/**
* A match's players in linked-scoreboard order — winning team's rows first —
* with unread names as empty strings. Null when the match can't link: its
* winner is unknown or either team wasn't fully seen.
*/
interface WinnerFirstView {
@@ -212,13 +298,17 @@ interface WinnerFirstView {
/** game scores [winner, loser] from the match's "Score:" banner */
scores: [number | null, number | null];
players: WinnerFirstPlayer[];
/** counter progress with both the sides and `t` already stored-shaped */
/** counter progress with both the sides and `t` already winner-first */
objective: ScannerMatchObjective | null;
povIndex: number | null;
/** chronological walk key: wall-clock, else video time, else input order */
order: number;
}
interface IndexedView extends WinnerFirstView {
matchIndex: number;
}
interface WinnerFirstPlayer {
name: string;
weaponId: MainWeaponId | null;
@@ -267,9 +357,9 @@ function winnerFirstView(
}
/**
* Puts a match's counter samples in stored-scoreboard shape: per-team values
* winner-first like `scores` and `players`, and `t` rebased to the game's
* first read so the samples stay meaningful without the source video.
* Puts a match's counter samples in derived-scoreboard shape: per-team
* values winner-first like `scores` and `players`, and `t` rebased to the
* game's first read so the samples stay meaningful without the source video.
*/
function winnerFirstObjective(
objective: ScannerMatchObjective | null,
@@ -293,12 +383,58 @@ function winnerFirstObjective(
};
}
/**
* Attributes each linked match's POV seat to its POV user on the merged
* rows: the seat's read name picks the row (unique name match), falling
* back to the seat's own winner-first position when the names don't
* contradict. A row already attributed, or a user already present, is left
* alone (first link wins).
*/
function attributePovUsers(
players: IngestedScoreboardPlayer[],
linked: Array<{ data: ScannerMatch; povUserId: number | null }>,
) {
for (const { data, povUserId } of linked) {
if (povUserId === null || data.pov === null) continue;
const view = winnerFirstView(data, 0);
if (!view || view.povIndex === null) continue;
if (players.some((player) => player.userId === povUserId)) continue;
const povName = Matches.normalizeInGameName(
view.players[view.povIndex]!.name,
);
const index = attributionIndex(players, povName, view.povIndex);
if (index === null || players[index]!.userId !== undefined) continue;
players[index] = { ...players[index]!, userId: povUserId };
}
}
function attributionIndex(
players: IngestedScoreboardPlayer[],
povName: string,
fallbackIndex: number,
): number | null {
if (povName) {
const hits = players.flatMap((player, index) =>
Matches.normalizeInGameName(player.name) === povName ? [index] : [],
);
if (hits.length === 1) return hits[0]!;
}
const fallback = players[fallbackIndex];
if (!fallback) return null;
const fallbackName = Matches.normalizeInGameName(fallback.name);
if (povName && fallbackName && fallbackName !== povName) return null;
return fallbackIndex;
}
/**
* Drops re-detections of the same game within one request: same mode and
* stage with every player row carrying the same name.
*/
function dedupeViews(sorted: WinnerFirstView[]): WinnerFirstView[] {
const result: WinnerFirstView[] = [];
function dedupeViews(sorted: IndexedView[]): IndexedView[] {
const result: IndexedView[] = [];
for (const view of sorted) {
const isDuplicate = result.some(
@@ -325,13 +461,13 @@ function dedupeViews(sorted: WinnerFirstView[]): WinnerFirstView[] {
function sidesMatchKnownPlayers(view: WinnerFirstView, game: IngestableGame) {
const winnerSide = view.players
.slice(0, PLAYERS_PER_TEAM)
.map((player) => normalizeInGameName(player.name));
.map((player) => Matches.normalizeInGameName(player.name));
const loserSide = view.players
.slice(PLAYERS_PER_TEAM)
.map((player) => normalizeInGameName(player.name));
.map((player) => Matches.normalizeInGameName(player.name));
const knownWinners = game.winnerInGameNames.map(normalizeInGameName);
const knownLosers = game.loserInGameNames.map(normalizeInGameName);
const knownWinners = game.winnerInGameNames.map(Matches.normalizeInGameName);
const knownLosers = game.loserInGameNames.map(Matches.normalizeInGameName);
const straight =
nameOverlap(winnerSide, knownWinners) + nameOverlap(loserSide, knownLosers);
@@ -347,55 +483,19 @@ function nameOverlap(names: string[], knownNames: string[]) {
}
/**
* Checks whether a match is a re-detection of a game's already stored
* Checks whether a match is a re-detection of a game's already linked
* scoreboard: enough player rows carry the same readable name in the same
* position. Positional comparison keeps two games between the same eight
* players apart — their row orders and sides practically always differ.
*/
function isStoredDuplicate(view: WinnerFirstView, storedPlayerNames: string[]) {
function isLinkedDuplicate(view: WinnerFirstView, linkedPlayerNames: string[]) {
const matches = view.players.filter((player, i) => {
const name = normalizeInGameName(player.name);
const storedName = storedPlayerNames[i]
? normalizeInGameName(storedPlayerNames[i])
const name = Matches.normalizeInGameName(player.name);
const linkedName = linkedPlayerNames[i]
? Matches.normalizeInGameName(linkedPlayerNames[i]!)
: "";
return name !== "" && name === storedName;
return name !== "" && name === linkedName;
}).length;
return matches >= MIN_STORED_DUPLICATE_NAME_MATCHES;
}
function viewToMatchedScoreboard({
view,
game,
}: {
view: WinnerFirstView;
game: IngestableGame;
}): MatchedScoreboard {
const players = view.players.map(
(player, playerIdx): IngestedScoreboardPlayer => {
return {
name: player.name.trim(),
tournamentTeamId:
playerIdx < PLAYERS_PER_TEAM ? game.winnerTeamId : game.loserTeamId,
weaponSplId: player.weaponId,
ka: player.ka,
d: player.d,
s: player.s,
paint: player.paint,
...(player.abilities ? { abilities: player.abilities } : null),
};
},
);
return {
matchGameResultId: game.matchGameResultId,
tournamentMatchId: game.tournamentMatchId,
mapIndex: game.mapIndex,
povIndex: view.povIndex,
data: {
scores: view.scores,
players,
...(view.objective ? { objective: view.objective } : null),
},
};
return matches >= MIN_LINKED_DUPLICATE_NAME_MATCHES;
}

View File

@@ -12,6 +12,5 @@ const MAX_MATCHES_PER_REQUEST = 50;
export const ingestBodySchema = z.object({
/** the user whose point of view the matches were detected from */
povUserId: id.optional(),
tournamentId: id.optional(),
matches: z.array(scannerMatchSchema).min(1).max(MAX_MATCHES_PER_REQUEST),
});

View File

@@ -38,7 +38,7 @@ sequenceDiagram
participant MB as match-builder
participant UI as Live/VoD tab
participant ING as /ingest (scanner-ingest)
participant DB as IngestedMatch / IngestedScoreboard
participant DB as IngestedMatch / IngestedMatchLink
Cap->>W: frame + t (live/screenshot/seek) — VoD: worker decodes its own slice
W->>W: scheduler dueDetectors() → gate() → parse()
W-->>TL: DetectedEvents
@@ -46,9 +46,9 @@ sequenceDiagram
UI->>MB: buildScannerMatches(events)
MB-->>UI: ScannerMatch[] + source events
UI->>ING: POST { matches } (Live: on match close / scan end, VoD: whole scan)
ING->>ING: resolve tournament (content sequence ≥2, else playedAt)
ING->>DB: merge-store IngestedMatch (matchHash, isSameMatch + merge)
ING->>DB: attach winner-first view → IngestedScoreboard (first-ingest-wins, POV + ReportedWeapon)
ING->>ING: resolve context (current tournament/SendouQ activity, casts via staff roles, else content sequence ≥2)
ING->>DB: merge-store IngestedMatch (matchHash, isSameMatch + merge, context hints)
ING->>DB: link matches to game results → IngestedMatchLink (POV weapon → ReportedWeapon; scoreboards derived at read time)
Note over UI: VoD "Add VoD": ScannerMatch → slim prefill param → /vods/new
```

View File

@@ -1,15 +1,12 @@
import { type Kysely, sql } from "kysely";
/** Tables for scanner-ingested matches and end-of-game scoreboards */
/** Tables for scanner-ingested matches and their links to reported game results */
export async function up(db: Kysely<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("IngestedMatch")
.addColumn("id", "integer", (col) => col.primaryKey())
.addColumn("tournamentId", "integer", (col) =>
col.references("Tournament.id").onDelete("cascade"),
)
.addColumn("povUserId", "integer", (col) =>
col.references("User.id").onDelete("set null"),
)
@@ -19,6 +16,12 @@ export async function up(db: Kysely<any>): Promise<void> {
.addColumn("playedAt", "integer")
.addColumn("data", "text", (col) => col.notNull())
.addColumn("matchHash", "text", (col) => col.unique().notNull())
.addColumn("tournamentIdHint", "integer", (col) =>
col.references("Tournament.id").onDelete("set null"),
)
.addColumn("groupMatchIdHint", "integer", (col) =>
col.references("GroupMatch.id").onDelete("set null"),
)
.addColumn("createdAt", "integer", (col) =>
col.notNull().defaultTo(sql`(strftime('%s', 'now'))`),
)
@@ -26,12 +29,6 @@ export async function up(db: Kysely<any>): Promise<void> {
.modifyEnd(sql`strict`)
.execute();
await trx.schema
.createIndex("ingested_match_tournament_id")
.on("IngestedMatch")
.column("tournamentId")
.execute();
await trx.schema
.createIndex("ingested_match_pov_user_id_played_at")
.on("IngestedMatch")
@@ -39,20 +36,53 @@ export async function up(db: Kysely<any>): Promise<void> {
.execute();
await trx.schema
.createTable("IngestedScoreboard")
.createIndex("ingested_match_tournament_id_hint")
.on("IngestedMatch")
.column("tournamentIdHint")
.execute();
await trx.schema
.createIndex("ingested_match_group_match_id_hint")
.on("IngestedMatch")
.column("groupMatchIdHint")
.execute();
await trx.schema
.createTable("IngestedMatchLink")
.addColumn("id", "integer", (col) => col.primaryKey())
.addColumn("matchGameResultId", "integer", (col) =>
.addColumn("ingestedMatchId", "integer", (col) =>
col
.notNull()
.unique()
.references("TournamentMatchGameResult.id")
.references("IngestedMatch.id")
.onDelete("cascade"),
)
.addColumn("data", "text", (col) => col.notNull())
.addColumn("tournamentMatchGameResultId", "integer", (col) =>
col.references("TournamentMatchGameResult.id").onDelete("cascade"),
)
.addColumn("groupMatchMapId", "integer", (col) =>
col.references("GroupMatchMap.id").onDelete("cascade"),
)
.addColumn("createdAt", "integer", (col) =>
col.notNull().defaultTo(sql`(strftime('%s', 'now'))`),
)
.addCheckConstraint(
"ingested_match_link_one_target",
sql`("tournamentMatchGameResultId" is not null) + ("groupMatchMapId" is not null) = 1`,
)
.modifyEnd(sql`strict`)
.execute();
await trx.schema
.createIndex("ingested_match_link_tournament_match_game_result_id")
.on("IngestedMatchLink")
.column("tournamentMatchGameResultId")
.execute();
await trx.schema
.createIndex("ingested_match_link_group_match_map_id")
.on("IngestedMatchLink")
.column("groupMatchMapId")
.execute();
});
}