diff --git a/app/db/tables.ts b/app/db/tables.ts index 8394e6b52..aafe13e73 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 { IngestedScoreboardData } from "~/features/ingest/core/Scoreboards"; 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"; @@ -463,15 +464,12 @@ 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 | null; + userId: number; weaponSplId: MainWeaponId; - ingestedInGameName: string | null; - ingestedTeamId: number | null; createdAt: Generated; } @@ -489,6 +487,13 @@ export interface IngestedEvent { createdAt: Generated; } +export interface IngestedScoreboard { + id: GeneratedAlways; + matchGameResultId: number; + data: JSONColumnType; + createdAt: Generated; +} + export interface Skill { groupMatchId: number | null; id: GeneratedAlways; @@ -1523,6 +1528,7 @@ export interface DB { GroupMatchMap: GroupMatchMap; GroupMember: GroupMember; IngestedEvent: IngestedEvent; + IngestedScoreboard: IngestedScoreboard; PrivateUserNote: PrivateUserNote; LogInLink: LogInLink; LFGPost: LFGPost; diff --git a/app/features/ingest/IngestRepository.server.ts b/app/features/ingest/IngestRepository.server.ts index 8a042aae1..921821cd4 100644 --- a/app/features/ingest/IngestRepository.server.ts +++ b/app/features/ingest/IngestRepository.server.ts @@ -1,7 +1,12 @@ import { createHash } from "node:crypto"; -import { type NotNull, sql } from "kysely"; +import { sql, type Transaction } from "kysely"; import { db } from "~/db/sql"; -import type { IngestableGame, IngestedWeaponRow } from "./core/Scoreboards"; +import type { DB } from "~/db/tables"; +import type { + IngestableGame, + IngestedScoreboardData, + MatchedScoreboard, +} from "./core/Scoreboards"; import type { IngestedEventInput } from "./ingest-schemas"; const opponentOneId = sql`"TournamentMatch"."opponentOne" ->> '$.id'`; @@ -96,6 +101,7 @@ export async function gamesPlayedByUserInTournament({ "TournamentMatch.stageId", ) .select([ + "TournamentMatchGameResult.id as matchGameResultId", "TournamentMatchGameResult.matchId as tournamentMatchId", "TournamentMatchGameResult.number", "TournamentMatchGameResult.mode", @@ -124,6 +130,7 @@ export async function gamesPlayedByUserInTournament({ : null; return { + matchGameResultId: row.matchGameResultId, tournamentMatchId: row.tournamentMatchId, mapIndex: row.number - 1, mode: row.mode, @@ -188,235 +195,136 @@ export async function tournamentStartTime(tournamentId: number) { } /** - * 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). + * Stores matched scoreboards. A game that already has a stored scoreboard + * keeps it (first ingest wins). When the scoreboard's POV player is known + * (via povIndex + povUserId), their row is attributed to the user and their + * weapon is reported as a regular ReportedWeapon, unless the user already + * has one for that game. * - * @returns count of inserted rows + * @returns count of newly stored scoreboards */ -export async function addReportedWeapons(rows: IngestedWeaponRow[]) { - if (rows.length === 0) return 0; +export async function addScoreboards({ + scoreboards, + povUserId, +}: { + scoreboards: MatchedScoreboard[]; + povUserId: number | null; +}) { + let storedCount = 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(); + for (const scoreboard of scoreboards) { + const wasInserted = await db.transaction().execute(async (trx) => { + const povPlayer = + povUserId !== null && scoreboard.povIndex !== null + ? scoreboard.data.players[scoreboard.povIndex] + : undefined; - const rowKey = (row: { - tournamentMatchId: number | null; - mapIndex: number; - ingestedInGameName: string | null; - ingestedTeamId: number | null; - }) => - `${row.tournamentMatchId}-${row.mapIndex}-${row.ingestedTeamId}-${row.ingestedInGameName}`; + const data: IngestedScoreboardData = povPlayer + ? { + ...scoreboard.data, + players: scoreboard.data.players.map((player, playerIdx) => + playerIdx === scoreboard.povIndex + ? { ...player, userId: povUserId! } + : player, + ), + } + : scoreboard.data; - const existingKeys = new Set(existing.map(rowKey)); - const newRows = rows.filter((row) => !existingKeys.has(rowKey(row))); + const insertResult = await trx + .insertInto("IngestedScoreboard") + .values({ + matchGameResultId: scoreboard.matchGameResultId, + data: JSON.stringify(data), + }) + .onConflict((oc) => oc.column("matchGameResultId").doNothing()) + .executeTakeFirst(); + const inserted = Number(insertResult.numInsertedOrUpdatedRows ?? 0) > 0; - if (newRows.length === 0) return 0; + if (!povPlayer) return inserted; - await db.insertInto("ReportedWeapon").values(newRows).execute(); + if (!inserted) { + await attributePovUser({ + trx, + matchGameResultId: scoreboard.matchGameResultId, + povIndex: scoreboard.povIndex!, + userId: povUserId!, + }); + } - return newRows.length; + if (povPlayer.weaponSplId !== null) { + await trx + .insertInto("ReportedWeapon") + .values({ + tournamentMatchId: scoreboard.tournamentMatchId, + mapIndex: scoreboard.mapIndex, + userId: povUserId!, + weaponSplId: povPlayer.weaponSplId, + }) + .onConflict((oc) => + oc.columns(["tournamentMatchId", "mapIndex", "userId"]).doNothing(), + ) + .execute(); + } + + return inserted; + }); + + if (wasInserted) storedCount++; + } + + return storedCount; } -/** 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, +async function attributePovUser({ + trx, + matchGameResultId, + povIndex, userId, }: { - tournamentId: number; - ingestedInGameName: string; - ingestedTeamId: number | null; + trx: Transaction; + matchGameResultId: number; + povIndex: number; 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); + const existing = await trx + .selectFrom("IngestedScoreboard") + .select(["id", "data"]) + .where("matchGameResultId", "=", matchGameResultId) + .executeTakeFirst(); + if (!existing) return; - 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(); + const player = existing.data.players[povIndex]; + if (!player || player.userId !== undefined) return; - // 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 players = existing.data.players.map((other, playerIdx) => + playerIdx === povIndex ? { ...other, userId } : other, + ); - 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(); - }); + await trx + .updateTable("IngestedScoreboard") + .set({ data: JSON.stringify({ ...existing.data, players }) }) + .where("id", "=", existing.id) + .execute(); +} + +/** Returns a tournament match's ingested scoreboards with their 0-based map indexes. */ +export async function findScoreboardsByTournamentMatchId( + tournamentMatchId: number, +) { + const rows = await db + .selectFrom("IngestedScoreboard") + .innerJoin( + "TournamentMatchGameResult", + "TournamentMatchGameResult.id", + "IngestedScoreboard.matchGameResultId", + ) + .select(["TournamentMatchGameResult.number", "IngestedScoreboard.data"]) + .where("TournamentMatchGameResult.matchId", "=", tournamentMatchId) + .orderBy("TournamentMatchGameResult.number", "asc") + .execute(); + + return rows.map((row) => ({ + mapIndex: row.number - 1, + data: row.data, + })); } diff --git a/app/features/ingest/actions/ingest.server.ts b/app/features/ingest/actions/ingest.server.ts index fe02bc922..a9b7bb116 100644 --- a/app/features/ingest/actions/ingest.server.ts +++ b/app/features/ingest/actions/ingest.server.ts @@ -21,11 +21,9 @@ export const action: ActionFunction = async ({ request }) => { if (povUserId) { badRequestIfFalsy(await UserRepository.findLeanById(povUserId)); } - const tournamentStartTime = tournamentId - ? badRequestIfFalsy( - await IngestRepository.tournamentStartTime(tournamentId), - ) - : null; + if (tournamentId) { + badRequestIfFalsy(await IngestRepository.tournamentStartTime(tournamentId)); + } const storedEventsCount = await IngestRepository.addEvents({ tournamentId, @@ -34,22 +32,21 @@ export const action: ActionFunction = async ({ request }) => { events: data.events, }); - let reportedWeaponsCount = 0; - if (tournamentId && tournamentStartTime && povUserId) { + let storedScoreboardsCount = 0; + if (tournamentId && povUserId) { const games = await IngestRepository.gamesPlayedByUserInTournament({ userId: povUserId, tournamentId, }); - reportedWeaponsCount = await IngestRepository.addReportedWeapons( - Scoreboards.reportedWeaponRowsFromEvents({ + storedScoreboardsCount = await IngestRepository.addScoreboards({ + scoreboards: Scoreboards.matchedScoreboards({ events: data.events, games, - // xxx: why createdAt here? makes no sense - createdAt: tournamentStartTime, }), - ); + povUserId, + }); } - return { storedEventsCount, reportedWeaponsCount }; + return { storedEventsCount, storedScoreboardsCount }; }; diff --git a/app/features/ingest/core/IngestedNames.test.ts b/app/features/ingest/core/IngestedNames.test.ts deleted file mode 100644 index 50946fc30..000000000 --- a/app/features/ingest/core/IngestedNames.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -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 deleted file mode 100644 index 76d71dc1f..000000000 --- a/app/features/ingest/core/IngestedNames.ts +++ /dev/null @@ -1,294 +0,0 @@ -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 index e86b2348c..19c86a821 100644 --- a/app/features/ingest/core/Scoreboards.test.ts +++ b/app/features/ingest/core/Scoreboards.test.ts @@ -10,6 +10,7 @@ function testGame( partial: Partial = {}, ): Scoreboards.IngestableGame { return { + matchGameResultId: 11, tournamentMatchId: 1, mapIndex: 0, mode: "SZ", @@ -30,6 +31,7 @@ function testScoreboard({ lobby = "Private Battle", names = ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"], weapons = ["10", "10", "10", "10", "20", "20", "20", "20"], + povIndex = null, }: { t?: number; mode?: string | null; @@ -37,6 +39,7 @@ function testScoreboard({ lobby?: string | null; names?: string[]; weapons?: string[]; + povIndex?: number | null; } = {}): IngestedEventInput { return { type: "Scoreboard", @@ -55,50 +58,43 @@ function testScoreboard({ d: 5, s: 2, })), + povIndex, }, }; } -describe("reportedWeaponRowsFromEvents", () => { - it("fills weapons for all 8 players of a matching game", () => { - const rows = Scoreboards.reportedWeaponRowsFromEvents({ - events: [testScoreboard()], +describe("matchedScoreboards", () => { + it("turns a matching game's scoreboard into stored scoreboard data", () => { + const scoreboards = Scoreboards.matchedScoreboards({ + events: [testScoreboard({ povIndex: 2 })], games: [testGame()], - createdAt: 123, }); - expect(rows).toHaveLength(8); - expect(rows[0]).toEqual({ + expect(scoreboards).toHaveLength(1); + expect(scoreboards[0]).toEqual({ + matchGameResultId: 11, tournamentMatchId: 1, mapIndex: 0, - weaponSplId: 10, - ingestedInGameName: "w1", - ingestedTeamId: WINNER_TEAM_ID, - createdAt: 123, + povIndex: 2, + data: { + scores: [100, 52], + players: ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"].map( + (name, i) => ({ + name, + tournamentTeamId: i < 4 ? WINNER_TEAM_ID : LOSER_TEAM_ID, + weaponSplId: i < 4 ? 10 : 20, + ka: 10, + d: 5, + s: 2, + paint: 1000, + }), + ), + }, }); }); - it("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({ + const scoreboards = Scoreboards.matchedScoreboards({ events: [ testScoreboard({ mode: "Rainmaker", stage: "Eeltail Alley", t: 60 }), ], @@ -106,14 +102,13 @@ describe("reportedWeaponRowsFromEvents", () => { 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])); + expect(scoreboards.map((s) => s.mapIndex)).toEqual([1]); }); it("assigns two games on the same mode and stage in chronological order", () => { - const rows = Scoreboards.reportedWeaponRowsFromEvents({ + const scoreboards = Scoreboards.matchedScoreboards({ events: [ testScoreboard({ t: 60, @@ -128,56 +123,54 @@ describe("reportedWeaponRowsFromEvents", () => { testGame({ tournamentMatchId: 1, playedAt: 1000 }), testGame({ tournamentMatchId: 2, playedAt: 2000 }), ], - createdAt: 123, }); expect( - rows.find((row) => row.ingestedInGameName === "a")?.tournamentMatchId, + scoreboards.find((s) => s.data.players[0]!.name === "a") + ?.tournamentMatchId, ).toBe(1); expect( - rows.find((row) => row.ingestedInGameName === "i")?.tournamentMatchId, + scoreboards.find((s) => s.data.players[0]!.name === "i") + ?.tournamentMatchId, ).toBe(2); }); it("skips duplicate detections of the same scoreboard", () => { - const rows = Scoreboards.reportedWeaponRowsFromEvents({ + const scoreboards = Scoreboards.matchedScoreboards({ 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); + expect(scoreboards).toHaveLength(1); + expect(scoreboards[0]!.tournamentMatchId).toBe(1); }); it("skips scoreboards from other lobbies", () => { - const rows = Scoreboards.reportedWeaponRowsFromEvents({ + const scoreboards = Scoreboards.matchedScoreboards({ events: [testScoreboard({ lobby: "X Battle" })], games: [testGame()], - createdAt: 123, }); - expect(rows).toHaveLength(0); + expect(scoreboards).toHaveLength(0); }); it("skips scoreboards with unreadable mode or stage", () => { - const rows = Scoreboards.reportedWeaponRowsFromEvents({ + const scoreboards = Scoreboards.matchedScoreboards({ events: [ testScoreboard({ mode: null }), testScoreboard({ stage: "Not A Stage" }), ], games: [testGame()], - createdAt: 123, }); - expect(rows).toHaveLength(0); + expect(scoreboards).toHaveLength(0); }); - it("skips players with unknown weapon or empty name", () => { - const rows = Scoreboards.reportedWeaponRowsFromEvents({ + it("keeps players with unknown weapon or empty name", () => { + const scoreboards = Scoreboards.matchedScoreboards({ events: [ testScoreboard({ names: ["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"], @@ -185,15 +178,18 @@ describe("reportedWeaponRowsFromEvents", () => { }), ], games: [testGame()], - createdAt: 123, }); - expect(rows).toHaveLength(6); - expect(rows.some((row) => row.ingestedInGameName === "w3")).toBe(false); + const players = scoreboards[0]!.data.players; + expect(players).toHaveLength(8); + expect(players[1]!.name).toBe(""); + expect(players[1]!.weaponSplId).toBe(10); + expect(players[2]!.weaponSplId).toBe(null); + expect(players[2]!.ka).toBe(10); }); it("skips non-scoreboard events", () => { - const rows = Scoreboards.reportedWeaponRowsFromEvents({ + const scoreboards = Scoreboards.matchedScoreboards({ events: [ { type: "MapStart", @@ -203,14 +199,13 @@ describe("reportedWeaponRowsFromEvents", () => { }, ], games: [testGame()], - createdAt: 123, }); - expect(rows).toHaveLength(0); + expect(scoreboards).toHaveLength(0); }); it("skips scoreboards that have no matching game left", () => { - const rows = Scoreboards.reportedWeaponRowsFromEvents({ + const scoreboards = Scoreboards.matchedScoreboards({ events: [ testScoreboard({ t: 60 }), testScoreboard({ @@ -219,15 +214,14 @@ describe("reportedWeaponRowsFromEvents", () => { }), ], games: [testGame()], - createdAt: 123, }); - expect(rows).toHaveLength(8); + expect(scoreboards).toHaveLength(1); }); it("uses ScoreboardReplay events too", () => { const scoreboard = testScoreboard(); - const rows = Scoreboards.reportedWeaponRowsFromEvents({ + const scoreboards = Scoreboards.matchedScoreboards({ events: [ { ...scoreboard, @@ -244,14 +238,13 @@ describe("reportedWeaponRowsFromEvents", () => { }, ], games: [testGame()], - createdAt: 123, }); - expect(rows).toHaveLength(8); + expect(scoreboards).toHaveLength(1); }); it("skips a game whose known rosters contradict the scoreboard sides", () => { - const rows = Scoreboards.reportedWeaponRowsFromEvents({ + const scoreboards = Scoreboards.matchedScoreboards({ events: [testScoreboard()], games: [ testGame({ @@ -268,15 +261,13 @@ describe("reportedWeaponRowsFromEvents", () => { playedAt: 2000, }), ], - createdAt: 123, }); - expect(rows.map((row) => row.tournamentMatchId)).not.toContain(1); - expect(rows.filter((row) => row.tournamentMatchId === 2)).toHaveLength(8); + expect(scoreboards.map((s) => s.tournamentMatchId)).toEqual([2]); }); it("matches known in-game names ignoring discriminator, case and unicode width", () => { - const rows = Scoreboards.reportedWeaponRowsFromEvents({ + const scoreboards = Scoreboards.matchedScoreboards({ events: [ testScoreboard({ names: ["W1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"], @@ -288,36 +279,30 @@ describe("reportedWeaponRowsFromEvents", () => { 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); + expect(scoreboards).toHaveLength(1); }); - it("skips players whose name appears twice on the same side", () => { - const rows = Scoreboards.reportedWeaponRowsFromEvents({ + it("keeps players whose name appears twice on the same side", () => { + const scoreboards = Scoreboards.matchedScoreboards({ 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); + scoreboards[0]!.data.players.filter((p) => p.name === "dupe"), + ).toHaveLength(3); }); it("does not assign a game played before the previously assigned one", () => { - const rows = Scoreboards.reportedWeaponRowsFromEvents({ + const scoreboards = Scoreboards.matchedScoreboards({ events: [ testScoreboard({ t: 60, mode: "Rainmaker", stage: "Eeltail Alley" }), testScoreboard({ t: 1000, mode: "Splat Zones", stage: "Scorch Gorge" }), @@ -336,10 +321,8 @@ describe("reportedWeaponRowsFromEvents", () => { playedAt: 2000, }), ], - createdAt: 123, }); - expect(rows.map((row) => row.tournamentMatchId)).not.toContain(1); - expect(rows.filter((row) => row.tournamentMatchId === 2)).toHaveLength(8); + expect(scoreboards.map((s) => s.tournamentMatchId)).toEqual([2]); }); }); diff --git a/app/features/ingest/core/Scoreboards.ts b/app/features/ingest/core/Scoreboards.ts index d732ca322..46493127d 100644 --- a/app/features/ingest/core/Scoreboards.ts +++ b/app/features/ingest/core/Scoreboards.ts @@ -1,4 +1,3 @@ -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 { @@ -43,6 +42,7 @@ const PLAYERS_PER_TEAM = 4; /** A game of a tournament match that ingested scoreboards can be matched against. */ export interface IngestableGame { + matchGameResultId: number; tournamentMatchId: number; /** 0-based index of the game within its match */ mapIndex: number; @@ -58,18 +58,35 @@ export interface IngestableGame { playedAt: number; } -export type IngestedWeaponRow = Pick< - Tables["ReportedWeapon"], - "tournamentMatchId" | "mapIndex" | "weaponSplId" | "createdAt" -> & { - ingestedInGameName: string; - ingestedTeamId: number | null; -}; +export interface IngestedScoreboardPlayer { + name: string; + tournamentTeamId: number | null; + weaponSplId: MainWeaponId | null; + ka: number | null; + d: number | null; + s: number | null; + paint: number | null; + /** set only via povIndex attribution */ + userId?: number; +} + +export interface IngestedScoreboardData { + scores: [number | null, number | null]; + /** in scoreboard order: rows 0-3 winning team, rows 4-7 losing team */ + players: IngestedScoreboardPlayer[]; +} + +export interface MatchedScoreboard { + matchGameResultId: number; + tournamentMatchId: number; + mapIndex: number; + povIndex: number | null; + data: IngestedScoreboardData; +} /** * 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). + * them into insertable scoreboard rows. * * 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 @@ -78,15 +95,13 @@ export type IngestedWeaponRow = Pick< * Scoreboards from other lobbies, with unreadable mode/stage or duplicated * detections of the same game are skipped. */ -export function reportedWeaponRowsFromEvents({ +export function matchedScoreboards({ events, games, - createdAt, }: { events: IngestedEventInput[]; games: IngestableGame[]; - createdAt: number; -}): IngestedWeaponRow[] { +}): MatchedScoreboard[] { const scoreboards = dedupeScoreboards( events .filter(isScoreboardEvent) @@ -99,7 +114,7 @@ export function reportedWeaponRowsFromEvents({ (a, b) => a.playedAt - b.playedAt || a.mapIndex - b.mapIndex, ); - const rows: IngestedWeaponRow[] = []; + const result: MatchedScoreboard[] = []; let nextGameIdx = 0; for (const scoreboard of scoreboards) { @@ -116,13 +131,13 @@ export function reportedWeaponRowsFromEvents({ if (game.mode !== mode || game.stageId !== stageId) continue; if (!sidesMatchKnownPlayers(scoreboard, game)) continue; - rows.push(...scoreboardToWeaponRows({ scoreboard, game, createdAt })); + result.push(scoreboardToMatchedScoreboard({ scoreboard, game })); nextGameIdx = i + 1; break; } } - return rows; + return result; } function isScoreboardEvent( @@ -189,47 +204,40 @@ function normalizeInGameName(name: string) { return name.split("#")[0]!.normalize("NFKC").trim().toLowerCase(); } -function scoreboardToWeaponRows({ +function scoreboardToMatchedScoreboard({ scoreboard, game, - createdAt, }: { scoreboard: ScoreboardEventInput; game: IngestableGame; - createdAt: number; -}): IngestedWeaponRow[] { - const rows: IngestedWeaponRow[] = []; +}): MatchedScoreboard { + const players = scoreboard.data.players.map( + (player, playerIdx): IngestedScoreboardPlayer => { + const weaponSplId = Number(player.weapon); - 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); - } + return { + name: player.name.trim(), + tournamentTeamId: + playerIdx < PLAYERS_PER_TEAM ? game.winnerTeamId : game.loserTeamId, + weaponSplId: MAIN_WEAPON_IDS.has(weaponSplId) + ? (weaponSplId as MainWeaponId) + : null, + ka: player.ka, + d: player.d, + s: player.s, + paint: player.paint, + }; + }, + ); - 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; + return { + matchGameResultId: game.matchGameResultId, + tournamentMatchId: game.tournamentMatchId, + mapIndex: game.mapIndex, + povIndex: scoreboard.data.povIndex, + data: { + scores: scoreboard.data.scores, + players, + }, + }; } diff --git a/app/features/ingest/ingest-schemas.ts b/app/features/ingest/ingest-schemas.ts index 00d84f376..cef2d88ce 100644 --- a/app/features/ingest/ingest-schemas.ts +++ b/app/features/ingest/ingest-schemas.ts @@ -22,6 +22,7 @@ const scoreboardDataSchema = z.object({ stage: detectionText.nullable(), scores: z.tuple([z.number().nullable(), z.number().nullable()]), players: z.array(scoreboardPlayerSchema).length(8), + povIndex: z.number().int().min(0).max(7).nullable(), }); const scoreboardReplayDataSchema = scoreboardDataSchema.extend({ diff --git a/app/features/sendouq-match/ReportedWeaponRepository.server.ts b/app/features/sendouq-match/ReportedWeaponRepository.server.ts index 2bf2924cd..343611855 100644 --- a/app/features/sendouq-match/ReportedWeaponRepository.server.ts +++ b/app/features/sendouq-match/ReportedWeaponRepository.server.ts @@ -101,10 +101,9 @@ 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; userId: NotNull }>() + .$narrowType<{ groupMatchId: NotNull }>() .execute(); if (rows.length === 0) return null; @@ -184,14 +183,9 @@ 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; - userId: NotNull; - }>() + .$narrowType<{ tournamentMatchId: NotNull; mapIndex: NotNull }>() .execute(); if (rows.length === 0) return null; diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts index 436b3d95c..fb77cf096 100644 --- a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts +++ b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts @@ -112,19 +112,6 @@ 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 5f3a500c2..fbc9ad550 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,7 +3,6 @@ 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"; @@ -29,7 +28,6 @@ import { dateToDatabaseTimestamp } from "~/utils/dates"; import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; import { - errorToast, errorToastIfFalsy, notFoundIfFalsy, parseParams, @@ -869,68 +867,6 @@ 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 deleted file mode 100644 index 07eb81de8..000000000 --- a/app/features/tournament-match/components/TournamentMatchIngestedUsers.module.css +++ /dev/null @@ -1,55 +0,0 @@ -.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 deleted file mode 100644 index 2d155070e..000000000 --- a/app/features/tournament-match/components/TournamentMatchIngestedUsers.tsx +++ /dev/null @@ -1,270 +0,0 @@ -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 a4231413b..427df80ec 100644 --- a/app/features/tournament-match/components/TournamentMatchTabs.tsx +++ b/app/features/tournament-match/components/TournamentMatchTabs.tsx @@ -19,7 +19,6 @@ 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, @@ -84,9 +83,7 @@ export function TournamentMatchTabs({ maps={timelineMaps} pickBanRowsBySlot={pickBanData?.rowsBySlot} isOngoing={!data.matchIsOver && data.results.length > 0} - > - - + /> ) : null} {tabs.includes(TAB_KEYS.ACTION) ? ( @@ -174,9 +171,7 @@ function resolveTimelineMaps( ): WeaponPoolWeapon[] => { const unlinkedIngested = data.ingestedWeapons.filter( (w) => - w.mapIndex === mapIndex && - w.userId === null && - w.ingestedTeamId === tournamentTeamId, + w.mapIndex === mapIndex && w.tournamentTeamId === tournamentTeamId, ); let unlinkedIdx = 0; 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 f9e3eea75..7f8c716b2 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 @@ -61,11 +61,21 @@ 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 ingestedScoreboards = + await IngestRepository.findScoreboardsByTournamentMatchId(matchId); + const ingestedWeapons = ingestedScoreboards.flatMap((scoreboard) => + scoreboard.data.players.flatMap((player) => + player.userId === undefined && player.weaponSplId !== null + ? [ + { + mapIndex: scoreboard.mapIndex, + tournamentTeamId: player.tournamentTeamId, + weaponSplId: player.weaponSplId, + }, + ] + : [], + ), + ); const matchIsOver = match.opponentOne?.result === "win" || match.opponentTwo?.result === "win"; @@ -236,7 +246,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { results, reportedWeapons, ingestedWeapons, - ingestedWeaponPools, mapList, matchIsOver, endedEarly, diff --git a/app/features/tournament/tournament-constants.ts b/app/features/tournament/tournament-constants.ts index ec6b6fe37..f9a34a512 100644 --- a/app/features/tournament/tournament-constants.ts +++ b/app/features/tournament/tournament-constants.ts @@ -12,7 +12,6 @@ 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 92f4ebdb6..c649eda36 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -1398,20 +1398,3 @@ 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/db-test.sqlite3 b/db-test.sqlite3 index 564946f88..baf40bf79 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 e84570375..21418ef8d 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 480b52e94..c3861ab9e 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 77842451b..3926686ba 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 79de880ef..e54d1f4b9 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 90f7d95b6..8f7a4e4fa 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 94aca338a..2b510a01b 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 9d88cc716..d22302ac1 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 59f12883f..6a4a07364 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 77978f9c3..02ce06f98 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 c0048d8d3..80af303e5 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 03fedd56c..e2a38ef4f 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 8802a51c5..fe88e8fe8 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -179,11 +179,6 @@ "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 871e49da5..e45efa7a4 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -179,11 +179,6 @@ "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 aaa7a7292..e61902699 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -179,11 +179,6 @@ "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 767bcc97d..f4c6e30bb 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -181,11 +181,6 @@ "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 8ad90257f..1954fa13a 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -181,11 +181,6 @@ "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 126126593..16e9b27a9 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -181,11 +181,6 @@ "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 8acf65290..a95bc342b 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -181,11 +181,6 @@ "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 6d8dbf6fb..0763a5957 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -181,11 +181,6 @@ "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 c30bc9061..697ad843d 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -181,11 +181,6 @@ "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 d1917d64c..0c3694d80 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -175,11 +175,6 @@ "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 c73e130ab..022754298 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -175,11 +175,6 @@ "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 da669bb11..141826d9f 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -179,11 +179,6 @@ "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 901f3b29f..1e6b39be9 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -183,11 +183,6 @@ "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 ef1476652..66e0e53f2 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -181,11 +181,6 @@ "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 3c37c613a..7d00ebebd 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -183,11 +183,6 @@ "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 99e411137..b98d1ed2b 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -177,11 +177,6 @@ "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 index fa65b8bce..7242fd395 100644 --- a/migrations/155-ingest.js +++ b/migrations/155-ingest.js @@ -1,67 +1,5 @@ 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" ( @@ -91,8 +29,16 @@ export function up(db) { /* sql */ `create index ingested_event_pov_user_id on "IngestedEvent"("povUserId")`, ).run(); - db.pragma("foreign_key_check"); + db.prepare( + /* sql */ ` + create table "IngestedScoreboard" ( + "id" integer primary key, + "matchGameResultId" integer not null unique, + "data" text not null, + "createdAt" integer default (strftime('%s', 'now')) not null, + foreign key ("matchGameResultId") references "TournamentMatchGameResult"("id") on delete cascade + ) strict + `, + ).run(); })(); - - db.pragma("foreign_keys = ON"); }