mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-07 19:55:46 -05:00
More ingest stuff
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user