Simplify ingest

This commit is contained in:
Kalle
2026-07-06 13:44:04 +03:00
parent 0706a24c72
commit 1dbc56a2b4
46 changed files with 300 additions and 1451 deletions

View File

@@ -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<number>;
}
@@ -489,6 +487,13 @@ export interface IngestedEvent {
createdAt: Generated<number>;
}
export interface IngestedScoreboard {
id: GeneratedAlways<number>;
matchGameResultId: number;
data: JSONColumnType<IngestedScoreboardData>;
createdAt: Generated<number>;
}
export interface Skill {
groupMatchId: number | null;
id: GeneratedAlways<number>;
@@ -1523,6 +1528,7 @@ export interface DB {
GroupMatchMap: GroupMatchMap;
GroupMember: GroupMember;
IngestedEvent: IngestedEvent;
IngestedScoreboard: IngestedScoreboard;
PrivateUserNote: PrivateUserNote;
LogInLink: LogInLink;
LFGPost: LFGPost;

View File

@@ -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<number>`"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 67 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<DB>;
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<boolean>`"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,
}));
}

View File

@@ -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 };
};

View File

@@ -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<typeof row>[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);
});
});

View File

@@ -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<number | null, NameAggregate[]>();
for (const aggregate of aggregates) {
const list = byTeam.get(aggregate.teamId) ?? [];
list.push(aggregate);
byTeam.set(aggregate.teamId, list);
}
const result: Array<IngestedNameGroup & { firstSeen: number }> = [];
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<string, number> {
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<string, number> = {};
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<IngestedNameGroup, "ingestedTeamId" | "primaryName">,
) {
return `${group.ingestedTeamId ?? "unknown"}|${group.primaryName}`;
}
interface NameAggregate {
name: string;
teamId: number | null;
mapIndexes: Set<number>;
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<IngestedNameGroup & { firstSeen: number }> {
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<number, NameAggregate[]>();
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<IngestedNameGroup & { firstSeen: number }> = [];
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<number>();
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]!;
}

View File

@@ -10,6 +10,7 @@ function testGame(
partial: Partial<Scoreboards.IngestableGame> = {},
): 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: ["", "w2", "w3", "w4", "l1", "l2", "l3", "l4"],
@@ -288,36 +279,30 @@ describe("reportedWeaponRowsFromEvents", () => {
loserInGameNames: ["W3#5678"],
}),
],
createdAt: 123,
});
// "" 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]);
});
});

View File

@@ -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<string, number>();
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,
},
};
}

View File

@@ -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({

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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 (
<div className="stack items-center mt-4">
<SendouDialog
heading={t("tournament:match.ingest.title")}
trigger={
<SendouButton variant="outlined" size="small">
{t("tournament:match.ingest.showDialogButton")}
</SendouButton>
}
>
<IngestedUsersForm data={data} unlinkedGroups={unlinkedGroups} />
</SendouDialog>
</div>
);
}
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<string, number>
>(() => {
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 (
<div className="stack md">
<div className="text-lighter text-sm">
{t("tournament:match.ingest.explanation")}
</div>
{teamIds.map((teamId) => {
const members = playersInSet.filter(
(p) => teamId === null || p.tournamentTeamId === teamId,
);
return (
<section key={teamId ?? "unknown"} className="stack sm">
<h3 className={styles.teamName}>
{teamId !== null
? (tournament.teamById(teamId)?.name ?? "?")
: t("tournament:match.ingest.unknownTeam")}
</h3>
<div className="stack xs">
{members.map((member) => (
<div key={member.id} className={styles.memberRow}>
<Avatar user={member} size="xxs" />
<span className={styles.memberName}>{member.username}</span>
{member.inGameName ? (
<span className={styles.memberInGameName}>
{member.inGameName}
</span>
) : null}
<MemberWeaponPool
weaponPools={data.ingestedWeaponPools}
userId={member.id}
/>
</div>
))}
</div>
{unlinkedGroups
.filter((group) => group.ingestedTeamId === teamId)
.map((group) => (
<div
key={IngestedNames.groupKey(group)}
className={styles.ingestedRow}
>
<div className={styles.ingestedInfo}>
<div
className={styles.ingestedName}
title={
group.names.length > 1
? group.names.join(", ")
: undefined
}
>
{group.primaryName}
{group.names.length > 1 ? (
<span className={styles.variantCount}>
+{group.names.length - 1}
</span>
) : null}
</div>
<div className={styles.ingestedWeapons}>
{group.weapons.map((weaponSplId) => (
<WeaponImage
key={weaponSplId}
weaponSplId={weaponSplId}
variant="badge"
size={24}
/>
))}
</div>
</div>
<SendouSelect
aria-label={t("tournament:match.ingest.selectUser")}
placeholder={t("tournament:match.ingest.selectUser")}
selectedKey={
selectedUserByGroup[IngestedNames.groupKey(group)] ?? null
}
onSelectionChange={(key) =>
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) => (
<SendouSelectItem
key={member.id}
id={member.id}
textValue={member.username}
>
{member.username}
{member.inGameName ? ` (${member.inGameName})` : ""}
</SendouSelectItem>
))}
</SendouSelect>
</div>
))}
</section>
);
})}
<div className="stack items-center">
<SendouButton
onPress={handleSave}
isDisabled={links.length === 0 || fetcher.state !== "idle"}
>
{t("common:actions.save")}
</SendouButton>
</div>
</div>
);
}
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 <WeaponPool weapons={weapons} size={20} />;
}
function resolveLinkedMapIndexesByUser(data: TournamentMatchLoaderData) {
const result = new Map<number, Set<number>>();
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),
);
}

View File

@@ -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}
>
<TournamentMatchIngestedUsers data={data} />
</MatchResultTab>
/>
) : null}
<TournamentMatchRosterTab data={data} />
{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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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();
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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.",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "请在赛事管理页面配置直播频道以启用转播功能。",

View File

@@ -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");
}