diff --git a/app/features/ingest/IngestRepository.server.ts b/app/features/ingest/IngestRepository.server.ts index 8c873d5c3..a495bfbfd 100644 --- a/app/features/ingest/IngestRepository.server.ts +++ b/app/features/ingest/IngestRepository.server.ts @@ -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 { +}) { + 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 { 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, diff --git a/app/features/ingest/actions/ingest.server.ts b/app/features/ingest/actions/ingest.server.ts index e3a63bbb1..143b0ba4d 100644 --- a/app/features/ingest/actions/ingest.server.ts +++ b/app/features/ingest/actions/ingest.server.ts @@ -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 diff --git a/app/features/ingest/core/Scoreboards.test.ts b/app/features/ingest/core/Scoreboards.test.ts index 457ddf543..07b8559a6 100644 --- a/app/features/ingest/core/Scoreboards.test.ts +++ b/app/features/ingest/core/Scoreboards.test.ts @@ -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.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); + }); +}); diff --git a/app/features/ingest/core/Scoreboards.ts b/app/features/ingest/core/Scoreboards.ts index 511b3ed81..af6f2e9ac 100644 --- a/app/features/ingest/core/Scoreboards.ts +++ b/app/features/ingest/core/Scoreboards.ts @@ -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(); + 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. diff --git a/app/features/ingest/ingest-schemas.ts b/app/features/ingest/ingest-schemas.ts index 101236beb..de73726ce 100644 --- a/app/features/ingest/ingest-schemas.ts +++ b/app/features/ingest/ingest-schemas.ts @@ -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(), diff --git a/vite.config.ts b/vite.config.ts index c75078f8a..de878f213 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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"], },