diff --git a/app/components/match-page/MatchTimeline.tsx b/app/components/match-page/MatchTimeline.tsx index fe8d8e40d..89e48cef9 100644 --- a/app/components/match-page/MatchTimeline.tsx +++ b/app/components/match-page/MatchTimeline.tsx @@ -11,11 +11,7 @@ import { useTranslation } from "react-i18next"; import { LocaleTime } from "~/components/LocaleTime"; import type { GroupSkillDifference, UserSkillDifference } from "~/db/tables"; import { shortStageName } from "~/modules/in-game-lists/stage-ids"; -import type { - MainWeaponId, - ModeShort, - StageId, -} from "~/modules/in-game-lists/types"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import type { CommonUser } from "~/utils/kysely.server"; import { roundToNDecimalPlaces } from "~/utils/number"; import { Avatar } from "../Avatar"; @@ -24,6 +20,7 @@ import { SendouPopover } from "../elements/Popover"; import { ModeImage, StageImage } from "../Image"; import styles from "./MatchTimeline.module.css"; import { type InferredSubstitution, inferSubstitutions } from "./utils"; +import type { WeaponPoolWeapon } from "./WeaponPool"; import { WeaponPool } from "./WeaponPool"; const LONG_TEAM_NAME_THRESHOLD = 16; @@ -45,8 +42,8 @@ export interface TimelineMap { bravo: CommonUser[]; }; weapons?: { - alpha: Array; - bravo: Array; + alpha: WeaponPoolWeapon[]; + bravo: WeaponPoolWeapon[]; }; /** Optional point values [alpha, bravo] */ points?: [number, number]; @@ -259,7 +256,7 @@ function SideResult({ }: { result: "WIN" | "LOSS"; points?: number; - weapons?: Array; + weapons?: WeaponPoolWeapon[]; isPicked?: boolean; }) { const { t } = useTranslation(["q"]); diff --git a/app/components/match-page/WeaponPool.module.css b/app/components/match-page/WeaponPool.module.css index 9b5e459ee..2dde75430 100644 --- a/app/components/match-page/WeaponPool.module.css +++ b/app/components/match-page/WeaponPool.module.css @@ -25,3 +25,7 @@ font-size: var(--font-xs); font-weight: var(--weight-semi); } + +.unverifiedWeapon { + opacity: 0.7; +} diff --git a/app/components/match-page/WeaponPool.tsx b/app/components/match-page/WeaponPool.tsx index 100ede8df..94f4a7529 100644 --- a/app/components/match-page/WeaponPool.tsx +++ b/app/components/match-page/WeaponPool.tsx @@ -1,3 +1,4 @@ +import clsx from "clsx"; import { Button } from "react-aria-components"; import { useTranslation } from "react-i18next"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; @@ -6,26 +7,42 @@ import { SendouPopover } from "../elements/Popover"; import { Image, WeaponImage } from "../Image"; import styles from "./WeaponPool.module.css"; +export type WeaponPoolWeapon = + | MainWeaponId + | { + weaponSplId: MainWeaponId; + /** renders faded, e.g. an ingested weapon not yet linked to its user */ + unverified?: boolean; + } + | null; + export function WeaponPool({ weapons, size = 24, }: { - weapons: Array; + weapons: WeaponPoolWeapon[]; size?: number; }) { const { t } = useTranslation(["weapons"]); + const entries = weapons.map((weapon) => + typeof weapon === "number" ? { weaponSplId: weapon } : weapon, + ); + return ( - {weapons.map((weaponId, i) => - weaponId !== null ? ( + {entries.map((weapon, i) => + weapon !== null ? ( ) : (
- {weapons.map((weaponId, i) => - weaponId !== null ? ( -
- - {t(`weapons:MAIN_${weaponId}` as any)} + {entries.map((weapon, i) => + weapon !== null ? ( +
+ + {t(`weapons:MAIN_${weapon.weaponSplId}` as any)}
) : null, )} diff --git a/app/db/tables.ts b/app/db/tables.ts index ca82b5ddb..8394e6b52 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -8,6 +8,7 @@ import type { import type { AssociationVisibility } from "~/features/associations/associations-types"; import type { tags } from "~/features/calendar/calendar-constants"; import type { CalendarFilters } from "~/features/calendar/calendar-types"; +import type { IngestedEventData } from "~/features/ingest/ingest-schemas"; import type { TieredSkill } from "~/features/mmr/tiered.server"; import type { Notification as NotificationValue } from "~/features/notifications/notifications-types"; import type { ScrimFilters } from "~/features/scrims/scrims-types"; @@ -462,12 +463,29 @@ export interface PlusVotingResult { wasSuggested: DBBoolean; } +// xxx: or keep ReportedWeapon as it was and add some new rich stats table? export interface ReportedWeapon { groupMatchId: number | null; tournamentMatchId: number | null; mapIndex: number; - userId: number; + userId: number | null; weaponSplId: MainWeaponId; + ingestedInGameName: string | null; + ingestedTeamId: number | null; + createdAt: Generated; +} + +export interface IngestedEvent { + id: GeneratedAlways; + tournamentId: number | null; + povUserId: number | null; + submitterUserId: number | null; + type: string; + t: number; + confidence: number; + data: JSONColumnType; + detectedAt: number | null; + eventHash: string; createdAt: Generated; } @@ -1504,6 +1522,7 @@ export interface DB { GroupMatchContinueVote: GroupMatchContinueVote; GroupMatchMap: GroupMatchMap; GroupMember: GroupMember; + IngestedEvent: IngestedEvent; PrivateUserNote: PrivateUserNote; LogInLink: LogInLink; LFGPost: LFGPost; diff --git a/app/features/ingest/IngestRepository.server.ts b/app/features/ingest/IngestRepository.server.ts new file mode 100644 index 000000000..8a042aae1 --- /dev/null +++ b/app/features/ingest/IngestRepository.server.ts @@ -0,0 +1,422 @@ +import { createHash } from "node:crypto"; +import { type NotNull, sql } from "kysely"; +import { db } from "~/db/sql"; +import type { IngestableGame, IngestedWeaponRow } from "./core/Scoreboards"; +import type { IngestedEventInput } from "./ingest-schemas"; + +const opponentOneId = sql`"TournamentMatch"."opponentOne" ->> '$.id'`; +const opponentTwoId = sql`"TournamentMatch"."opponentTwo" ->> '$.id'`; + +/** + * Stores raw ingested events. Events whose contents were stored before + * (for the same tournament and POV user) are skipped. + * + * @returns count of newly stored events + */ +export async function addEvents({ + tournamentId, + povUserId, + submitterUserId, + events, +}: { + tournamentId: number | null; + povUserId: number | null; + submitterUserId: number | null; + events: IngestedEventInput[]; +}) { + const result = await db + .insertInto("IngestedEvent") + .values( + events.map((event) => ({ + tournamentId, + povUserId, + submitterUserId, + type: event.type, + t: event.t, + confidence: event.confidence, + data: JSON.stringify(event.data), + detectedAt: event.detectedAt ?? null, + eventHash: eventHash({ tournamentId, povUserId, event }), + })), + ) + .onConflict((oc) => oc.column("eventHash").doNothing()) + .execute(); + + return result.reduce( + (acc, cur) => acc + Number(cur.numInsertedOrUpdatedRows ?? 0), + 0, + ); +} + +function eventHash({ + tournamentId, + povUserId, + event, +}: { + tournamentId: number | null; + povUserId: number | null; + event: IngestedEventInput; +}) { + return createHash("sha256") + .update( + JSON.stringify([ + tournamentId, + povUserId, + event.type, + event.t, + event.data, + ]), + ) + .digest("hex"); +} + +/** Returns the games a user played in a tournament, in chronological order. */ +export async function gamesPlayedByUserInTournament({ + userId, + tournamentId, +}: { + userId: number; + tournamentId: number; +}): Promise { + const rows = await db + .selectFrom("TournamentMatchGameResultParticipant") + .innerJoin( + "TournamentMatchGameResult", + "TournamentMatchGameResult.id", + "TournamentMatchGameResultParticipant.matchGameResultId", + ) + .innerJoin( + "TournamentMatch", + "TournamentMatch.id", + "TournamentMatchGameResult.matchId", + ) + .innerJoin( + "TournamentStage", + "TournamentStage.id", + "TournamentMatch.stageId", + ) + .select([ + "TournamentMatchGameResult.matchId as tournamentMatchId", + "TournamentMatchGameResult.number", + "TournamentMatchGameResult.mode", + "TournamentMatchGameResult.stageId", + "TournamentMatchGameResult.winnerTeamId", + "TournamentMatchGameResult.createdAt as playedAt", + opponentOneId.as("opponentOneId"), + opponentTwoId.as("opponentTwoId"), + ]) + .where("TournamentMatchGameResultParticipant.userId", "=", userId) + .where("TournamentStage.tournamentId", "=", tournamentId) + .orderBy("TournamentMatchGameResult.createdAt", "asc") + .orderBy("TournamentMatchGameResult.number", "asc") + .execute(); + + const inGameNamesByTeamId = await teamInGameNames( + rows.flatMap((row) => [row.opponentOneId, row.opponentTwoId]), + ); + + return rows.map((row) => { + const loserTeamId = + row.winnerTeamId === row.opponentOneId + ? row.opponentTwoId + : row.winnerTeamId === row.opponentTwoId + ? row.opponentOneId + : null; + + return { + tournamentMatchId: row.tournamentMatchId, + 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, + }; + }); +} + +async function teamInGameNames(teamIds: Array) { + const uniqueTeamIds = [ + ...new Set(teamIds.filter((id): id is number => id !== null)), + ]; + if (uniqueTeamIds.length === 0) return new Map(); + + const members = await db + .selectFrom("TournamentTeamMember") + .innerJoin("User", "User.id", "TournamentTeamMember.userId") + .select([ + "TournamentTeamMember.tournamentTeamId", + sql< + string | null + >`coalesce("TournamentTeamMember"."inGameName", "User"."inGameName")`.as( + "inGameName", + ), + ]) + .where("TournamentTeamMember.tournamentTeamId", "in", uniqueTeamIds) + .execute(); + + const result = new Map(); + for (const member of members) { + if (!member.inGameName) continue; + const names = result.get(member.tournamentTeamId) ?? []; + names.push(member.inGameName); + result.set(member.tournamentTeamId, names); + } + + return result; +} + +/** Returns the tournament's start time as a database timestamp. */ +export async function tournamentStartTime(tournamentId: number) { + const row = await db + .selectFrom("CalendarEvent") + .innerJoin( + "CalendarEventDate", + "CalendarEventDate.eventId", + "CalendarEvent.id", + ) + .select(({ fn }) => fn.min("CalendarEventDate.startTime").as("startTime")) + .where("CalendarEvent.tournamentId", "=", tournamentId) + .executeTakeFirst(); + + return row?.startTime ?? null; +} + +/** + * Inserts ingested weapon rows, skipping rows whose in-game name already has + * a reported weapon for the same map (e.g. from an earlier ingest). + * + * @returns count of inserted rows + */ +export async function addReportedWeapons(rows: IngestedWeaponRow[]) { + if (rows.length === 0) return 0; + + const matchIds = [...new Set(rows.map((row) => row.tournamentMatchId))]; + const existing = await db + .selectFrom("ReportedWeapon") + .select([ + "tournamentMatchId", + "mapIndex", + "ingestedInGameName", + "ingestedTeamId", + ]) + .where("tournamentMatchId", "in", matchIds) + .where("ingestedInGameName", "is not", null) + .execute(); + + const rowKey = (row: { + tournamentMatchId: number | null; + mapIndex: number; + ingestedInGameName: string | null; + ingestedTeamId: number | null; + }) => + `${row.tournamentMatchId}-${row.mapIndex}-${row.ingestedTeamId}-${row.ingestedInGameName}`; + + const existingKeys = new Set(existing.map(rowKey)); + const newRows = rows.filter((row) => !existingKeys.has(rowKey(row))); + + if (newRows.length === 0) return 0; + + await db.insertInto("ReportedWeapon").values(newRows).execute(); + + return newRows.length; +} + +/** Returns a tournament match's ingested weapons (both linked to a user and not). */ +export function findIngestedWeaponsByTournamentMatchId( + tournamentMatchId: number, +) { + return db + .selectFrom("ReportedWeapon") + .select([ + "ReportedWeapon.mapIndex", + "ReportedWeapon.weaponSplId", + "ReportedWeapon.ingestedInGameName", + "ReportedWeapon.ingestedTeamId", + "ReportedWeapon.userId", + ]) + .where("ReportedWeapon.tournamentMatchId", "=", tournamentMatchId) + .where("ReportedWeapon.ingestedInGameName", "is not", null) + .orderBy("ReportedWeapon.mapIndex", "asc") + .$narrowType<{ ingestedInGameName: NotNull }>() + .execute(); +} + +// xxx: probably would struggle with people with same name? + +/** + * it's half-handled. Let me walk through the two variants, because they behave very differently. + +Same name on opposite teams: handled. Every row carries ingestedTeamId (derived from scoreboard position + who won the game), so the two "Nayo"s produce distinct (name, team) identities. The dialog groups by team, so each appears under its own team section with its own select, and linkIngestedUser scopes the update by name and team. No collision anywhere. + +Same name on the same team: stored, but linking breaks. Tracing it through: + +1. Ingest: both scoreboard rows insert fine — there's no unique constraint on (match, mapIndex, name), and the userId uniques don't fire because userId is NULL (SQLite treats NULLs as distinct). Raw data preserved, weapons possibly different per row. +2. Dialog: resolveUnlinkedNames collapses them into one entry keyed by (name, teamId), with their weapons merged. You get a single select for what is physically two players — there's no way to express "row 2's Nayo is user A, row 3's Nayo is user B". +3. Linking — the actual bug: the update sets userId on every matching row. Two rows on the same (match, mapIndex) both get the same userId → violates unique(tournamentMatchId, mapIndex, userId) → the transaction rolls back and the user gets a generic error. So for that name, linking doesn't degrade gracefully — it just fails. + +The deeper issue is that the ambiguity is unresolvable in principle: ReportedWeapon rows don't retain scoreboard row position, and even if they did, row position isn't a stable identity across games — so there's no honest way to say which weapon belonged to which of the two players, in this game or the next. + +How likely is it? True duplicates in one lobby are rare, but note the OCR angle: emberz name reading can snap two different names to the same string, which makes this more common than the "two players genuinely named the same" case suggests. + +Options as I see them: + +- A. Drop ambiguous rows at ingest time. When a scoreboard has two identical (non-empty) names on the same side, skip those rows (keep the other 6–7 players). The raw event is stored anyway, so nothing is truly lost. This makes everything downstream consistent by construction: the dialog never shows an unlinkable entry, linking can never double-assign. Simple, honest — we refuse to attribute data we can't attribute. +- B. Keep the rows but harden linking. Store them, show the merged entry, and make linkIngestedUser assign at most one row per (match, mapIndex) (e.g. lowest rowid) and leave/delete the rest. No crash, but the attribution of the kept row is a coin flip, and the merged weapon display quietly mixes two players. +- C. Full modeling — add a row-index column, per-map link rows, "Nayo (2)" UI with multiple selects. Correct-ish within a single map but still can't track identity across games, and a lot of UX for a rare case. + +My recommendation is A, plus a cheap defense-in-depth tweak to linkIngestedUser so it can never violate the unique constraint even if bad rows exist (from data ingested before the fix, or future regressions). One related small thing I'd fix in the same pass: the re-ingest skip key in addReportedWeapons is (match, mapIndex, name) without teamId, so an opposite-team duplicate that becomes readable only in a later re-ingest would get skipped — including teamId in that key closes it. + */ +/** + * Thrown by linkIngestedUser when the target user is already attributed + * another ingested name on one of the games the linked name appears in. + */ +export class IngestedLinkConflictError extends Error {} + +/** + * Connects an ingested in-game name to a sendou.ink user, filling + * ReportedWeapon.userId for every match of the tournament where the name + * played for the given team. Ingested rows that would duplicate a weapon the + * user reported themselves are dropped instead. Linking a user who is already + * attributed another ingested name on one of the games throws + * IngestedLinkConflictError and rolls the whole link back. + */ +export function linkIngestedUser({ + tournamentId, + ingestedInGameName, + ingestedTeamId, + userId, +}: { + tournamentId: number; + ingestedInGameName: string; + ingestedTeamId: number | null; + userId: number; +}) { + return db.transaction().execute(async (trx) => { + const tournamentMatchIds = trx + .selectFrom("TournamentMatch") + .innerJoin( + "TournamentStage", + "TournamentStage.id", + "TournamentMatch.stageId", + ) + .select("TournamentMatch.id") + .where("TournamentStage.tournamentId", "=", tournamentId); + + await trx + .deleteFrom("ReportedWeapon as ingested") + .where("ingested.userId", "is", null) + .where("ingested.ingestedInGameName", "=", ingestedInGameName) + .where((eb) => + ingestedTeamId === null + ? eb("ingested.ingestedTeamId", "is", null) + : eb("ingested.ingestedTeamId", "=", ingestedTeamId), + ) + .where("ingested.tournamentMatchId", "in", tournamentMatchIds) + .where(({ exists, selectFrom }) => + exists( + selectFrom("ReportedWeapon as own") + .select("own.mapIndex") + .whereRef( + "own.tournamentMatchId", + "=", + "ingested.tournamentMatchId", + ) + .whereRef("own.mapIndex", "=", "ingested.mapIndex") + .where("own.userId", "=", userId) + .where("own.ingestedInGameName", "is", null), + ), + ) + .execute(); + + // xxx: why is this needed? + // rows from before same-side duplicate names were dropped at ingest time + // can still hold two rows on one (match, mapIndex); linking both would + // violate unique(tournamentMatchId, mapIndex, userId), so keep only one + await trx + .deleteFrom("ReportedWeapon") + .where( + sql`rowid`, + "in", + trx + .selectFrom("ReportedWeapon as ingested") + .select(sql`"ingested"."rowid"`.as("rowid")) + .where("ingested.userId", "is", null) + .where("ingested.ingestedInGameName", "=", ingestedInGameName) + .where((eb) => + ingestedTeamId === null + ? eb("ingested.ingestedTeamId", "is", null) + : eb("ingested.ingestedTeamId", "=", ingestedTeamId), + ) + .where("ingested.tournamentMatchId", "in", tournamentMatchIds) + .where(({ exists, selectFrom }) => + exists( + selectFrom("ReportedWeapon as other") + .select("other.mapIndex") + .whereRef( + "other.tournamentMatchId", + "=", + "ingested.tournamentMatchId", + ) + .whereRef("other.mapIndex", "=", "ingested.mapIndex") + .whereRef( + "other.ingestedInGameName", + "=", + "ingested.ingestedInGameName", + ) + .where("other.userId", "is", null) + .where(sql`"other"."rowid" < "ingested"."rowid"`), + ), + ), + ) + .execute(); + + const conflictingRow = await trx + .selectFrom("ReportedWeapon as ingested") + .select("ingested.mapIndex") + .where("ingested.userId", "is", null) + .where("ingested.ingestedInGameName", "=", ingestedInGameName) + .where((eb) => + ingestedTeamId === null + ? eb("ingested.ingestedTeamId", "is", null) + : eb("ingested.ingestedTeamId", "=", ingestedTeamId), + ) + .where("ingested.tournamentMatchId", "in", tournamentMatchIds) + .where(({ exists, selectFrom }) => + exists( + selectFrom("ReportedWeapon as own") + .select("own.mapIndex") + .whereRef( + "own.tournamentMatchId", + "=", + "ingested.tournamentMatchId", + ) + .whereRef("own.mapIndex", "=", "ingested.mapIndex") + .where("own.userId", "=", userId), + ), + ) + .limit(1) + .executeTakeFirst(); + if (conflictingRow) { + throw new IngestedLinkConflictError(); + } + + await trx + .updateTable("ReportedWeapon") + .set({ userId }) + .where("ReportedWeapon.userId", "is", null) + .where("ReportedWeapon.ingestedInGameName", "=", ingestedInGameName) + .where((eb) => + ingestedTeamId === null + ? eb("ReportedWeapon.ingestedTeamId", "is", null) + : eb("ReportedWeapon.ingestedTeamId", "=", ingestedTeamId), + ) + .where("ReportedWeapon.tournamentMatchId", "in", tournamentMatchIds) + .execute(); + }); +} diff --git a/app/features/ingest/actions/ingest.server.ts b/app/features/ingest/actions/ingest.server.ts new file mode 100644 index 000000000..fe02bc922 --- /dev/null +++ b/app/features/ingest/actions/ingest.server.ts @@ -0,0 +1,55 @@ +import type { ActionFunction } from "react-router"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { + badRequestIfFalsy, + canAccessLohiEndpoint, + parseBody, +} from "~/utils/remix.server"; +import * as Scoreboards from "../core/Scoreboards"; +import * as IngestRepository from "../IngestRepository.server"; +import { ingestBodySchema } from "../ingest-schemas"; + +export const action: ActionFunction = async ({ request }) => { + const user = canAccessLohiEndpoint(request) ? null : requireUser(); + + const data = await parseBody({ request, schema: ingestBodySchema }); + + const povUserId = data.povUserId ?? user?.id ?? null; + const tournamentId = data.tournamentId ?? null; + + if (povUserId) { + badRequestIfFalsy(await UserRepository.findLeanById(povUserId)); + } + const tournamentStartTime = tournamentId + ? badRequestIfFalsy( + await IngestRepository.tournamentStartTime(tournamentId), + ) + : null; + + const storedEventsCount = await IngestRepository.addEvents({ + tournamentId, + povUserId, + submitterUserId: user?.id ?? null, + events: data.events, + }); + + let reportedWeaponsCount = 0; + if (tournamentId && tournamentStartTime && povUserId) { + const games = await IngestRepository.gamesPlayedByUserInTournament({ + userId: povUserId, + tournamentId, + }); + + reportedWeaponsCount = await IngestRepository.addReportedWeapons( + Scoreboards.reportedWeaponRowsFromEvents({ + events: data.events, + games, + // xxx: why createdAt here? makes no sense + createdAt: tournamentStartTime, + }), + ); + } + + return { storedEventsCount, reportedWeaponsCount }; +}; diff --git a/app/features/ingest/core/IngestedNames.test.ts b/app/features/ingest/core/IngestedNames.test.ts new file mode 100644 index 000000000..50946fc30 --- /dev/null +++ b/app/features/ingest/core/IngestedNames.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from "vitest"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import * as IngestedNames from "./IngestedNames"; + +const TEAM_A = 100; +const TEAM_B = 200; + +function row({ + name, + teamId = TEAM_A, + mapIndex = 0, + weaponSplId = 10 as MainWeaponId, +}: { + name: string; + teamId?: number | null; + mapIndex?: number; + weaponSplId?: MainWeaponId; +}) { + return { + ingestedInGameName: name, + ingestedTeamId: teamId, + mapIndex, + weaponSplId, + }; +} + +describe("unlinkedNameGroups", () => { + it("merges near-identical names that never appear in the same map", () => { + const groups = IngestedNames.unlinkedNameGroups([ + row({ name: "Jrod_14", mapIndex: 0 }), + row({ name: "Jrodl4", mapIndex: 1, weaponSplId: 20 as MainWeaponId }), + row({ name: "Jrod_14", mapIndex: 2 }), + ]); + + expect(groups).toHaveLength(1); + expect(groups[0]!.primaryName).toBe("Jrod_14"); + expect(groups[0]!.names.sort()).toEqual(["Jrod_14", "Jrodl4"]); + expect(groups[0]!.weapons).toEqual([10, 20]); + expect(groups[0]!.mapIndexes).toEqual([0, 1, 2]); + }); + + it("chains variants transitively", () => { + const groups = IngestedNames.unlinkedNameGroups([ + row({ name: "くらうlに ★^", mapIndex: 0 }), + row({ name: "<らうrに ★¬", mapIndex: 1 }), + row({ name: "<らうιに ★¬", mapIndex: 2 }), + row({ name: "くらうιに ★¬", mapIndex: 3 }), + row({ name: "くらうlに ★^", mapIndex: 4 }), + ]); + + expect(groups).toHaveLength(1); + expect(groups[0]!.primaryName).toBe("くらうlに ★^"); + expect(groups[0]!.names).toHaveLength(4); + }); + + it("does not merge similar names that appear in the same map", () => { + const groups = IngestedNames.unlinkedNameGroups([ + row({ name: "Sami", mapIndex: 0 }), + row({ name: "Samu", mapIndex: 0 }), + ]); + + expect(groups).toHaveLength(2); + }); + + it("leaves the whole cluster unmerged when a transitive merge is contradicted by a shared map", () => { + const groups = IngestedNames.unlinkedNameGroups([ + row({ name: "player1", mapIndex: 0 }), + row({ name: "playerl", mapIndex: 0 }), + row({ name: "playerI", mapIndex: 1 }), + ]); + + expect(groups).toHaveLength(3); + }); + + it("does not merge dissimilar names", () => { + const groups = IngestedNames.unlinkedNameGroups([ + row({ name: "Bocchi", mapIndex: 0 }), + row({ name: "have faith", mapIndex: 1 }), + ]); + + expect(groups).toHaveLength(2); + }); + + it("is stricter with short names", () => { + const groups = IngestedNames.unlinkedNameGroups([ + row({ name: "Eli", mapIndex: 0 }), + row({ name: "Ala", mapIndex: 1 }), + ]); + + expect(groups).toHaveLength(2); + }); + + it("does not merge across teams", () => { + const groups = IngestedNames.unlinkedNameGroups([ + row({ name: "Jrod_14", teamId: TEAM_A, mapIndex: 0 }), + row({ name: "Jrodl4", teamId: TEAM_B, mapIndex: 1 }), + ]); + + expect(groups).toHaveLength(2); + }); +}); + +describe("preselectedUserIdByGroup", () => { + function player({ + id, + teamId = TEAM_A, + inGameName = null, + }: { + id: number; + teamId?: number; + inGameName?: string | null; + }) { + return { id, tournamentTeamId: teamId, inGameName }; + } + + function groupsOf(rows: Parameters[0][]) { + return IngestedNames.unlinkedNameGroups(rows.map(row)); + } + + it("preselects on an exact in-game name match", () => { + const groups = groupsOf([{ name: "Nayo" }]); + const result = IngestedNames.preselectedUserIdByGroup({ + groups, + players: [player({ id: 1, inGameName: "nayo#1234" })], + }); + + expect(result[IngestedNames.groupKey(groups[0]!)]).toBe(1); + }); + + it("preselects on an unambiguous fuzzy match", () => { + const groups = groupsOf([{ name: "Jrodl4" }]); + const result = IngestedNames.preselectedUserIdByGroup({ + groups, + players: [ + player({ id: 1, inGameName: "Jrod_14#3336" }), + player({ id: 2, inGameName: "Tenshi#1233" }), + ], + }); + + expect(result[IngestedNames.groupKey(groups[0]!)]).toBe(1); + }); + + it("does not preselect when a group fuzzy-matches several players", () => { + const groups = groupsOf([{ name: "player1" }]); + const result = IngestedNames.preselectedUserIdByGroup({ + groups, + players: [ + player({ id: 1, inGameName: "playerI" }), + player({ id: 2, inGameName: "player7" }), + ], + }); + + expect(result).toEqual({}); + }); + + it("does not preselect when several groups fuzzy-match the same player", () => { + const groups = groupsOf([ + { name: "Samii", mapIndex: 0 }, + { name: "Samio", mapIndex: 0 }, + ]); + const result = IngestedNames.preselectedUserIdByGroup({ + groups, + players: [player({ id: 1, inGameName: "Samir" })], + }); + + expect(result).toEqual({}); + }); + + it("keeps an exact match even when another group fuzzy-matches the same player", () => { + const groups = groupsOf([ + { name: "Samir", mapIndex: 0 }, + { name: "Samio", mapIndex: 0 }, + ]); + const result = IngestedNames.preselectedUserIdByGroup({ + groups, + players: [player({ id: 1, inGameName: "Samir" })], + }); + + const exactGroup = groups.find((g) => g.primaryName === "Samir")!; + const fuzzyGroup = groups.find((g) => g.primaryName === "Samio")!; + expect(result[IngestedNames.groupKey(exactGroup)]).toBe(1); + expect(result[IngestedNames.groupKey(fuzzyGroup)]).toBeUndefined(); + }); + + it("only considers players of the group's team", () => { + const groups = groupsOf([{ name: "Nayo", teamId: TEAM_A }]); + const result = IngestedNames.preselectedUserIdByGroup({ + groups, + players: [player({ id: 1, teamId: TEAM_B, inGameName: "Nayo#1234" })], + }); + + expect(result).toEqual({}); + }); + + it("considers all players when the group's team is unknown", () => { + const groups = groupsOf([{ name: "Nayo", teamId: null }]); + const result = IngestedNames.preselectedUserIdByGroup({ + groups, + players: [player({ id: 1, teamId: TEAM_B, inGameName: "Nayo#1234" })], + }); + + expect(result[IngestedNames.groupKey(groups[0]!)]).toBe(1); + }); +}); diff --git a/app/features/ingest/core/IngestedNames.ts b/app/features/ingest/core/IngestedNames.ts new file mode 100644 index 000000000..76d71dc1f --- /dev/null +++ b/app/features/ingest/core/IngestedNames.ts @@ -0,0 +1,294 @@ +import type { MainWeaponId } from "~/modules/in-game-lists/types"; + +/** + * Max edit distance between two normalized names for them to be considered + * OCR variants of the same name. Short names get a tighter budget so e.g. + * two different 4-letter names don't collapse into one. + */ +const FUZZY_DISTANCE_LONG = 2; +const FUZZY_DISTANCE_SHORT = 1; +const FUZZY_SHORT_NAME_LENGTH = 6; + +interface UnlinkedIngestedRow { + ingestedInGameName: string; + ingestedTeamId: number | null; + mapIndex: number; + weaponSplId: MainWeaponId; +} + +export interface IngestedNameGroup { + /** the variant detected in the most maps, shown in the UI */ + primaryName: string; + /** every detected spelling belonging to this group, `primaryName` included */ + names: string[]; + ingestedTeamId: number | null; + weapons: MainWeaponId[]; + /** 0-based indexes of the match's games this name has weapon rows for */ + mapIndexes: number[]; +} + +interface LinkablePlayer { + id: number; + tournamentTeamId: number; + inGameName: string | null; +} + +/** + * Groups unlinked ingested weapon rows into one entry per (likely) player. + * Two detected names on the same team are merged when they are near-identical + * strings AND never appear in the same map — different maps reading the same + * splash tag slightly differently. If a merge candidate group turns out + * ambiguous (two of its names appear in the same map, meaning they must be + * different players) the whole group is left unmerged for the user to decide. + */ +export function unlinkedNameGroups( + rows: UnlinkedIngestedRow[], +): IngestedNameGroup[] { + const aggregates = aggregateByName(rows); + + const byTeam = new Map(); + for (const aggregate of aggregates) { + const list = byTeam.get(aggregate.teamId) ?? []; + list.push(aggregate); + byTeam.set(aggregate.teamId, list); + } + + const result: Array = []; + for (const teamAggregates of byTeam.values()) { + result.push(...clusterTeamAggregates(teamAggregates)); + } + + return result + .sort((a, b) => a.firstSeen - b.firstSeen) + .map(({ firstSeen: _, ...group }) => group); +} + +/** + * Resolves which sendou.ink user each group should come pre-selected as. + * A group is matched to a player of its team by exact normalized in-game name + * first, falling back to a fuzzy match with the same tolerance used for + * variant merging. Anything ambiguous (a group matching several players, or + * several groups fuzzy-matching the same player) is left unselected for the + * user to decide. + */ +export function preselectedUserIdByGroup({ + groups, + players, +}: { + groups: IngestedNameGroup[]; + players: LinkablePlayer[]; +}): Record { + const claims: Array<{ key: string; playerId: number; exact: boolean }> = []; + + for (const group of groups) { + const candidates = players.filter( + (player) => + (group.ingestedTeamId === null || + player.tournamentTeamId === group.ingestedTeamId) && + player.inGameName, + ); + + const exactMatches = candidates.filter((player) => + group.names.some( + (name) => + normalizeInGameName(name) === normalizeInGameName(player.inGameName!), + ), + ); + if (exactMatches.length === 1) { + claims.push({ + key: groupKey(group), + playerId: exactMatches[0]!.id, + exact: true, + }); + continue; + } + if (exactMatches.length > 1) continue; + + const fuzzyMatches = candidates.filter((player) => + group.names.some((name) => namesSimilar(name, player.inGameName!)), + ); + if (fuzzyMatches.length === 1) { + claims.push({ + key: groupKey(group), + playerId: fuzzyMatches[0]!.id, + exact: false, + }); + } + } + + const result: Record = {}; + for (const claim of claims) { + const competing = claims.filter((c) => c.playerId === claim.playerId); + if (competing.length === 1) { + result[claim.key] = claim.playerId; + continue; + } + if (claim.exact && competing.filter((c) => c.exact).length === 1) { + result[claim.key] = claim.playerId; + } + } + + return result; +} + +/** Stable identifier of a group within one match's linking dialog. */ +export function groupKey( + group: Pick, +) { + return `${group.ingestedTeamId ?? "unknown"}|${group.primaryName}`; +} + +interface NameAggregate { + name: string; + teamId: number | null; + mapIndexes: Set; + weaponsByMap: Array<{ mapIndex: number; weaponSplId: MainWeaponId }>; + firstSeen: number; +} + +function aggregateByName(rows: UnlinkedIngestedRow[]): NameAggregate[] { + const result: NameAggregate[] = []; + + for (const [rowIdx, row] of rows.entries()) { + let aggregate = result.find( + (a) => + a.name === row.ingestedInGameName && a.teamId === row.ingestedTeamId, + ); + if (!aggregate) { + aggregate = { + name: row.ingestedInGameName, + teamId: row.ingestedTeamId, + mapIndexes: new Set(), + weaponsByMap: [], + firstSeen: rowIdx, + }; + result.push(aggregate); + } + aggregate.mapIndexes.add(row.mapIndex); + aggregate.weaponsByMap.push({ + mapIndex: row.mapIndex, + weaponSplId: row.weaponSplId, + }); + } + + return result; +} + +function clusterTeamAggregates( + aggregates: NameAggregate[], +): Array { + const clusterIdxs = aggregates.map((_, i) => i); + const rootOf = (i: number): number => + clusterIdxs[i] === i ? i : rootOf(clusterIdxs[i]!); + + for (let a = 0; a < aggregates.length; a++) { + for (let b = a + 1; b < aggregates.length; b++) { + if (!namesSimilar(aggregates[a]!.name, aggregates[b]!.name)) continue; + if (mapsOverlap(aggregates[a]!, aggregates[b]!)) continue; + clusterIdxs[rootOf(b)] = rootOf(a); + } + } + + const clusters = new Map(); + for (const [i, aggregate] of aggregates.entries()) { + const root = rootOf(i); + const members = clusters.get(root) ?? []; + members.push(aggregate); + clusters.set(root, members); + } + + const result: Array = []; + for (const members of clusters.values()) { + if (members.length > 1 && anyMapsOverlap(members)) { + // two names of this cluster appeared in the same map, so at least + // some of them are different players after all -> user decides + result.push(...members.map((member) => toGroup([member]))); + } else { + result.push(toGroup(members)); + } + } + + return result; +} + +function toGroup( + members: NameAggregate[], +): IngestedNameGroup & { firstSeen: number } { + const primary = [...members].sort( + (a, b) => + b.mapIndexes.size - a.mapIndexes.size || a.firstSeen - b.firstSeen, + )[0]!; + + const weapons: MainWeaponId[] = []; + const allWeapons = members + .flatMap((member) => member.weaponsByMap) + .sort((a, b) => a.mapIndex - b.mapIndex); + for (const { weaponSplId } of allWeapons) { + if (!weapons.includes(weaponSplId)) weapons.push(weaponSplId); + } + + return { + primaryName: primary.name, + names: members.map((member) => member.name), + ingestedTeamId: primary.teamId, + weapons, + mapIndexes: [ + ...new Set(members.flatMap((member) => [...member.mapIndexes])), + ].sort((a, b) => a - b), + firstSeen: Math.min(...members.map((member) => member.firstSeen)), + }; +} + +function mapsOverlap(a: NameAggregate, b: NameAggregate) { + return [...a.mapIndexes].some((mapIndex) => b.mapIndexes.has(mapIndex)); +} + +function anyMapsOverlap(members: NameAggregate[]) { + const seen = new Set(); + for (const member of members) { + for (const mapIndex of member.mapIndexes) { + if (seen.has(mapIndex)) return true; + seen.add(mapIndex); + } + } + return false; +} + +function namesSimilar(a: string, b: string) { + const normalizedA = normalizeInGameName(a); + const normalizedB = normalizeInGameName(b); + if (normalizedA === normalizedB) return true; + + const allowed = + Math.max(normalizedA.length, normalizedB.length) >= FUZZY_SHORT_NAME_LENGTH + ? FUZZY_DISTANCE_LONG + : FUZZY_DISTANCE_SHORT; + + return editDistance(normalizedA, normalizedB) <= allowed; +} + +function normalizeInGameName(name: string) { + return name.split("#")[0]!.normalize("NFKC").trim().toLowerCase(); +} + +function editDistance(a: string, b: string) { + const charsA = Array.from(a); + const charsB = Array.from(b); + if (charsA.length === 0) return charsB.length; + if (charsB.length === 0) return charsA.length; + + let previousRow = Array.from({ length: charsB.length + 1 }, (_, i) => i); + for (let i = 1; i <= charsA.length; i++) { + const currentRow = [i]; + for (let j = 1; j <= charsB.length; j++) { + currentRow[j] = Math.min( + previousRow[j]! + 1, + currentRow[j - 1]! + 1, + previousRow[j - 1]! + (charsA[i - 1] === charsB[j - 1] ? 0 : 1), + ); + } + previousRow = currentRow; + } + + return previousRow[charsB.length]!; +} diff --git a/app/features/ingest/core/Scoreboards.test.ts b/app/features/ingest/core/Scoreboards.test.ts new file mode 100644 index 000000000..e86b2348c --- /dev/null +++ b/app/features/ingest/core/Scoreboards.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, it } from "vitest"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; +import type { IngestedEventInput } from "../ingest-schemas"; +import * as Scoreboards from "./Scoreboards"; + +const WINNER_TEAM_ID = 100; +const LOSER_TEAM_ID = 200; + +function testGame( + partial: Partial = {}, +): Scoreboards.IngestableGame { + return { + tournamentMatchId: 1, + mapIndex: 0, + mode: "SZ", + stageId: 0 as StageId, + winnerTeamId: WINNER_TEAM_ID, + loserTeamId: LOSER_TEAM_ID, + winnerInGameNames: [], + loserInGameNames: [], + playedAt: 1000, + ...partial, + }; +} + +function testScoreboard({ + t = 60, + mode = "Splat Zones", + stage = "Scorch Gorge", + lobby = "Private Battle", + names = ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"], + weapons = ["10", "10", "10", "10", "20", "20", "20", "20"], +}: { + t?: number; + mode?: string | null; + stage?: string | null; + lobby?: string | null; + names?: string[]; + weapons?: string[]; +} = {}): IngestedEventInput { + return { + type: "Scoreboard", + t, + confidence: 0.9, + data: { + lobby, + mode, + stage, + scores: [100, 52], + players: names.map((name, i) => ({ + name, + weapon: weapons[i]!, + paint: 1000, + ka: 10, + d: 5, + s: 2, + })), + }, + }; +} + +describe("reportedWeaponRowsFromEvents", () => { + it("fills weapons for all 8 players of a matching game", () => { + const rows = Scoreboards.reportedWeaponRowsFromEvents({ + events: [testScoreboard()], + games: [testGame()], + createdAt: 123, + }); + + expect(rows).toHaveLength(8); + expect(rows[0]).toEqual({ + tournamentMatchId: 1, + mapIndex: 0, + weaponSplId: 10, + ingestedInGameName: "w1", + ingestedTeamId: WINNER_TEAM_ID, + createdAt: 123, + }); + }); + + it("assigns the winning side to the game's winner team and the losing side to the loser team", () => { + const rows = Scoreboards.reportedWeaponRowsFromEvents({ + events: [testScoreboard()], + games: [testGame()], + createdAt: 123, + }); + + expect( + rows + .filter((row) => row.ingestedTeamId === WINNER_TEAM_ID) + .map((row) => row.ingestedInGameName), + ).toEqual(["w1", "w2", "w3", "w4"]); + expect( + rows + .filter((row) => row.ingestedTeamId === LOSER_TEAM_ID) + .map((row) => row.ingestedInGameName), + ).toEqual(["l1", "l2", "l3", "l4"]); + }); + + it("matches scoreboards to games by mode and stage", () => { + const rows = Scoreboards.reportedWeaponRowsFromEvents({ + events: [ + testScoreboard({ mode: "Rainmaker", stage: "Eeltail Alley", t: 60 }), + ], + games: [ + testGame({ mapIndex: 0, mode: "SZ", stageId: 0 as StageId }), + testGame({ mapIndex: 1, mode: "RM", stageId: 1 as StageId }), + ], + createdAt: 123, + }); + + expect(new Set(rows.map((row) => row.mapIndex))).toEqual(new Set([1])); + }); + + it("assigns two games on the same mode and stage in chronological order", () => { + const rows = Scoreboards.reportedWeaponRowsFromEvents({ + events: [ + testScoreboard({ + t: 60, + names: ["a", "b", "c", "d", "e", "f", "g", "h"], + }), + testScoreboard({ + t: 5000, + names: ["i", "j", "k", "l", "m", "n", "o", "p"], + }), + ], + games: [ + testGame({ tournamentMatchId: 1, playedAt: 1000 }), + testGame({ tournamentMatchId: 2, playedAt: 2000 }), + ], + createdAt: 123, + }); + + expect( + rows.find((row) => row.ingestedInGameName === "a")?.tournamentMatchId, + ).toBe(1); + expect( + rows.find((row) => row.ingestedInGameName === "i")?.tournamentMatchId, + ).toBe(2); + }); + + it("skips duplicate detections of the same scoreboard", () => { + const rows = Scoreboards.reportedWeaponRowsFromEvents({ + events: [testScoreboard({ t: 60 }), testScoreboard({ t: 65 })], + games: [ + testGame({ tournamentMatchId: 1, playedAt: 1000 }), + testGame({ tournamentMatchId: 2, playedAt: 2000 }), + ], + createdAt: 123, + }); + + expect(rows).toHaveLength(8); + expect(rows[0]!.tournamentMatchId).toBe(1); + }); + + it("skips scoreboards from other lobbies", () => { + const rows = Scoreboards.reportedWeaponRowsFromEvents({ + events: [testScoreboard({ lobby: "X Battle" })], + games: [testGame()], + createdAt: 123, + }); + + expect(rows).toHaveLength(0); + }); + + it("skips scoreboards with unreadable mode or stage", () => { + const rows = Scoreboards.reportedWeaponRowsFromEvents({ + events: [ + testScoreboard({ mode: null }), + testScoreboard({ stage: "Not A Stage" }), + ], + games: [testGame()], + createdAt: 123, + }); + + expect(rows).toHaveLength(0); + }); + + it("skips players with unknown weapon or empty name", () => { + const rows = Scoreboards.reportedWeaponRowsFromEvents({ + events: [ + testScoreboard({ + names: ["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"], + weapons: ["10", "10", "unknown", "10", "20", "20", "20", "20"], + }), + ], + games: [testGame()], + createdAt: 123, + }); + + expect(rows).toHaveLength(6); + expect(rows.some((row) => row.ingestedInGameName === "w3")).toBe(false); + }); + + it("skips non-scoreboard events", () => { + const rows = Scoreboards.reportedWeaponRowsFromEvents({ + events: [ + { + type: "MapStart", + t: 10, + confidence: 0.9, + data: { mode: "Splat Zones", stage: "Scorch Gorge" }, + }, + ], + games: [testGame()], + createdAt: 123, + }); + + expect(rows).toHaveLength(0); + }); + + it("skips scoreboards that have no matching game left", () => { + const rows = Scoreboards.reportedWeaponRowsFromEvents({ + events: [ + testScoreboard({ t: 60 }), + testScoreboard({ + t: 5000, + names: ["i", "j", "k", "l", "m", "n", "o", "p"], + }), + ], + games: [testGame()], + createdAt: 123, + }); + + expect(rows).toHaveLength(8); + }); + + it("uses ScoreboardReplay events too", () => { + const scoreboard = testScoreboard(); + const rows = Scoreboards.reportedWeaponRowsFromEvents({ + events: [ + { + ...scoreboard, + type: "ScoreboardReplay", + data: { + ...(scoreboard.data as Extract< + IngestedEventInput, + { type: "Scoreboard" } + >["data"]), + timestamp: "3/7/2026 22:28", + replayCode: "ABCD-EFGH-IJKL-MNOP", + matchScores: [100, 52], + }, + }, + ], + games: [testGame()], + createdAt: 123, + }); + + expect(rows).toHaveLength(8); + }); + + it("skips a game whose known rosters contradict the scoreboard sides", () => { + const rows = Scoreboards.reportedWeaponRowsFromEvents({ + events: [testScoreboard()], + games: [ + testGame({ + tournamentMatchId: 1, + // scoreboard winners are w1-w4 but this game was won by the l* players + winnerInGameNames: ["l1#1234", "l2"], + loserInGameNames: ["w1", "w2"], + playedAt: 1000, + }), + testGame({ + tournamentMatchId: 2, + winnerInGameNames: ["w1", "w2"], + loserInGameNames: ["l1#1234", "l2"], + playedAt: 2000, + }), + ], + createdAt: 123, + }); + + expect(rows.map((row) => row.tournamentMatchId)).not.toContain(1); + expect(rows.filter((row) => row.tournamentMatchId === 2)).toHaveLength(8); + }); + + it("matches known in-game names ignoring discriminator, case and unicode width", () => { + const rows = Scoreboards.reportedWeaponRowsFromEvents({ + events: [ + testScoreboard({ + names: ["W1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"], + }), + ], + games: [ + testGame({ + winnerInGameNames: ["w1#1234"], + loserInGameNames: ["W3#5678"], + }), + ], + createdAt: 123, + }); + + // "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(rows).toHaveLength(8); + }); + + it("skips players whose name appears twice on the same side", () => { + const rows = Scoreboards.reportedWeaponRowsFromEvents({ + events: [ + testScoreboard({ + names: ["dupe", "dupe", "w3", "w4", "l1", "l2", "l3", "dupe"], + }), + ], + games: [testGame()], + createdAt: 123, + }); + + expect( + rows.filter((row) => row.ingestedInGameName === "dupe"), + ).toHaveLength(1); + expect( + rows.find((row) => row.ingestedInGameName === "dupe")?.ingestedTeamId, + ).toBe(LOSER_TEAM_ID); + expect(rows).toHaveLength(6); + }); + + it("does not assign a game played before the previously assigned one", () => { + const rows = Scoreboards.reportedWeaponRowsFromEvents({ + events: [ + testScoreboard({ t: 60, mode: "Rainmaker", stage: "Eeltail Alley" }), + testScoreboard({ t: 1000, mode: "Splat Zones", stage: "Scorch Gorge" }), + ], + games: [ + testGame({ + tournamentMatchId: 1, + mode: "SZ", + stageId: 0 as StageId, + playedAt: 1000, + }), + testGame({ + tournamentMatchId: 2, + mode: "RM" as ModeShort, + stageId: 1 as StageId, + playedAt: 2000, + }), + ], + createdAt: 123, + }); + + expect(rows.map((row) => row.tournamentMatchId)).not.toContain(1); + expect(rows.filter((row) => row.tournamentMatchId === 2)).toHaveLength(8); + }); +}); diff --git a/app/features/ingest/core/Scoreboards.ts b/app/features/ingest/core/Scoreboards.ts new file mode 100644 index 000000000..d732ca322 --- /dev/null +++ b/app/features/ingest/core/Scoreboards.ts @@ -0,0 +1,235 @@ +import type { Tables } from "~/db/tables"; +import { modesShort } from "~/modules/in-game-lists/modes"; +import { stageIds } from "~/modules/in-game-lists/stage-ids"; +import type { + MainWeaponId, + ModeShort, + StageId, +} from "~/modules/in-game-lists/types"; +import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; +import gameMisc from "../../../../locales/en/game-misc.json"; +import type { + IngestedEventInput, + ScoreboardEventInput, +} from "../ingest-schemas"; + +const STAGE_ID_BY_ENGLISH_NAME = new Map( + stageIds.map((stageId) => [ + (gameMisc as Record)[`STAGE_${stageId}`]!, + stageId, + ]), +); + +const MODE_SHORT_BY_ENGLISH_NAME = new Map( + modesShort.map((modeShort) => [ + (gameMisc as Record)[`MODE_LONG_${modeShort}`]!, + modeShort, + ]), +); + +const MAIN_WEAPON_IDS: ReadonlySet = new Set(mainWeaponIds); + +/** Lobby header value scoreboards of tournament games are expected to have. */ +const TOURNAMENT_LOBBY = "Private Battle"; + +/** + * Two scoreboards this close in the source video with identical contents are + * considered duplicate detections of the same game. + */ +const DUPLICATE_SCOREBOARD_WINDOW_SECONDS = 300; + +/** How many players on the winning (first) resp. losing side of a scoreboard. */ +const PLAYERS_PER_TEAM = 4; + +/** A game of a tournament match that ingested scoreboards can be matched against. */ +export interface IngestableGame { + tournamentMatchId: number; + /** 0-based index of the game within its match */ + mapIndex: number; + mode: ModeShort; + stageId: StageId; + winnerTeamId: number; + loserTeamId: number | null; + /** 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 */ + loserInGameNames: string[]; + /** database timestamp used to order games chronologically across matches */ + playedAt: number; +} + +export type IngestedWeaponRow = Pick< + Tables["ReportedWeapon"], + "tournamentMatchId" | "mapIndex" | "weaponSplId" | "createdAt" +> & { + ingestedInGameName: string; + ingestedTeamId: number | null; +}; + +/** + * Matches scoreboard events against the games the POV user played and turns + * them into insertable ReportedWeapon rows (identified by in-game name, not + * user id). + * + * Events and games are both walked in chronological order: each scoreboard 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 + * scoreboard rows should overlap the game winner's roster, not the loser's). + * Scoreboards from other lobbies, with unreadable mode/stage or duplicated + * detections of the same game are skipped. + */ +export function reportedWeaponRowsFromEvents({ + events, + games, + createdAt, +}: { + events: IngestedEventInput[]; + games: IngestableGame[]; + createdAt: number; +}): IngestedWeaponRow[] { + const scoreboards = dedupeScoreboards( + events + .filter(isScoreboardEvent) + .filter( + (event) => !event.data.lobby || event.data.lobby === TOURNAMENT_LOBBY, + ) + .sort((a, b) => a.t - b.t), + ); + const orderedGames = games.toSorted( + (a, b) => a.playedAt - b.playedAt || a.mapIndex - b.mapIndex, + ); + + const rows: IngestedWeaponRow[] = []; + + let nextGameIdx = 0; + for (const scoreboard of scoreboards) { + const mode = scoreboard.data.mode + ? MODE_SHORT_BY_ENGLISH_NAME.get(scoreboard.data.mode) + : undefined; + const stageId = scoreboard.data.stage + ? STAGE_ID_BY_ENGLISH_NAME.get(scoreboard.data.stage) + : undefined; + if (mode === undefined || stageId === undefined) continue; + + for (let i = nextGameIdx; i < orderedGames.length; i++) { + const game = orderedGames[i]!; + if (game.mode !== mode || game.stageId !== stageId) continue; + if (!sidesMatchKnownPlayers(scoreboard, game)) continue; + + rows.push(...scoreboardToWeaponRows({ scoreboard, game, createdAt })); + nextGameIdx = i + 1; + break; + } + } + + return rows; +} + +function isScoreboardEvent( + event: IngestedEventInput, +): event is ScoreboardEventInput { + return event.type === "Scoreboard" || event.type === "ScoreboardReplay"; +} + +function dedupeScoreboards(sorted: ScoreboardEventInput[]) { + const result: ScoreboardEventInput[] = []; + + for (const scoreboard of sorted) { + const isDuplicate = result.some( + (other) => + Math.abs(other.t - scoreboard.t) <= + DUPLICATE_SCOREBOARD_WINDOW_SECONDS && + other.data.mode === scoreboard.data.mode && + other.data.stage === scoreboard.data.stage && + other.data.players.every( + (player, i) => player.name === scoreboard.data.players[i]!.name, + ), + ); + if (!isDuplicate) result.push(scoreboard); + } + + return result; +} + +/** + * Checks that the scoreboard's sides don't contradict the teams' known + * rosters: the winning rows should overlap the game winner's in-game names at + * least as well as the losing team's (and vice versa). A contradiction means + * the scoreboard belongs to some other game. No overlap at all (e.g. no + * in-game names set) counts as a pass. + */ +function sidesMatchKnownPlayers( + scoreboard: ScoreboardEventInput, + game: IngestableGame, +) { + const winnerSide = scoreboard.data.players + .slice(0, PLAYERS_PER_TEAM) + .map((player) => normalizeInGameName(player.name)); + const loserSide = scoreboard.data.players + .slice(PLAYERS_PER_TEAM) + .map((player) => normalizeInGameName(player.name)); + + const knownWinners = game.winnerInGameNames.map(normalizeInGameName); + const knownLosers = game.loserInGameNames.map(normalizeInGameName); + + const straight = + nameOverlap(winnerSide, knownWinners) + nameOverlap(loserSide, knownLosers); + const flipped = + nameOverlap(winnerSide, knownLosers) + nameOverlap(loserSide, knownWinners); + + return straight >= flipped; +} + +function nameOverlap(names: string[], knownNames: string[]) { + const known = new Set(knownNames.filter(Boolean)); + return names.filter((name) => name && known.has(name)).length; +} + +function normalizeInGameName(name: string) { + return name.split("#")[0]!.normalize("NFKC").trim().toLowerCase(); +} + +function scoreboardToWeaponRows({ + scoreboard, + game, + createdAt, +}: { + scoreboard: ScoreboardEventInput; + game: IngestableGame; + createdAt: number; +}): IngestedWeaponRow[] { + const rows: IngestedWeaponRow[] = []; + + const sideNameCounts = new Map(); + for (const [playerIdx, player] of scoreboard.data.players.entries()) { + const side = playerIdx < PLAYERS_PER_TEAM ? "W" : "L"; + const key = `${side}|${normalizeInGameName(player.name)}`; + sideNameCounts.set(key, (sideNameCounts.get(key) ?? 0) + 1); + } + + for (const [playerIdx, player] of scoreboard.data.players.entries()) { + const ingestedInGameName = player.name.trim(); + if (!ingestedInGameName) continue; + + // two identical names on the same side can't be told apart, so + // attributing weapons to either player would be a coin flip + const side = playerIdx < PLAYERS_PER_TEAM ? "W" : "L"; + if (sideNameCounts.get(`${side}|${normalizeInGameName(player.name)}`)! > 1) + continue; + + const weaponSplId = Number(player.weapon); + if (!MAIN_WEAPON_IDS.has(weaponSplId)) continue; + + rows.push({ + tournamentMatchId: game.tournamentMatchId, + mapIndex: game.mapIndex, + weaponSplId: weaponSplId as MainWeaponId, + ingestedInGameName, + ingestedTeamId: + playerIdx < PLAYERS_PER_TEAM ? game.winnerTeamId : game.loserTeamId, + createdAt, + }); + } + + return rows; +} diff --git a/app/features/ingest/ingest-cors-middleware.server.ts b/app/features/ingest/ingest-cors-middleware.server.ts new file mode 100644 index 000000000..1b10ca65f --- /dev/null +++ b/app/features/ingest/ingest-cors-middleware.server.ts @@ -0,0 +1,55 @@ +type MiddlewareArgs = { + request: Request; + context: unknown; +}; + +type MiddlewareFn = ( + args: MiddlewareArgs, + next: () => Promise, +) => Promise; + +const ALLOWED_ORIGIN_PATTERNS = [ + /^https:\/\/emberz\.sendou\.ink$/, + /^http:\/\/localhost:\d+$/, +]; + +export const ingestCorsMiddleware: MiddlewareFn = async ({ request }, next) => { + const headers = corsHeaders(request.headers.get("Origin")); + + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: headers ?? undefined }); + } + + const response = await next(); + if (!headers) return response; + + const newHeaders = new Headers(response.headers); + for (const [key, value] of Object.entries(headers)) { + newHeaders.set(key, value); + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }); +}; + +function corsHeaders(origin: string | null) { + if ( + !origin || + !ALLOWED_ORIGIN_PATTERNS.some((pattern) => pattern.test(origin)) + ) { + return null; + } + + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Methods": "POST, OPTIONS", + // xxx: we will also need to allow auth header eventually? + "Access-Control-Allow-Headers": "Content-Type, Lohi-Token", + "Access-Control-Max-Age": "86400", + Vary: "Origin", + }; +} diff --git a/app/features/ingest/ingest-schemas.ts b/app/features/ingest/ingest-schemas.ts new file mode 100644 index 000000000..00d84f376 --- /dev/null +++ b/app/features/ingest/ingest-schemas.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; +import { id } from "~/utils/zod"; + +// xxx: we can make these more strict e.g. validate is a proper mode etc. + +const INGEST_MAX_EVENTS_PER_REQUEST = 1000; + +const detectionText = z.string().max(500); + +const scoreboardPlayerSchema = z.object({ + name: detectionText, + weapon: detectionText, + paint: z.number().nullable(), + ka: z.number().nullable(), + d: z.number().nullable(), + s: z.number().nullable(), +}); + +const scoreboardDataSchema = z.object({ + lobby: detectionText.nullable(), + mode: detectionText.nullable(), + stage: detectionText.nullable(), + scores: z.tuple([z.number().nullable(), z.number().nullable()]), + players: z.array(scoreboardPlayerSchema).length(8), +}); + +const scoreboardReplayDataSchema = scoreboardDataSchema.extend({ + timestamp: detectionText.nullable(), + replayCode: detectionText.nullable(), + matchScores: z.tuple([z.number().nullable(), z.number().nullable()]), +}); + +const deathDataSchema = z.object({ + weapon: detectionText.nullable(), + weaponId: detectionText.nullable(), + weaponType: z.enum(["MAIN", "SUB", "SPECIAL"]).nullable(), + abilities: z.array(z.array(detectionText)), + name: detectionText.nullable(), +}); + +const mapStartDataSchema = z.object({ + mode: detectionText.nullable(), + stage: detectionText.nullable(), +}); + +const eventBaseSchema = z.object({ + /** seconds into the stream/video the event was detected at */ + t: z.number().min(0), + /** wall-clock timestamp (ms) of the detection */ + detectedAt: z.number().int().positive().optional(), + confidence: z.number().min(0).max(1), +}); + +const ingestedEventSchema = z.discriminatedUnion("type", [ + eventBaseSchema.extend({ + type: z.literal("Scoreboard"), + data: scoreboardDataSchema, + }), + eventBaseSchema.extend({ + type: z.literal("ScoreboardReplay"), + data: scoreboardReplayDataSchema, + }), + eventBaseSchema.extend({ + type: z.literal("Death"), + data: deathDataSchema, + }), + eventBaseSchema.extend({ + type: z.literal("MapStart"), + data: mapStartDataSchema, + }), +]); + +export const ingestBodySchema = z.object({ + /** the user whose point of view the events were detected from */ + povUserId: id.optional(), + tournamentId: id.optional(), + events: z + .array(ingestedEventSchema) + .min(1) + .max(INGEST_MAX_EVENTS_PER_REQUEST), +}); + +export type IngestedEventInput = z.infer; +export type IngestedEventData = IngestedEventInput["data"]; +export type ScoreboardEventInput = Extract< + IngestedEventInput, + { type: "Scoreboard" | "ScoreboardReplay" } +>; diff --git a/app/features/ingest/routes/ingest.ts b/app/features/ingest/routes/ingest.ts new file mode 100644 index 000000000..cd9a21767 --- /dev/null +++ b/app/features/ingest/routes/ingest.ts @@ -0,0 +1,6 @@ +import { ingestCorsMiddleware } from "../ingest-cors-middleware.server"; +import type { Route } from "./+types/ingest"; + +export { action } from "../actions/ingest.server"; + +export const middleware: Route.MiddlewareFunction[] = [ingestCorsMiddleware]; diff --git a/app/features/sendouq-match/ReportedWeaponRepository.server.ts b/app/features/sendouq-match/ReportedWeaponRepository.server.ts index 343611855..2bf2924cd 100644 --- a/app/features/sendouq-match/ReportedWeaponRepository.server.ts +++ b/app/features/sendouq-match/ReportedWeaponRepository.server.ts @@ -101,9 +101,10 @@ export async function findByMatchId(matchId: number) { "ReportedWeapon.userId", ]) .where("ReportedWeapon.groupMatchId", "=", matchId) + .where("ReportedWeapon.userId", "is not", null) .orderBy("ReportedWeapon.mapIndex", "asc") .orderBy("ReportedWeapon.userId", "asc") - .$narrowType<{ groupMatchId: NotNull }>() + .$narrowType<{ groupMatchId: NotNull; userId: NotNull }>() .execute(); if (rows.length === 0) return null; @@ -183,9 +184,14 @@ export async function findByTournamentMatchId(matchId: number) { "ReportedWeapon.userId", ]) .where("ReportedWeapon.tournamentMatchId", "=", matchId) + .where("ReportedWeapon.userId", "is not", null) .orderBy("ReportedWeapon.mapIndex", "asc") .orderBy("ReportedWeapon.userId", "asc") - .$narrowType<{ tournamentMatchId: NotNull; mapIndex: NotNull }>() + .$narrowType<{ + tournamentMatchId: NotNull; + mapIndex: NotNull; + userId: NotNull; + }>() .execute(); if (rows.length === 0) return null; diff --git a/app/features/tournament-bracket/core/Bracket/Bracket.ts b/app/features/tournament-bracket/core/Bracket/Bracket.ts index 298e57988..971783fdd 100644 --- a/app/features/tournament-bracket/core/Bracket/Bracket.ts +++ b/app/features/tournament-bracket/core/Bracket/Bracket.ts @@ -127,7 +127,7 @@ export abstract class Bracket { try { const manager = getTournamentManager(); - manager.import(this.data); + manager.importData(this.data); const teamOrder = this.teamOrderForSimulation(); diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts index fb77cf096..436b3d95c 100644 --- a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts +++ b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts @@ -112,6 +112,19 @@ export const matchSchema = z.union([ _action: _action("UNDO_WEAPON_REPORT"), mapIndex: z.coerce.number().int().nonnegative(), }), + z.object({ + _action: _action("LINK_INGESTED_USERS"), + links: z + .array( + z.object({ + ingestedInGameName: z.string().min(1).max(500), + ingestedTeamId: id.nullable(), + userId: id, + }), + ) + .min(1) + .max(TOURNAMENT.INGESTED_USER_LINKS_MAX), + }), ]); export const bracketIdx = z.coerce.number().int().min(0).max(100); diff --git a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts index fbc9ad550..5f3a500c2 100644 --- a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts @@ -3,6 +3,7 @@ import { sql } from "~/db/sql"; import { TournamentMatchStatus } from "~/db/tables"; import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as IngestRepository from "~/features/ingest/IngestRepository.server"; import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; @@ -28,6 +29,7 @@ import { dateToDatabaseTimestamp } from "~/utils/dates"; import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; import { + errorToast, errorToastIfFalsy, notFoundIfFalsy, parseParams, @@ -867,6 +869,68 @@ export const action: ActionFunction = async ({ params, request }) => { break; } + case "LINK_INGESTED_USERS": { + const isMemberOfATeamInTheMatch = match.players.some( + (p) => p.id === user.id, + ); + errorToastIfFalsy( + isMemberOfATeamInTheMatch || tournament.isOrganizer(user), + "Unauthorized", + ); + + const ingestedWeapons = + await IngestRepository.findIngestedWeaponsByTournamentMatchId(matchId); + const setParticipantUserIds = new Set( + (await TournamentMatchRepository.findResultsByMatchId(matchId)).flatMap( + (result) => + result.participants.map((participant) => participant.userId), + ), + ); + + for (const link of data.links) { + const player = match.players.find((p) => p.id === link.userId); + errorToastIfFalsy(player, "User is not in the match"); + errorToastIfFalsy( + setParticipantUserIds.size === 0 || + setParticipantUserIds.has(link.userId), + "User did not play in the match", + ); + errorToastIfFalsy( + link.ingestedTeamId === null || + player.tournamentTeamId === link.ingestedTeamId, + "User is not a member of the team", + ); + errorToastIfFalsy( + ingestedWeapons.some( + (w) => + w.userId === null && + w.ingestedInGameName === link.ingestedInGameName && + w.ingestedTeamId === link.ingestedTeamId, + ), + "Unknown ingested in-game name", + ); + } + + for (const link of data.links) { + try { + await IngestRepository.linkIngestedUser({ + tournamentId, + ingestedInGameName: link.ingestedInGameName, + ingestedTeamId: link.ingestedTeamId, + userId: link.userId, + }); + } catch (error) { + if (error instanceof IngestRepository.IngestedLinkConflictError) { + errorToast( + `Could not link "${link.ingestedInGameName}": the selected user already has a weapon on one of the games`, + ); + } + throw error; + } + } + + break; + } default: { assertUnreachable(data); } diff --git a/app/features/tournament-match/components/TournamentMatchIngestedUsers.module.css b/app/features/tournament-match/components/TournamentMatchIngestedUsers.module.css new file mode 100644 index 000000000..07eb81de8 --- /dev/null +++ b/app/features/tournament-match/components/TournamentMatchIngestedUsers.module.css @@ -0,0 +1,55 @@ +.teamName { + font-size: var(--font-sm); + color: var(--color-text-second); + border-bottom: var(--border-style); + padding-block-end: var(--s-1); +} + +.memberRow { + display: flex; + align-items: center; + gap: var(--s-2); + font-size: var(--font-sm); +} + +.memberName { + font-weight: var(--weight-semi); +} + +.memberInGameName { + color: var(--color-text-second); +} + +.ingestedRow { + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + align-items: center; + gap: var(--s-3); +} + +.ingestedInfo { + display: flex; + align-items: center; + gap: var(--s-2); + min-width: 0; +} + +.ingestedName { + font-weight: var(--weight-semi); + font-size: var(--font-sm); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.variantCount { + color: var(--color-text-second); + font-weight: var(--weight-body); + font-size: var(--font-xs); + margin-inline-start: var(--s-1); +} + +.ingestedWeapons { + display: flex; + gap: var(--s-1); +} diff --git a/app/features/tournament-match/components/TournamentMatchIngestedUsers.tsx b/app/features/tournament-match/components/TournamentMatchIngestedUsers.tsx new file mode 100644 index 000000000..2d155070e --- /dev/null +++ b/app/features/tournament-match/components/TournamentMatchIngestedUsers.tsx @@ -0,0 +1,270 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useFetcher } from "react-router"; +import { Avatar } from "~/components/Avatar"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouDialog } from "~/components/elements/Dialog"; +import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; +import { WeaponImage } from "~/components/Image"; +import { WeaponPool } from "~/components/match-page/WeaponPool"; +import { useUser } from "~/features/auth/core/user"; +import * as IngestedNames from "~/features/ingest/core/IngestedNames"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; +import styles from "./TournamentMatchIngestedUsers.module.css"; + +// xxx: do we really need linking ingested users yet? +// xxx: clear buttons overlap + +export function TournamentMatchIngestedUsers({ + data, +}: { + data: TournamentMatchLoaderData; +}) { + const { t } = useTranslation(["tournament"]); + const user = useUser(); + const tournament = useTournament(); + + const unlinkedGroups = IngestedNames.unlinkedNameGroups( + data.ingestedWeapons.filter((weapon) => weapon.userId === null), + ); + + const canLink = + data.match.players.some((p) => p.id === user?.id) || + tournament.isOrganizer(user); + + if (unlinkedGroups.length === 0 || !canLink) return null; + + return ( +
+ + {t("tournament:match.ingest.showDialogButton")} + + } + > + + +
+ ); +} + +function IngestedUsersForm({ + data, + unlinkedGroups, +}: { + data: TournamentMatchLoaderData; + unlinkedGroups: IngestedNames.IngestedNameGroup[]; +}) { + const { t } = useTranslation(["common", "tournament"]); + const tournament = useTournament(); + const fetcher = useFetcher(); + const playersInSet = resolvePlayersWhoPlayedInSet(data); + const linkedMapIndexesByUser = resolveLinkedMapIndexesByUser(data); + const isLinkableTo = ( + group: IngestedNames.IngestedNameGroup, + userId: number, + ) => + !group.mapIndexes.some((mapIndex) => + linkedMapIndexesByUser.get(userId)?.has(mapIndex), + ); + const [selectedUserByGroup, setSelectedUserByGroup] = useState< + Record + >(() => { + const preselected = IngestedNames.preselectedUserIdByGroup({ + groups: unlinkedGroups, + players: playersInSet, + }); + for (const group of unlinkedGroups) { + const key = IngestedNames.groupKey(group); + const userId = preselected[key]; + if (userId && !isLinkableTo(group, userId)) { + delete preselected[key]; + } + } + return preselected; + }); + + const teamIds = [ + ...new Set(unlinkedGroups.map((group) => group.ingestedTeamId)), + ].sort((a, b) => (a === null ? 1 : 0) - (b === null ? 1 : 0)); + + const links = unlinkedGroups.flatMap((group) => { + const userId = selectedUserByGroup[IngestedNames.groupKey(group)]; + if (!userId) return []; + + return group.names.map((ingestedInGameName) => ({ + ingestedInGameName, + ingestedTeamId: group.ingestedTeamId, + userId, + })); + }); + + const handleSave = () => { + fetcher.submit( + { _action: "LINK_INGESTED_USERS", links }, + { method: "post", encType: "application/json" }, + ); + }; + + return ( +
+
+ {t("tournament:match.ingest.explanation")} +
+ {teamIds.map((teamId) => { + const members = playersInSet.filter( + (p) => teamId === null || p.tournamentTeamId === teamId, + ); + + return ( +
+

+ {teamId !== null + ? (tournament.teamById(teamId)?.name ?? "?") + : t("tournament:match.ingest.unknownTeam")} +

+
+ {members.map((member) => ( +
+ + {member.username} + {member.inGameName ? ( + + {member.inGameName} + + ) : null} + +
+ ))} +
+ {unlinkedGroups + .filter((group) => group.ingestedTeamId === teamId) + .map((group) => ( +
+
+
1 + ? group.names.join(", ") + : undefined + } + > + {group.primaryName} + {group.names.length > 1 ? ( + + +{group.names.length - 1} + + ) : null} +
+
+ {group.weapons.map((weaponSplId) => ( + + ))} +
+
+ + setSelectedUserByGroup((prev) => { + const next = { ...prev }; + if (key === null) { + delete next[IngestedNames.groupKey(group)]; + } else { + next[IngestedNames.groupKey(group)] = Number(key); + } + return next; + }) + } + clearable + > + {members + .filter((member) => isLinkableTo(group, member.id)) + .map((member) => ( + + {member.username} + {member.inGameName ? ` (${member.inGameName})` : ""} + + ))} + +
+ ))} +
+ ); + })} +
+ + {t("common:actions.save")} + +
+
+ ); +} + +function MemberWeaponPool({ + weaponPools, + userId, +}: { + weaponPools: TournamentMatchLoaderData["ingestedWeaponPools"]; + userId: number; +}) { + const weapons = weaponPools + .filter((entry) => entry.userId === userId) + .map((entry) => entry.weaponSplId); + + if (weapons.length === 0) return null; + + return ; +} + +function resolveLinkedMapIndexesByUser(data: TournamentMatchLoaderData) { + const result = new Map>(); + + for (const weapon of data.ingestedWeapons) { + if (weapon.userId === null) continue; + + const mapIndexes = result.get(weapon.userId) ?? new Set(); + mapIndexes.add(weapon.mapIndex); + result.set(weapon.userId, mapIndexes); + } + + return result; +} + +function resolvePlayersWhoPlayedInSet(data: TournamentMatchLoaderData) { + const participantUserIds = new Set( + data.results.flatMap((result) => + result.participants.map((participant) => participant.userId), + ), + ); + if (participantUserIds.size === 0) return data.match.players; + + return data.match.players.filter((player) => + participantUserIds.has(player.id), + ); +} diff --git a/app/features/tournament-match/components/TournamentMatchTabs.tsx b/app/features/tournament-match/components/TournamentMatchTabs.tsx index a05ef6052..a4231413b 100644 --- a/app/features/tournament-match/components/TournamentMatchTabs.tsx +++ b/app/features/tournament-match/components/TournamentMatchTabs.tsx @@ -7,6 +7,7 @@ import type { TimelineMap, TimelinePickBanEvent, } from "~/components/match-page/MatchTimeline"; +import type { WeaponPoolWeapon } from "~/components/match-page/WeaponPool"; import { useUser } from "~/features/auth/core/user"; import { useTournament } from "~/features/tournament/routes/to.$id"; import * as PickBan from "~/features/tournament-bracket/core/PickBan"; @@ -18,6 +19,7 @@ import { type MatchPageTeam, useMatch } from "../match-page-context"; import { TournamentMatchActionPickBanTab } from "./TournamentMatchActionPickBanTab"; import { TournamentMatchActionTab } from "./TournamentMatchActionTab"; import { TournamentMatchAdminTab } from "./TournamentMatchAdminTab"; +import { TournamentMatchIngestedUsers } from "./TournamentMatchIngestedUsers"; export function TournamentMatchTabs({ data, @@ -82,7 +84,9 @@ export function TournamentMatchTabs({ maps={timelineMaps} pickBanRowsBySlot={pickBanData?.rowsBySlot} isOngoing={!data.matchIsOver && data.results.length > 0} - /> + > + + ) : null} {tabs.includes(TAB_KEYS.ACTION) ? ( @@ -164,8 +168,31 @@ function resolveTimelineMaps( (w) => w.mapIndex === mapIndex && w.userId === userId, )?.weaponSplId ?? null; - const alphaWeapons = alphaRoster.map((u) => weaponFor(u.id)); - const bravoWeapons = bravoRoster.map((u) => weaponFor(u.id)); + const weaponsFor = ( + roster: ReturnType, + tournamentTeamId: number, + ): WeaponPoolWeapon[] => { + const unlinkedIngested = data.ingestedWeapons.filter( + (w) => + w.mapIndex === mapIndex && + w.userId === null && + w.ingestedTeamId === tournamentTeamId, + ); + + let unlinkedIdx = 0; + return roster.map((u) => { + const linked = weaponFor(u.id); + if (linked !== null) return linked; + + const ingested = unlinkedIngested[unlinkedIdx++]; + return ingested + ? { weaponSplId: ingested.weaponSplId, unverified: true } + : null; + }); + }; + + const alphaWeapons = weaponsFor(alphaRoster, opponentOneId); + const bravoWeapons = weaponsFor(bravoRoster, opponentTwoId); const hasAnyWeapon = alphaWeapons.some((w) => w !== null) || bravoWeapons.some((w) => w !== null); diff --git a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts index c94ae4db0..f9e3eea75 100644 --- a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts @@ -3,6 +3,7 @@ import type { LoaderFunctionArgs } from "react-router"; import { getUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import { chatAccessible } from "~/features/chat/chat-utils"; +import * as IngestRepository from "~/features/ingest/IngestRepository.server"; import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; @@ -60,6 +61,12 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { const reportedWeapons = await ReportedWeaponRepository.findByTournamentMatchId(matchId); + const ingestedWeapons = + await IngestRepository.findIngestedWeaponsByTournamentMatchId(matchId); + const ingestedWeaponPools = ingestedWeapons.some((w) => w.userId === null) + ? await UserRepository.weaponPoolsByUserIds(match.players.map((p) => p.id)) + : []; + const matchIsOver = match.opponentOne?.result === "win" || match.opponentTwo?.result === "win"; @@ -228,6 +235,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { match: hasPermsToSeeChat ? match : { ...match, chatCode: undefined }, results, reportedWeapons, + ingestedWeapons, + ingestedWeaponPools, mapList, matchIsOver, endedEarly, diff --git a/app/features/tournament/tournament-constants.ts b/app/features/tournament/tournament-constants.ts index f9a34a512..ec6b6fe37 100644 --- a/app/features/tournament/tournament-constants.ts +++ b/app/features/tournament/tournament-constants.ts @@ -12,6 +12,7 @@ export const TOURNAMENT = { MAX_GROUP_SIZE: 6, MAX_BRACKETS_PER_TOURNAMENT: 10, BRACKET_NAME_MAX_LENGTH: 32, + INGESTED_USER_LINKS_MAX: 32, PLACEMENT_MAX: 100, // just a fallback, normally this should be set by user explicitly RR_DEFAULT_TEAM_COUNT_PER_GROUP: 4, diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index c649eda36..92f4ebdb6 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -1398,3 +1398,20 @@ export function weaponPoolByUserId(userId: number) { .orderBy("UserWeaponPool.sortOrder", "asc") .execute(); } + +/** Returns weapon pool entries for the given users. */ +export function weaponPoolsByUserIds(userIds: number[]) { + if (userIds.length === 0) return Promise.resolve([]); + + return db + .selectFrom("UserWeaponPool") + .select([ + "UserWeaponPool.userId", + "UserWeaponPool.weaponSplId", + "UserWeaponPool.isFavorite", + ]) + .where("UserWeaponPool.userId", "in", userIds) + .orderBy("UserWeaponPool.userId", "asc") + .orderBy("UserWeaponPool.sortOrder", "asc") + .execute(); +} diff --git a/app/modules/brackets-manager/manager.ts b/app/modules/brackets-manager/manager.ts index 53ef59c18..b5a45146b 100644 --- a/app/modules/brackets-manager/manager.ts +++ b/app/modules/brackets-manager/manager.ts @@ -69,7 +69,7 @@ export class BracketsManager { * @param data Data to import. * @param normalizeIds Enable ID normalization: all IDs (and references to them) are remapped to consecutive IDs starting from 0. */ - public import(rawData: Database, normalizeIds = false): void { + public importData(rawData: Database, normalizeIds = false): void { const data = normalizeIds ? helpers.normalizeIds(rawData) : rawData; if (!this.storage.delete("stage")) diff --git a/app/modules/brackets-manager/test/general.test.ts b/app/modules/brackets-manager/test/general.test.ts index 5a7389613..0492c6a15 100644 --- a/app/modules/brackets-manager/test/general.test.ts +++ b/app/modules/brackets-manager/test/general.test.ts @@ -358,7 +358,7 @@ describe("Import / export", () => { expect(storage.select("match", 0).opponent1.result).toBe("win"); expect(storage.select("match", 1).opponent1.result).toBe("win"); - manager.import(initialData); + manager.importData(initialData); expect(storage.select("match", 0).opponent1.result).toBe(undefined); expect(storage.select("match", 1).opponent1.result).toBe(undefined); diff --git a/app/routes.ts b/app/routes.ts index 2a9604289..7c8c6264b 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -329,6 +329,8 @@ export default [ route("/seed", "features/api-private/routes/seed.ts"), route("/users", "features/api-private/routes/users.ts"), + route("/ingest", "features/ingest/routes/ingest.ts"), + layout("features/api-public/routes/api.layout.tsx", [ ...prefix("/api", [ route( diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 0e2115c6d..564946f88 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/seeds/db-seed-AB_RR.sqlite3 b/e2e/seeds/db-seed-AB_RR.sqlite3 index 837eb7a48..e84570375 100644 Binary files a/e2e/seeds/db-seed-AB_RR.sqlite3 and b/e2e/seeds/db-seed-AB_RR.sqlite3 differ diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index 1f2836960..480b52e94 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 index 9458745ba..77842451b 100644 Binary files a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 and b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 differ diff --git a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 index ff28584e5..79de880ef 100644 Binary files a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 and b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index cb556bc7b..90f7d95b6 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index e56cc9491..94aca338a 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index f1ee19da7..9d88cc716 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index d847ca1e3..59f12883f 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index 4e33798a6..77978f9c3 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index 53d25ea1b..c0048d8d3 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 index 6b861441c..03fedd56c 100644 Binary files a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 and b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 differ diff --git a/locales/da/tournament.json b/locales/da/tournament.json index fe88e8fe8..8802a51c5 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -179,6 +179,11 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.ingest.showDialogButton": "", + "match.ingest.title": "", + "match.ingest.explanation": "", + "match.ingest.unknownTeam": "", + "match.ingest.selectUser": "", "match.admin.cast": "", "match.admin.castInfo": "", "match.admin.castConfigureHint": "", diff --git a/locales/de/tournament.json b/locales/de/tournament.json index e45efa7a4..871e49da5 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -179,6 +179,11 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.ingest.showDialogButton": "", + "match.ingest.title": "", + "match.ingest.explanation": "", + "match.ingest.unknownTeam": "", + "match.ingest.selectUser": "", "match.admin.cast": "", "match.admin.castInfo": "", "match.admin.castConfigureHint": "", diff --git a/locales/en/tournament.json b/locales/en/tournament.json index e61902699..aaa7a7292 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -179,6 +179,11 @@ "match.endSet.selectWinner": "Select Winner", "match.endSet.randomWinner": "Random (50/50)", "match.deadline.explanation": "Please let tournament organizers know about any delays. Matches that go past their deadline may be ended early. Consult tournament rules for details.", + "match.ingest.showDialogButton": "Match Ingested Users", + "match.ingest.title": "Match ingested users", + "match.ingest.explanation": "Weapons were ingested from a scoreboard recording but some in-game names could not be connected to a sendou.ink user. Connect them below to show the weapons in the timeline.", + "match.ingest.unknownTeam": "Unknown team", + "match.ingest.selectUser": "Select user", "match.admin.cast": "Cast", "match.admin.castInfo": "Select the Twitch account that is currently casting this match. It is then indicated in the bracket view.", "match.admin.castConfigureHint": "Configure streaming channels on the tournament admin page to enable casting.", diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index f4c6e30bb..767bcc97d 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -181,6 +181,11 @@ "match.endSet.selectWinner": "Seleccionar ganador", "match.endSet.randomWinner": "Aleatorio (50/50)", "match.deadline.explanation": "Informa a los organizadores del torneo sobre cualquier retraso. Los partidos que superen su límite de tiempo pueden terminarse anticipadamente. Consulta las reglas del torneo para más detalles.", + "match.ingest.showDialogButton": "", + "match.ingest.title": "", + "match.ingest.explanation": "", + "match.ingest.unknownTeam": "", + "match.ingest.selectUser": "", "match.admin.cast": "", "match.admin.castInfo": "", "match.admin.castConfigureHint": "", diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index 1954fa13a..8ad90257f 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -181,6 +181,11 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.ingest.showDialogButton": "", + "match.ingest.title": "", + "match.ingest.explanation": "", + "match.ingest.unknownTeam": "", + "match.ingest.selectUser": "", "match.admin.cast": "", "match.admin.castInfo": "", "match.admin.castConfigureHint": "", diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index 16e9b27a9..126126593 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -181,6 +181,11 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.ingest.showDialogButton": "", + "match.ingest.title": "", + "match.ingest.explanation": "", + "match.ingest.unknownTeam": "", + "match.ingest.selectUser": "", "match.admin.cast": "", "match.admin.castInfo": "", "match.admin.castConfigureHint": "", diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index a95bc342b..8acf65290 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -181,6 +181,11 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.ingest.showDialogButton": "", + "match.ingest.title": "", + "match.ingest.explanation": "", + "match.ingest.unknownTeam": "", + "match.ingest.selectUser": "", "match.admin.cast": "", "match.admin.castInfo": "", "match.admin.castConfigureHint": "", diff --git a/locales/he/tournament.json b/locales/he/tournament.json index 0763a5957..6d8dbf6fb 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -181,6 +181,11 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.ingest.showDialogButton": "", + "match.ingest.title": "", + "match.ingest.explanation": "", + "match.ingest.unknownTeam": "", + "match.ingest.selectUser": "", "match.admin.cast": "", "match.admin.castInfo": "", "match.admin.castConfigureHint": "", diff --git a/locales/it/tournament.json b/locales/it/tournament.json index 697ad843d..c30bc9061 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -181,6 +181,11 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.ingest.showDialogButton": "", + "match.ingest.title": "", + "match.ingest.explanation": "", + "match.ingest.unknownTeam": "", + "match.ingest.selectUser": "", "match.admin.cast": "", "match.admin.castInfo": "", "match.admin.castConfigureHint": "", diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index 0c3694d80..d1917d64c 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -175,6 +175,11 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.ingest.showDialogButton": "", + "match.ingest.title": "", + "match.ingest.explanation": "", + "match.ingest.unknownTeam": "", + "match.ingest.selectUser": "", "match.admin.cast": "", "match.admin.castInfo": "", "match.admin.castConfigureHint": "", diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index 022754298..c73e130ab 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -175,6 +175,11 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.ingest.showDialogButton": "", + "match.ingest.title": "", + "match.ingest.explanation": "", + "match.ingest.unknownTeam": "", + "match.ingest.selectUser": "", "match.admin.cast": "", "match.admin.castInfo": "", "match.admin.castConfigureHint": "", diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index 141826d9f..da669bb11 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -179,6 +179,11 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.ingest.showDialogButton": "", + "match.ingest.title": "", + "match.ingest.explanation": "", + "match.ingest.unknownTeam": "", + "match.ingest.selectUser": "", "match.admin.cast": "", "match.admin.castInfo": "", "match.admin.castConfigureHint": "", diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index 1e6b39be9..901f3b29f 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -183,6 +183,11 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.ingest.showDialogButton": "", + "match.ingest.title": "", + "match.ingest.explanation": "", + "match.ingest.unknownTeam": "", + "match.ingest.selectUser": "", "match.admin.cast": "", "match.admin.castInfo": "", "match.admin.castConfigureHint": "", diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index 66e0e53f2..ef1476652 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -181,6 +181,11 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.ingest.showDialogButton": "", + "match.ingest.title": "", + "match.ingest.explanation": "", + "match.ingest.unknownTeam": "", + "match.ingest.selectUser": "", "match.admin.cast": "", "match.admin.castInfo": "", "match.admin.castConfigureHint": "", diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index 7d00ebebd..3c37c613a 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -183,6 +183,11 @@ "match.endSet.selectWinner": "", "match.endSet.randomWinner": "", "match.deadline.explanation": "", + "match.ingest.showDialogButton": "", + "match.ingest.title": "", + "match.ingest.explanation": "", + "match.ingest.unknownTeam": "", + "match.ingest.selectUser": "", "match.admin.cast": "", "match.admin.castInfo": "", "match.admin.castConfigureHint": "", diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index b98d1ed2b..99e411137 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -177,6 +177,11 @@ "match.endSet.selectWinner": "选择获胜者", "match.endSet.randomWinner": "随机产生 (50/50)", "match.deadline.explanation": "若有任何延迟,请及时通知赛事组织者。超过截止时间的对局可能会被提前终止。详情请参阅赛事规则。", + "match.ingest.showDialogButton": "", + "match.ingest.title": "", + "match.ingest.explanation": "", + "match.ingest.unknownTeam": "", + "match.ingest.selectUser": "", "match.admin.cast": "转播", "match.admin.castInfo": "选择当前正在转播此对局的 Twitch 账号。随后它会在对战表视图中显示。", "match.admin.castConfigureHint": "请在赛事管理页面配置直播频道以启用转播功能。", diff --git a/migrations/155-ingest.js b/migrations/155-ingest.js new file mode 100644 index 000000000..fa65b8bce --- /dev/null +++ b/migrations/155-ingest.js @@ -0,0 +1,98 @@ +export function up(db) { + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + db.prepare( + /* sql */ ` + create table "ReportedWeapon_new" ( + "groupMatchId" integer, + "tournamentMatchId" integer, + "mapIndex" integer not null, + "weaponSplId" integer not null, + "userId" integer, + "ingestedInGameName" text, + "ingestedTeamId" integer, + "createdAt" integer default (strftime('%s', 'now')) not null, + foreign key ("groupMatchId") references "GroupMatch"("id") on delete cascade, + foreign key ("tournamentMatchId") references "TournamentMatch"("id") on delete cascade, + foreign key ("userId") references "User"("id") on delete restrict, + foreign key ("ingestedTeamId") references "TournamentTeam"("id") on delete set null, + unique("groupMatchId", "mapIndex", "userId") on conflict rollback, + unique("tournamentMatchId", "mapIndex", "userId") on conflict rollback, + check (("groupMatchId" is not null) <> ("tournamentMatchId" is not null)), + check ("userId" is not null or "ingestedInGameName" is not null) + ) strict + `, + ).run(); + + db.prepare( + /* sql */ ` + insert into "ReportedWeapon_new" + ("groupMatchId", "tournamentMatchId", "mapIndex", "weaponSplId", "userId", "createdAt") + select + "groupMatchId", + "tournamentMatchId", + "mapIndex", + "weaponSplId", + "userId", + "createdAt" + from "ReportedWeapon" + `, + ).run(); + + db.prepare(/* sql */ `drop table "ReportedWeapon"`).run(); + + db.prepare( + /* sql */ `alter table "ReportedWeapon_new" rename to "ReportedWeapon"`, + ).run(); + + db.prepare( + /* sql */ `create index reported_weapon_group_match_id on "ReportedWeapon"("groupMatchId")`, + ).run(); + + db.prepare( + /* sql */ `create index reported_weapon_tournament_match_id on "ReportedWeapon"("tournamentMatchId")`, + ).run(); + + db.prepare( + /* sql */ `create index reported_weapon_user_id on "ReportedWeapon"("userId")`, + ).run(); + + db.prepare( + /* sql */ `create index reported_weapon_user_created_at_weapon on "ReportedWeapon"("userId", "createdAt", "weaponSplId")`, + ).run(); + + db.prepare( + /* sql */ ` + create table "IngestedEvent" ( + "id" integer primary key, + "tournamentId" integer, + "povUserId" integer, + "submitterUserId" integer, + "type" text not null, + "t" real not null, + "confidence" real not null, + "data" text not null, + "detectedAt" integer, + "eventHash" text unique not null, + "createdAt" integer default (strftime('%s', 'now')) not null, + foreign key ("tournamentId") references "Tournament"("id") on delete cascade, + foreign key ("povUserId") references "User"("id") on delete set null, + foreign key ("submitterUserId") references "User"("id") on delete set null + ) strict + `, + ).run(); + + db.prepare( + /* sql */ `create index ingested_event_tournament_id on "IngestedEvent"("tournamentId")`, + ).run(); + + db.prepare( + /* sql */ `create index ingested_event_pov_user_id on "IngestedEvent"("povUserId")`, + ).run(); + + db.pragma("foreign_key_check"); + })(); + + db.pragma("foreign_keys = ON"); +}