mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-28 06:08:13 -05:00
Adjust ingest logic
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 }];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: ["W1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"],
|
||||
@@ -467,26 +352,11 @@ describe("matchedScoreboards", () => {
|
||||
|
||||
// "W1" matches winner roster "w1#1234" straight (1) but "w3" on the
|
||||
// winning side would match the loser roster flipped (1); straight wins ties
|
||||
expect(scoreboards).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps players whose name appears twice on the same side", () => {
|
||||
const scoreboards = Scoreboards.matchedScoreboards({
|
||||
matches: [
|
||||
testMatch({
|
||||
names: ["dupe", "dupe", "w3", "w4", "l1", "l2", "l3", "dupe"],
|
||||
}),
|
||||
],
|
||||
games: [testGame()],
|
||||
});
|
||||
|
||||
expect(
|
||||
scoreboards[0]!.data.players.filter((p) => p.name === "dupe"),
|
||||
).toHaveLength(3);
|
||||
expect(matched).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not assign a game played before the previously assigned one", () => {
|
||||
const scoreboards = Scoreboards.matchedScoreboards({
|
||||
const matched = Scoreboards.matchedGames({
|
||||
matches: [
|
||||
testMatch({ t: 60, mode: "RM", stage: 1 }),
|
||||
testMatch({ t: 1000, mode: "SZ", stage: 0 }),
|
||||
@@ -507,17 +377,218 @@ describe("matchedScoreboards", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(scoreboards.map((s) => s.tournamentMatchId)).toEqual([2]);
|
||||
expect(matched.map(tournamentMatchIdOf)).toEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTournamentId", () => {
|
||||
describe("deriveScoreboardData", () => {
|
||||
function derive(
|
||||
linked: Array<{ data: ScannerMatch; povUserId: number | null }>,
|
||||
) {
|
||||
return Scoreboards.deriveScoreboardData({
|
||||
linked,
|
||||
winnerTeamId: WINNER_TEAM_ID,
|
||||
loserTeamId: LOSER_TEAM_ID,
|
||||
});
|
||||
}
|
||||
|
||||
it("projects a match winner-first into scoreboard data", () => {
|
||||
const data = derive([{ data: testMatch(), povUserId: null }]);
|
||||
|
||||
expect(data).toEqual({
|
||||
scores: [100, 52],
|
||||
players: ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"].map(
|
||||
(name, i) => ({
|
||||
name,
|
||||
tournamentTeamId: i < 4 ? WINNER_TEAM_ID : LOSER_TEAM_ID,
|
||||
weaponSplId: i < 4 ? 10 : 20,
|
||||
ka: 10,
|
||||
d: 5,
|
||||
s: 2,
|
||||
paint: 1000,
|
||||
}),
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it("a winner-1 match derives identically to its winner-0 mirror", () => {
|
||||
const straight = derive([{ data: testMatch(), povUserId: null }]);
|
||||
const swapped = derive([{ data: swapSides(testMatch()), povUserId: null }]);
|
||||
|
||||
expect(swapped).toEqual(straight);
|
||||
});
|
||||
|
||||
it("returns null for a match that cannot form a scoreboard", () => {
|
||||
expect(derive([])).toBe(null);
|
||||
expect(
|
||||
derive([{ data: { ...testMatch(), winner: null }, povUserId: null }]),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it("rebases counter samples to the game's first read", () => {
|
||||
const data = derive([
|
||||
{ data: testMatch({ objective: testObjective() }), povUserId: null },
|
||||
]);
|
||||
|
||||
expect(data!.objective).toEqual({
|
||||
mode: "SZ",
|
||||
samples: [
|
||||
{
|
||||
t: 0,
|
||||
time: 300,
|
||||
score: [100, 100],
|
||||
penalty: [null, null],
|
||||
control: [false, false],
|
||||
},
|
||||
{
|
||||
t: 30,
|
||||
time: 270,
|
||||
score: [80, 100],
|
||||
penalty: [null, 12],
|
||||
control: [true, false],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("derives counter samples winner-first", () => {
|
||||
const straight = derive([
|
||||
{ data: testMatch({ objective: testObjective() }), povUserId: null },
|
||||
]);
|
||||
const swapped = derive([
|
||||
{
|
||||
data: swapSides(testMatch({ objective: testObjective() })),
|
||||
povUserId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(swapped!.objective).toEqual(straight!.objective);
|
||||
});
|
||||
|
||||
it("leaves out the objective of a match with no counter reads", () => {
|
||||
const data = derive([{ data: testMatch(), povUserId: null }]);
|
||||
|
||||
expect(data!.objective).toBeUndefined();
|
||||
});
|
||||
|
||||
it("carries ingested player abilities through", () => {
|
||||
const build: ScannerAbility[][] = [
|
||||
["ISM", "ISS", "ISS", "ISS"],
|
||||
["QR", "QSJ", "QSJ", "QSJ"],
|
||||
["SSU", "RSU", "RSU", "RSU"],
|
||||
];
|
||||
const data = derive([
|
||||
{ data: testMatch({ abilities: { 5: build } }), povUserId: null },
|
||||
]);
|
||||
|
||||
expect(data!.players[5]!.abilities).toEqual(build);
|
||||
expect(data!.players[0]!.abilities).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps players with unread weapon or empty name", () => {
|
||||
const data = derive([
|
||||
{
|
||||
data: testMatch({
|
||||
names: ["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"],
|
||||
weapons: [10, 10, null, 10, 20, 20, 20, 20],
|
||||
}),
|
||||
povUserId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(data!.players).toHaveLength(8);
|
||||
expect(data!.players[1]!.name).toBe("");
|
||||
expect(data!.players[1]!.weaponSplId).toBe(10);
|
||||
expect(data!.players[2]!.weaponSplId).toBe(null);
|
||||
expect(data!.players[2]!.ka).toBe(10);
|
||||
});
|
||||
|
||||
it("keeps players whose name appears twice on the same side", () => {
|
||||
const data = derive([
|
||||
{
|
||||
data: testMatch({
|
||||
names: ["dupe", "dupe", "w3", "w4", "l1", "l2", "l3", "dupe"],
|
||||
}),
|
||||
povUserId: null,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(data!.players.filter((p) => p.name === "dupe")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("attributes the POV seat's row to the POV user", () => {
|
||||
const data = derive([{ data: testMatch({ povIndex: 2 }), povUserId: 42 }]);
|
||||
|
||||
expect(data!.players[2]!.userId).toBe(42);
|
||||
expect(data!.players.filter((p) => p.userId !== undefined)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("attributes a losing-side POV of a winner-1 match to the right row", () => {
|
||||
const data = derive([
|
||||
{ data: swapSides(testMatch({ povIndex: 6 })), povUserId: 42 },
|
||||
]);
|
||||
|
||||
expect(data!.players[6]!.userId).toBe(42);
|
||||
});
|
||||
|
||||
it("attributes each linked POV onto the merged scoreboard", () => {
|
||||
const data = derive([
|
||||
{ data: testMatch({ povIndex: 0 }), povUserId: 42 },
|
||||
{ data: swapSides(testMatch({ povIndex: 5 })), povUserId: 43 },
|
||||
]);
|
||||
|
||||
expect(data!.players[0]!.userId).toBe(42);
|
||||
expect(data!.players[5]!.userId).toBe(43);
|
||||
});
|
||||
|
||||
it("does not attribute the same row twice", () => {
|
||||
const data = derive([
|
||||
{ data: testMatch({ povIndex: 2 }), povUserId: 42 },
|
||||
{ data: testMatch({ povIndex: 2 }), povUserId: 43 },
|
||||
]);
|
||||
|
||||
expect(data!.players[2]!.userId).toBe(42);
|
||||
});
|
||||
|
||||
it("merges a later partial's fields under the first link's values", () => {
|
||||
const withoutScores: ScannerMatch = {
|
||||
...testMatch(),
|
||||
matchScores: null,
|
||||
};
|
||||
const data = derive([
|
||||
{ data: withoutScores, povUserId: null },
|
||||
{ data: testMatch(), povUserId: null },
|
||||
]);
|
||||
|
||||
expect(data!.scores).toEqual([100, 52]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("winnerFirstPlayerNames", () => {
|
||||
it("returns names winner-first with unread names empty", () => {
|
||||
const names = Scoreboards.winnerFirstPlayerNames(
|
||||
swapSides(
|
||||
testMatch({ names: ["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"] }),
|
||||
),
|
||||
);
|
||||
|
||||
expect(names).toEqual(["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"]);
|
||||
});
|
||||
|
||||
it("returns null for a match without a linkable scoreboard", () => {
|
||||
expect(
|
||||
Scoreboards.winnerFirstPlayerNames({ ...testMatch(), winner: null }),
|
||||
).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveContext", () => {
|
||||
/** A tournament's reported games as an ordered (mode, stageId) sequence. */
|
||||
function tournamentGames(
|
||||
tournamentId: number,
|
||||
sequence: [ModeShort, number][],
|
||||
partial: Partial<Scoreboards.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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user