More ingest stuff

This commit is contained in:
Kalle
2026-07-19 09:42:02 +03:00
parent ebd6721c64
commit d396449804
6 changed files with 255 additions and 26 deletions

View File

@@ -3,7 +3,7 @@ import { sql, type Transaction } from "kysely";
import { db } from "~/db/sql";
import type { DB } from "~/db/tables";
import type {
IngestableGame,
IngestableGameWithTournament,
IngestedScoreboardData,
MatchedScoreboard,
} from "./core/Scoreboards";
@@ -76,13 +76,35 @@ function eventHash({
}
/** Returns the games a user played in a tournament, in chronological order. */
export async function gamesPlayedByUserInTournament({
userId,
tournamentId,
}: {
export function gamesPlayedByUserInTournament(params: {
userId: number;
tournamentId: number;
}): Promise<IngestableGame[]> {
}) {
return gamesPlayedByUser(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).
*/
export function gamesPlayedByUserSince(params: {
userId: number;
/** database timestamp (seconds) */
since: number;
}) {
return gamesPlayedByUser(params);
}
async function gamesPlayedByUser({
userId,
tournamentId,
since,
}: {
userId: number;
tournamentId?: number;
since?: number;
}): Promise<IngestableGameWithTournament[]> {
const rows = await db
.selectFrom("TournamentMatchGameResultParticipant")
.innerJoin(
@@ -113,12 +135,18 @@ export async function gamesPlayedByUserInTournament({
"TournamentMatchGameResult.stageId",
"TournamentMatchGameResult.winnerTeamId",
"TournamentMatchGameResult.createdAt as playedAt",
"TournamentStage.tournamentId",
"IngestedScoreboard.data as storedScoreboardData",
opponentOneId.as("opponentOneId"),
opponentTwoId.as("opponentTwoId"),
])
.where("TournamentMatchGameResultParticipant.userId", "=", userId)
.where("TournamentStage.tournamentId", "=", tournamentId)
.$if(tournamentId !== undefined, (qb) =>
qb.where("TournamentStage.tournamentId", "=", tournamentId!),
)
.$if(since !== undefined, (qb) =>
qb.where("TournamentMatchGameResult.createdAt", ">=", since!),
)
.orderBy("TournamentMatchGameResult.createdAt", "asc")
.orderBy("TournamentMatchGameResult.number", "asc")
.execute();
@@ -138,6 +166,7 @@ export async function gamesPlayedByUserInTournament({
return {
matchGameResultId: row.matchGameResultId,
tournamentMatchId: row.tournamentMatchId,
tournamentId: row.tournamentId,
mapIndex: row.number - 1,
mode: row.mode,
stageId: row.stageId,

View File

@@ -14,6 +14,7 @@ import { type IngestedEventInput, ingestBodySchema } from "../ingest-schemas";
// xxx: dont only attach scoreboard on ingest, also when score is reported (for e.g. tournament stuff)
// xxx: check why http://localhost:7001/to/4066/matches/139247?tab=result layout bad
// xxx: check why http://localhost:7001/to/4066/matches/139247?tab=result first game not uploaded
// xxx: this needs some thinking and documentation to cover all the cases that can be ingested
export const action: ActionFunction = async ({ request }) => {
const user = canAccessLohiEndpoint(request) ? null : requireUser();
@@ -26,21 +27,49 @@ export const action: ActionFunction = async ({ request }) => {
}
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 IngestRepository.tournamentStartTime(tournamentId));
} else if (povUserId) {
// no explicit tournament: resolve from when the events' match was
// played (a replay scoreboard carries the original recording time)
const at = anchorTime(data.events);
tournamentId = await IngestRepository.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)`,
);
// no explicit tournament: resolve from the scoreboards' content first
// (the mode+stage sequence plus roster sides is near-unique in a
// user's history), then from when the events' match was played (a
// replay scoreboard carries the original recording time). Single-
// scoreboard requests (live sends) skip straight to the timestamp —
// content resolution needs a sequence to be decisive.
if (countScoreboardEvents(data.events) >= 2) {
const games = await IngestRepository.gamesPlayedByUserSince({
userId: povUserId,
since:
// xxx: use date-fns
Math.floor(Date.now() / 1000) - CONTENT_RESOLUTION_WINDOW_SECONDS,
});
tournamentId = Scoreboards.resolveTournamentId({
events: data.events,
games,
});
if (tournamentId) {
candidateGames = games;
logger.debug(
`ingest: resolved tournament ${tournamentId} for user ${povUserId} from scoreboard contents ` +
`(${games.length} candidate games)`,
);
}
}
if (!tournamentId) {
const at = anchorTime(data.events);
tournamentId = await IngestRepository.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 storedEventsCount = await IngestRepository.addEvents({
@@ -52,10 +81,15 @@ export const action: ActionFunction = async ({ request }) => {
let storedScoreboardsCount = 0;
if (tournamentId && povUserId) {
const games = await IngestRepository.gamesPlayedByUserInTournament({
userId: povUserId,
tournamentId,
});
const resolvedTournamentId = tournamentId;
const games = candidateGames
? candidateGames.filter(
(game) => game.tournamentId === resolvedTournamentId,
)
: await IngestRepository.gamesPlayedByUserInTournament({
userId: povUserId,
tournamentId,
});
const matched = Scoreboards.matchedScoreboards({
events: data.events,
@@ -85,6 +119,18 @@ export const action: ActionFunction = async ({ request }) => {
return { storedEventsCount, storedScoreboardsCount };
};
/**
* How far back the POV user's reported games are considered as content-
* resolution candidates (365 days)
*/
const CONTENT_RESOLUTION_WINDOW_SECONDS = 365 * 24 * 60 * 60;
function countScoreboardEvents(events: IngestedEventInput[]): number {
return events.filter(
(event) => event.type === "Scoreboard" || event.type === "ScoreboardReplay",
).length;
}
/**
* The wall-clock time the events' match was (probably) played: the latest
* scoreboard's recording time (replays) or detection time, falling back to

View File

@@ -401,3 +401,105 @@ describe("matchedScoreboards", () => {
expect(scoreboards.map((s) => s.tournamentMatchId)).toEqual([2]);
});
});
describe("resolveTournamentId", () => {
/** A tournament's reported games as an ordered (mode, stageId) sequence. */
function tournamentGames(
tournamentId: number,
sequence: [ModeShort, number][],
partial: Partial<Scoreboards.IngestableGame> = {},
): Scoreboards.IngestableGameWithTournament[] {
return sequence.map(([mode, stageId], i) => ({
...testGame({
matchGameResultId: tournamentId * 1000 + i,
tournamentMatchId: tournamentId * 100,
mapIndex: i,
mode,
stageId: stageId as StageId,
playedAt: 1000 + i,
...partial,
}),
tournamentId,
}));
}
const seenSequence = [
testScoreboard({ t: 60, mode: "Splat Zones", stage: "Scorch Gorge" }),
testScoreboard({ t: 600, mode: "Tower Control", stage: "Eeltail Alley" }),
];
it("resolves the tournament whose games match the scoreboard sequence", () => {
const tournamentId = Scoreboards.resolveTournamentId({
events: seenSequence,
games: [
...tournamentGames(1, [
["SZ", 0],
["TC", 1],
]),
...tournamentGames(2, [
["SZ", 3],
["TC", 2],
]),
],
});
expect(tournamentId).toBe(1);
});
it("does not resolve from a single matching scoreboard", () => {
const tournamentId = Scoreboards.resolveTournamentId({
events: [seenSequence[0]!],
games: tournamentGames(1, [
["SZ", 0],
["TC", 1],
]),
});
expect(tournamentId).toBe(null);
});
it("lets roster sides break a map-sequence tie", () => {
const sharedMaplist: [ModeShort, number][] = [
["SZ", 0],
["TC", 1],
];
const tournamentId = Scoreboards.resolveTournamentId({
events: seenSequence,
games: [
...tournamentGames(1, sharedMaplist, {
winnerInGameNames: ["w1", "w2", "w3", "w4"],
loserInGameNames: ["l1", "l2", "l3", "l4"],
}),
// the other tournament's rosters contradict the scoreboard sides
...tournamentGames(2, sharedMaplist, {
winnerInGameNames: ["l1", "l2", "l3", "l4"],
loserInGameNames: ["w1", "w2", "w3", "w4"],
}),
],
});
expect(tournamentId).toBe(1);
});
it("skips unreadable scoreboards but resolves from the rest", () => {
const tournamentId = Scoreboards.resolveTournamentId({
events: [
seenSequence[0]!,
testScoreboard({ t: 300, stage: null }),
seenSequence[1]!,
],
games: [
...tournamentGames(1, [
["SZ", 0],
["TC", 1],
]),
...tournamentGames(2, [
["SZ", 3],
["TC", 2],
]),
],
});
expect(tournamentId).toBe(1);
});
});

View File

@@ -32,6 +32,13 @@ const MIN_STORED_DUPLICATE_NAME_MATCHES = 6;
/** How many players on the winning (first) resp. losing side of a scoreboard. */
const PLAYERS_PER_TEAM = 4;
/**
* How many scoreboards must align with one tournament's games for content
* resolution to trust it. A single game's (mode, stage, sides) is common
* across a user's history; two already carry order.
*/
const MIN_RESOLVED_SCOREBOARDS = 2;
/** A game of a tournament match that ingested scoreboards can be matched against. */
export interface IngestableGame {
matchGameResultId: number;
@@ -42,6 +49,7 @@ export interface IngestableGame {
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 */
@@ -85,6 +93,48 @@ export interface MatchedScoreboard {
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 scoreboards belong to from their
* content alone: the candidate games (the POV user's reported games across
* tournaments) are grouped by tournament and each tournament is scored by
* how many scoreboards `matchedScoreboards` aligns with its games — the
* same mode+stage sequence walk and roster-side validation that decides
* what would actually be stored.
*/
export function resolveTournamentId({
events,
games,
}: {
events: IngestedEventInput[];
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);
}
let best: { tournamentId: number; matched: number } | null = null;
for (const [tournamentId, tournamentGames] of byTournament) {
const matched = matchedScoreboards({
events,
games: tournamentGames,
}).length;
if (!best || matched > best.matched) {
best = { tournamentId, matched };
}
}
if (!best || best.matched < MIN_RESOLVED_SCOREBOARDS) return null;
return best.tournamentId;
}
/**
* Matches scoreboard events against the games the POV user played and turns
* them into insertable scoreboard rows.

View File

@@ -36,7 +36,9 @@ const scoreboardReplayDataSchema = scoreboardDataSchema.extend({
const deathDataSchema = z.object({
weapon: detectionText.nullable(),
weaponId: detectionText.nullable(),
// xxx: these ids conflict so more info will be needed
/** sendou weapon id (main/sub/special id space per weaponType) */
weaponId: z.number().int().nullable(),
weaponType: z.enum(["MAIN", "SUB", "SPECIAL"]).nullable(),
abilities: z.array(z.array(detectionText)),
name: detectionText.nullable(),

View File

@@ -9,10 +9,10 @@ export default defineConfig((config) => {
return {
server: {
port: Number(env.PORT) || 5173,
warmup: {
// Vite's built-in CORS would answer preflights before route middleware
// Vite's built-in CORS would answer preflights before route middleware
// (e.g. ingestCorsMiddleware) and omit Access-Control-Allow-Credentials
cors: false,
warmup: {
clientFiles: ["./app/entry.client.tsx", "./app/root.tsx"],
ssrFiles: ["./app/entry.server.tsx"],
},