This commit is contained in:
Kalle
2026-07-06 10:53:17 +03:00
parent a4dfd82141
commit 0706a24c72
55 changed files with 2418 additions and 26 deletions

View File

@@ -11,11 +11,7 @@ import { useTranslation } from "react-i18next";
import { LocaleTime } from "~/components/LocaleTime";
import type { GroupSkillDifference, UserSkillDifference } from "~/db/tables";
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { CommonUser } from "~/utils/kysely.server";
import { roundToNDecimalPlaces } from "~/utils/number";
import { Avatar } from "../Avatar";
@@ -24,6 +20,7 @@ import { SendouPopover } from "../elements/Popover";
import { ModeImage, StageImage } from "../Image";
import styles from "./MatchTimeline.module.css";
import { type InferredSubstitution, inferSubstitutions } from "./utils";
import type { WeaponPoolWeapon } from "./WeaponPool";
import { WeaponPool } from "./WeaponPool";
const LONG_TEAM_NAME_THRESHOLD = 16;
@@ -45,8 +42,8 @@ export interface TimelineMap {
bravo: CommonUser[];
};
weapons?: {
alpha: Array<MainWeaponId | null>;
bravo: Array<MainWeaponId | null>;
alpha: WeaponPoolWeapon[];
bravo: WeaponPoolWeapon[];
};
/** Optional point values [alpha, bravo] */
points?: [number, number];
@@ -259,7 +256,7 @@ function SideResult({
}: {
result: "WIN" | "LOSS";
points?: number;
weapons?: Array<MainWeaponId | null>;
weapons?: WeaponPoolWeapon[];
isPicked?: boolean;
}) {
const { t } = useTranslation(["q"]);

View File

@@ -25,3 +25,7 @@
font-size: var(--font-xs);
font-weight: var(--weight-semi);
}
.unverifiedWeapon {
opacity: 0.7;
}

View File

@@ -1,3 +1,4 @@
import clsx from "clsx";
import { Button } from "react-aria-components";
import { useTranslation } from "react-i18next";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
@@ -6,26 +7,42 @@ import { SendouPopover } from "../elements/Popover";
import { Image, WeaponImage } from "../Image";
import styles from "./WeaponPool.module.css";
export type WeaponPoolWeapon =
| MainWeaponId
| {
weaponSplId: MainWeaponId;
/** renders faded, e.g. an ingested weapon not yet linked to its user */
unverified?: boolean;
}
| null;
export function WeaponPool({
weapons,
size = 24,
}: {
weapons: Array<MainWeaponId | null>;
weapons: WeaponPoolWeapon[];
size?: number;
}) {
const { t } = useTranslation(["weapons"]);
const entries = weapons.map((weapon) =>
typeof weapon === "number" ? { weaponSplId: weapon } : weapon,
);
return (
<SendouPopover
trigger={
<Button className={styles.weaponRow}>
{weapons.map((weaponId, i) =>
weaponId !== null ? (
{entries.map((weapon, i) =>
weapon !== null ? (
<WeaponImage
key={i}
weaponSplId={weaponId}
weaponSplId={weapon.weaponSplId}
variant="badge"
size={size}
className={clsx({
[styles.unverifiedWeapon]: weapon.unverified,
})}
/>
) : (
<Image
@@ -41,11 +58,20 @@ export function WeaponPool({
}
>
<div className={styles.weaponPopover}>
{weapons.map((weaponId, i) =>
weaponId !== null ? (
<div key={i} className={styles.weaponPopoverRow}>
<WeaponImage weaponSplId={weaponId} variant="badge" size={32} />
<span>{t(`weapons:MAIN_${weaponId}` as any)}</span>
{entries.map((weapon, i) =>
weapon !== null ? (
<div
key={i}
className={clsx(styles.weaponPopoverRow, {
[styles.unverifiedWeapon]: weapon.unverified,
})}
>
<WeaponImage
weaponSplId={weapon.weaponSplId}
variant="badge"
size={32}
/>
<span>{t(`weapons:MAIN_${weapon.weaponSplId}` as any)}</span>
</div>
) : null,
)}

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 { IngestedEventData } from "~/features/ingest/ingest-schemas";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import type { Notification as NotificationValue } from "~/features/notifications/notifications-types";
import type { ScrimFilters } from "~/features/scrims/scrims-types";
@@ -462,12 +463,29 @@ export interface PlusVotingResult {
wasSuggested: DBBoolean;
}
// xxx: or keep ReportedWeapon as it was and add some new rich stats table?
export interface ReportedWeapon {
groupMatchId: number | null;
tournamentMatchId: number | null;
mapIndex: number;
userId: number;
userId: number | null;
weaponSplId: MainWeaponId;
ingestedInGameName: string | null;
ingestedTeamId: number | null;
createdAt: Generated<number>;
}
export interface IngestedEvent {
id: GeneratedAlways<number>;
tournamentId: number | null;
povUserId: number | null;
submitterUserId: number | null;
type: string;
t: number;
confidence: number;
data: JSONColumnType<IngestedEventData>;
detectedAt: number | null;
eventHash: string;
createdAt: Generated<number>;
}
@@ -1504,6 +1522,7 @@ export interface DB {
GroupMatchContinueVote: GroupMatchContinueVote;
GroupMatchMap: GroupMatchMap;
GroupMember: GroupMember;
IngestedEvent: IngestedEvent;
PrivateUserNote: PrivateUserNote;
LogInLink: LogInLink;
LFGPost: LFGPost;

View File

@@ -0,0 +1,422 @@
import { createHash } from "node:crypto";
import { type NotNull, sql } from "kysely";
import { db } from "~/db/sql";
import type { IngestableGame, IngestedWeaponRow } from "./core/Scoreboards";
import type { IngestedEventInput } from "./ingest-schemas";
const opponentOneId = sql<number>`"TournamentMatch"."opponentOne" ->> '$.id'`;
const opponentTwoId = sql<number>`"TournamentMatch"."opponentTwo" ->> '$.id'`;
/**
* Stores raw ingested events. Events whose contents were stored before
* (for the same tournament and POV user) are skipped.
*
* @returns count of newly stored events
*/
export async function addEvents({
tournamentId,
povUserId,
submitterUserId,
events,
}: {
tournamentId: number | null;
povUserId: number | null;
submitterUserId: number | null;
events: IngestedEventInput[];
}) {
const result = await db
.insertInto("IngestedEvent")
.values(
events.map((event) => ({
tournamentId,
povUserId,
submitterUserId,
type: event.type,
t: event.t,
confidence: event.confidence,
data: JSON.stringify(event.data),
detectedAt: event.detectedAt ?? null,
eventHash: eventHash({ tournamentId, povUserId, event }),
})),
)
.onConflict((oc) => oc.column("eventHash").doNothing())
.execute();
return result.reduce(
(acc, cur) => acc + Number(cur.numInsertedOrUpdatedRows ?? 0),
0,
);
}
function eventHash({
tournamentId,
povUserId,
event,
}: {
tournamentId: number | null;
povUserId: number | null;
event: IngestedEventInput;
}) {
return createHash("sha256")
.update(
JSON.stringify([
tournamentId,
povUserId,
event.type,
event.t,
event.data,
]),
)
.digest("hex");
}
/** Returns the games a user played in a tournament, in chronological order. */
export async function gamesPlayedByUserInTournament({
userId,
tournamentId,
}: {
userId: number;
tournamentId: number;
}): Promise<IngestableGame[]> {
const rows = await db
.selectFrom("TournamentMatchGameResultParticipant")
.innerJoin(
"TournamentMatchGameResult",
"TournamentMatchGameResult.id",
"TournamentMatchGameResultParticipant.matchGameResultId",
)
.innerJoin(
"TournamentMatch",
"TournamentMatch.id",
"TournamentMatchGameResult.matchId",
)
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.select([
"TournamentMatchGameResult.matchId as tournamentMatchId",
"TournamentMatchGameResult.number",
"TournamentMatchGameResult.mode",
"TournamentMatchGameResult.stageId",
"TournamentMatchGameResult.winnerTeamId",
"TournamentMatchGameResult.createdAt as playedAt",
opponentOneId.as("opponentOneId"),
opponentTwoId.as("opponentTwoId"),
])
.where("TournamentMatchGameResultParticipant.userId", "=", userId)
.where("TournamentStage.tournamentId", "=", tournamentId)
.orderBy("TournamentMatchGameResult.createdAt", "asc")
.orderBy("TournamentMatchGameResult.number", "asc")
.execute();
const inGameNamesByTeamId = await teamInGameNames(
rows.flatMap((row) => [row.opponentOneId, row.opponentTwoId]),
);
return rows.map((row) => {
const loserTeamId =
row.winnerTeamId === row.opponentOneId
? row.opponentTwoId
: row.winnerTeamId === row.opponentTwoId
? row.opponentOneId
: null;
return {
tournamentMatchId: row.tournamentMatchId,
mapIndex: row.number - 1,
mode: row.mode,
stageId: row.stageId,
winnerTeamId: row.winnerTeamId,
loserTeamId,
winnerInGameNames: inGameNamesByTeamId.get(row.winnerTeamId) ?? [],
loserInGameNames:
(loserTeamId !== null
? inGameNamesByTeamId.get(loserTeamId)
: undefined) ?? [],
playedAt: row.playedAt,
};
});
}
async function teamInGameNames(teamIds: Array<number | null>) {
const uniqueTeamIds = [
...new Set(teamIds.filter((id): id is number => id !== null)),
];
if (uniqueTeamIds.length === 0) return new Map<number, string[]>();
const members = await db
.selectFrom("TournamentTeamMember")
.innerJoin("User", "User.id", "TournamentTeamMember.userId")
.select([
"TournamentTeamMember.tournamentTeamId",
sql<
string | null
>`coalesce("TournamentTeamMember"."inGameName", "User"."inGameName")`.as(
"inGameName",
),
])
.where("TournamentTeamMember.tournamentTeamId", "in", uniqueTeamIds)
.execute();
const result = new Map<number, string[]>();
for (const member of members) {
if (!member.inGameName) continue;
const names = result.get(member.tournamentTeamId) ?? [];
names.push(member.inGameName);
result.set(member.tournamentTeamId, names);
}
return result;
}
/** Returns the tournament's start time as a database timestamp. */
export async function tournamentStartTime(tournamentId: number) {
const row = await db
.selectFrom("CalendarEvent")
.innerJoin(
"CalendarEventDate",
"CalendarEventDate.eventId",
"CalendarEvent.id",
)
.select(({ fn }) => fn.min("CalendarEventDate.startTime").as("startTime"))
.where("CalendarEvent.tournamentId", "=", tournamentId)
.executeTakeFirst();
return row?.startTime ?? null;
}
/**
* Inserts ingested weapon rows, skipping rows whose in-game name already has
* a reported weapon for the same map (e.g. from an earlier ingest).
*
* @returns count of inserted rows
*/
export async function addReportedWeapons(rows: IngestedWeaponRow[]) {
if (rows.length === 0) return 0;
const matchIds = [...new Set(rows.map((row) => row.tournamentMatchId))];
const existing = await db
.selectFrom("ReportedWeapon")
.select([
"tournamentMatchId",
"mapIndex",
"ingestedInGameName",
"ingestedTeamId",
])
.where("tournamentMatchId", "in", matchIds)
.where("ingestedInGameName", "is not", null)
.execute();
const rowKey = (row: {
tournamentMatchId: number | null;
mapIndex: number;
ingestedInGameName: string | null;
ingestedTeamId: number | null;
}) =>
`${row.tournamentMatchId}-${row.mapIndex}-${row.ingestedTeamId}-${row.ingestedInGameName}`;
const existingKeys = new Set(existing.map(rowKey));
const newRows = rows.filter((row) => !existingKeys.has(rowKey(row)));
if (newRows.length === 0) return 0;
await db.insertInto("ReportedWeapon").values(newRows).execute();
return newRows.length;
}
/** Returns a tournament match's ingested weapons (both linked to a user and not). */
export function findIngestedWeaponsByTournamentMatchId(
tournamentMatchId: number,
) {
return db
.selectFrom("ReportedWeapon")
.select([
"ReportedWeapon.mapIndex",
"ReportedWeapon.weaponSplId",
"ReportedWeapon.ingestedInGameName",
"ReportedWeapon.ingestedTeamId",
"ReportedWeapon.userId",
])
.where("ReportedWeapon.tournamentMatchId", "=", tournamentMatchId)
.where("ReportedWeapon.ingestedInGameName", "is not", null)
.orderBy("ReportedWeapon.mapIndex", "asc")
.$narrowType<{ ingestedInGameName: NotNull }>()
.execute();
}
// xxx: probably would struggle with people with same name?
/**
* it's half-handled. Let me walk through the two variants, because they behave very differently.
Same name on opposite teams: handled. Every row carries ingestedTeamId (derived from scoreboard position + who won the game), so the two "Nayo"s produce distinct (name, team) identities. The dialog groups by team, so each appears under its own team section with its own select, and linkIngestedUser scopes the update by name and team. No collision anywhere.
Same name on the same team: stored, but linking breaks. Tracing it through:
1. Ingest: both scoreboard rows insert fine — there's no unique constraint on (match, mapIndex, name), and the userId uniques don't fire because userId is NULL (SQLite treats NULLs as distinct). Raw data preserved, weapons possibly different per row.
2. Dialog: resolveUnlinkedNames collapses them into one entry keyed by (name, teamId), with their weapons merged. You get a single select for what is physically two players — there's no way to express "row 2's Nayo is user A, row 3's Nayo is user B".
3. Linking — the actual bug: the update sets userId on every matching row. Two rows on the same (match, mapIndex) both get the same userId → violates unique(tournamentMatchId, mapIndex, userId) → the transaction rolls back and the user gets a generic error. So for that name, linking doesn't degrade gracefully — it just fails.
The deeper issue is that the ambiguity is unresolvable in principle: ReportedWeapon rows don't retain scoreboard row position, and even if they did, row position isn't a stable identity across games — so there's no honest way to say which weapon belonged to which of the two players, in this game or the next.
How likely is it? True duplicates in one lobby are rare, but note the OCR angle: emberz name reading can snap two different names to the same string, which makes this more common than the "two players genuinely named the same" case suggests.
Options as I see them:
- A. Drop ambiguous rows at ingest time. When a scoreboard has two identical (non-empty) names on the same side, skip those rows (keep the other 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,
userId,
}: {
tournamentId: number;
ingestedInGameName: string;
ingestedTeamId: number | null;
userId: number;
}) {
return db.transaction().execute(async (trx) => {
const tournamentMatchIds = trx
.selectFrom("TournamentMatch")
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.select("TournamentMatch.id")
.where("TournamentStage.tournamentId", "=", tournamentId);
await trx
.deleteFrom("ReportedWeapon as ingested")
.where("ingested.userId", "is", null)
.where("ingested.ingestedInGameName", "=", ingestedInGameName)
.where((eb) =>
ingestedTeamId === null
? eb("ingested.ingestedTeamId", "is", null)
: eb("ingested.ingestedTeamId", "=", ingestedTeamId),
)
.where("ingested.tournamentMatchId", "in", tournamentMatchIds)
.where(({ exists, selectFrom }) =>
exists(
selectFrom("ReportedWeapon as own")
.select("own.mapIndex")
.whereRef(
"own.tournamentMatchId",
"=",
"ingested.tournamentMatchId",
)
.whereRef("own.mapIndex", "=", "ingested.mapIndex")
.where("own.userId", "=", userId)
.where("own.ingestedInGameName", "is", null),
),
)
.execute();
// xxx: why is this needed?
// rows from before same-side duplicate names were dropped at ingest time
// can still hold two rows on one (match, mapIndex); linking both would
// violate unique(tournamentMatchId, mapIndex, userId), so keep only one
await trx
.deleteFrom("ReportedWeapon")
.where(
sql`rowid`,
"in",
trx
.selectFrom("ReportedWeapon as ingested")
.select(sql`"ingested"."rowid"`.as("rowid"))
.where("ingested.userId", "is", null)
.where("ingested.ingestedInGameName", "=", ingestedInGameName)
.where((eb) =>
ingestedTeamId === null
? eb("ingested.ingestedTeamId", "is", null)
: eb("ingested.ingestedTeamId", "=", ingestedTeamId),
)
.where("ingested.tournamentMatchId", "in", tournamentMatchIds)
.where(({ exists, selectFrom }) =>
exists(
selectFrom("ReportedWeapon as other")
.select("other.mapIndex")
.whereRef(
"other.tournamentMatchId",
"=",
"ingested.tournamentMatchId",
)
.whereRef("other.mapIndex", "=", "ingested.mapIndex")
.whereRef(
"other.ingestedInGameName",
"=",
"ingested.ingestedInGameName",
)
.where("other.userId", "is", null)
.where(sql<boolean>`"other"."rowid" < "ingested"."rowid"`),
),
),
)
.execute();
const conflictingRow = await trx
.selectFrom("ReportedWeapon as ingested")
.select("ingested.mapIndex")
.where("ingested.userId", "is", null)
.where("ingested.ingestedInGameName", "=", ingestedInGameName)
.where((eb) =>
ingestedTeamId === null
? eb("ingested.ingestedTeamId", "is", null)
: eb("ingested.ingestedTeamId", "=", ingestedTeamId),
)
.where("ingested.tournamentMatchId", "in", tournamentMatchIds)
.where(({ exists, selectFrom }) =>
exists(
selectFrom("ReportedWeapon as own")
.select("own.mapIndex")
.whereRef(
"own.tournamentMatchId",
"=",
"ingested.tournamentMatchId",
)
.whereRef("own.mapIndex", "=", "ingested.mapIndex")
.where("own.userId", "=", userId),
),
)
.limit(1)
.executeTakeFirst();
if (conflictingRow) {
throw new IngestedLinkConflictError();
}
await trx
.updateTable("ReportedWeapon")
.set({ userId })
.where("ReportedWeapon.userId", "is", null)
.where("ReportedWeapon.ingestedInGameName", "=", ingestedInGameName)
.where((eb) =>
ingestedTeamId === null
? eb("ReportedWeapon.ingestedTeamId", "is", null)
: eb("ReportedWeapon.ingestedTeamId", "=", ingestedTeamId),
)
.where("ReportedWeapon.tournamentMatchId", "in", tournamentMatchIds)
.execute();
});
}

View File

@@ -0,0 +1,55 @@
import type { ActionFunction } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import {
badRequestIfFalsy,
canAccessLohiEndpoint,
parseBody,
} from "~/utils/remix.server";
import * as Scoreboards from "../core/Scoreboards";
import * as IngestRepository from "../IngestRepository.server";
import { ingestBodySchema } from "../ingest-schemas";
export const action: ActionFunction = async ({ request }) => {
const user = canAccessLohiEndpoint(request) ? null : requireUser();
const data = await parseBody({ request, schema: ingestBodySchema });
const povUserId = data.povUserId ?? user?.id ?? null;
const tournamentId = data.tournamentId ?? null;
if (povUserId) {
badRequestIfFalsy(await UserRepository.findLeanById(povUserId));
}
const tournamentStartTime = tournamentId
? badRequestIfFalsy(
await IngestRepository.tournamentStartTime(tournamentId),
)
: null;
const storedEventsCount = await IngestRepository.addEvents({
tournamentId,
povUserId,
submitterUserId: user?.id ?? null,
events: data.events,
});
let reportedWeaponsCount = 0;
if (tournamentId && tournamentStartTime && povUserId) {
const games = await IngestRepository.gamesPlayedByUserInTournament({
userId: povUserId,
tournamentId,
});
reportedWeaponsCount = await IngestRepository.addReportedWeapons(
Scoreboards.reportedWeaponRowsFromEvents({
events: data.events,
games,
// xxx: why createdAt here? makes no sense
createdAt: tournamentStartTime,
}),
);
}
return { storedEventsCount, reportedWeaponsCount };
};

View File

@@ -0,0 +1,204 @@
import { describe, expect, it } from "vitest";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import * as IngestedNames from "./IngestedNames";
const TEAM_A = 100;
const TEAM_B = 200;
function row({
name,
teamId = TEAM_A,
mapIndex = 0,
weaponSplId = 10 as MainWeaponId,
}: {
name: string;
teamId?: number | null;
mapIndex?: number;
weaponSplId?: MainWeaponId;
}) {
return {
ingestedInGameName: name,
ingestedTeamId: teamId,
mapIndex,
weaponSplId,
};
}
describe("unlinkedNameGroups", () => {
it("merges near-identical names that never appear in the same map", () => {
const groups = IngestedNames.unlinkedNameGroups([
row({ name: "Jrod_14", mapIndex: 0 }),
row({ name: "Jrodl4", mapIndex: 1, weaponSplId: 20 as MainWeaponId }),
row({ name: "Jrod_14", mapIndex: 2 }),
]);
expect(groups).toHaveLength(1);
expect(groups[0]!.primaryName).toBe("Jrod_14");
expect(groups[0]!.names.sort()).toEqual(["Jrod_14", "Jrodl4"]);
expect(groups[0]!.weapons).toEqual([10, 20]);
expect(groups[0]!.mapIndexes).toEqual([0, 1, 2]);
});
it("chains variants transitively", () => {
const groups = IngestedNames.unlinkedNameGroups([
row({ name: "くらうlに ★^", mapIndex: 0 }),
row({ name: "<らうrに ★¬", mapIndex: 1 }),
row({ name: "<らうιに ★¬", mapIndex: 2 }),
row({ name: "くらうιに ★¬", mapIndex: 3 }),
row({ name: "くらうlに ★^", mapIndex: 4 }),
]);
expect(groups).toHaveLength(1);
expect(groups[0]!.primaryName).toBe("くらうlに ★^");
expect(groups[0]!.names).toHaveLength(4);
});
it("does not merge similar names that appear in the same map", () => {
const groups = IngestedNames.unlinkedNameGroups([
row({ name: "Sami", mapIndex: 0 }),
row({ name: "Samu", mapIndex: 0 }),
]);
expect(groups).toHaveLength(2);
});
it("leaves the whole cluster unmerged when a transitive merge is contradicted by a shared map", () => {
const groups = IngestedNames.unlinkedNameGroups([
row({ name: "player1", mapIndex: 0 }),
row({ name: "playerl", mapIndex: 0 }),
row({ name: "playerI", mapIndex: 1 }),
]);
expect(groups).toHaveLength(3);
});
it("does not merge dissimilar names", () => {
const groups = IngestedNames.unlinkedNameGroups([
row({ name: "Bocchi", mapIndex: 0 }),
row({ name: "have faith", mapIndex: 1 }),
]);
expect(groups).toHaveLength(2);
});
it("is stricter with short names", () => {
const groups = IngestedNames.unlinkedNameGroups([
row({ name: "Eli", mapIndex: 0 }),
row({ name: "Ala", mapIndex: 1 }),
]);
expect(groups).toHaveLength(2);
});
it("does not merge across teams", () => {
const groups = IngestedNames.unlinkedNameGroups([
row({ name: "Jrod_14", teamId: TEAM_A, mapIndex: 0 }),
row({ name: "Jrodl4", teamId: TEAM_B, mapIndex: 1 }),
]);
expect(groups).toHaveLength(2);
});
});
describe("preselectedUserIdByGroup", () => {
function player({
id,
teamId = TEAM_A,
inGameName = null,
}: {
id: number;
teamId?: number;
inGameName?: string | null;
}) {
return { id, tournamentTeamId: teamId, inGameName };
}
function groupsOf(rows: Parameters<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

@@ -0,0 +1,294 @@
import type { MainWeaponId } from "~/modules/in-game-lists/types";
/**
* Max edit distance between two normalized names for them to be considered
* OCR variants of the same name. Short names get a tighter budget so e.g.
* two different 4-letter names don't collapse into one.
*/
const FUZZY_DISTANCE_LONG = 2;
const FUZZY_DISTANCE_SHORT = 1;
const FUZZY_SHORT_NAME_LENGTH = 6;
interface UnlinkedIngestedRow {
ingestedInGameName: string;
ingestedTeamId: number | null;
mapIndex: number;
weaponSplId: MainWeaponId;
}
export interface IngestedNameGroup {
/** the variant detected in the most maps, shown in the UI */
primaryName: string;
/** every detected spelling belonging to this group, `primaryName` included */
names: string[];
ingestedTeamId: number | null;
weapons: MainWeaponId[];
/** 0-based indexes of the match's games this name has weapon rows for */
mapIndexes: number[];
}
interface LinkablePlayer {
id: number;
tournamentTeamId: number;
inGameName: string | null;
}
/**
* Groups unlinked ingested weapon rows into one entry per (likely) player.
* Two detected names on the same team are merged when they are near-identical
* strings AND never appear in the same map — different maps reading the same
* splash tag slightly differently. If a merge candidate group turns out
* ambiguous (two of its names appear in the same map, meaning they must be
* different players) the whole group is left unmerged for the user to decide.
*/
export function unlinkedNameGroups(
rows: UnlinkedIngestedRow[],
): IngestedNameGroup[] {
const aggregates = aggregateByName(rows);
const byTeam = new Map<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

@@ -0,0 +1,345 @@
import { describe, expect, it } from "vitest";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { IngestedEventInput } from "../ingest-schemas";
import * as Scoreboards from "./Scoreboards";
const WINNER_TEAM_ID = 100;
const LOSER_TEAM_ID = 200;
function testGame(
partial: Partial<Scoreboards.IngestableGame> = {},
): Scoreboards.IngestableGame {
return {
tournamentMatchId: 1,
mapIndex: 0,
mode: "SZ",
stageId: 0 as StageId,
winnerTeamId: WINNER_TEAM_ID,
loserTeamId: LOSER_TEAM_ID,
winnerInGameNames: [],
loserInGameNames: [],
playedAt: 1000,
...partial,
};
}
function testScoreboard({
t = 60,
mode = "Splat Zones",
stage = "Scorch Gorge",
lobby = "Private Battle",
names = ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"],
weapons = ["10", "10", "10", "10", "20", "20", "20", "20"],
}: {
t?: number;
mode?: string | null;
stage?: string | null;
lobby?: string | null;
names?: string[];
weapons?: string[];
} = {}): IngestedEventInput {
return {
type: "Scoreboard",
t,
confidence: 0.9,
data: {
lobby,
mode,
stage,
scores: [100, 52],
players: names.map((name, i) => ({
name,
weapon: weapons[i]!,
paint: 1000,
ka: 10,
d: 5,
s: 2,
})),
},
};
}
describe("reportedWeaponRowsFromEvents", () => {
it("fills weapons for all 8 players of a matching game", () => {
const rows = Scoreboards.reportedWeaponRowsFromEvents({
events: [testScoreboard()],
games: [testGame()],
createdAt: 123,
});
expect(rows).toHaveLength(8);
expect(rows[0]).toEqual({
tournamentMatchId: 1,
mapIndex: 0,
weaponSplId: 10,
ingestedInGameName: "w1",
ingestedTeamId: WINNER_TEAM_ID,
createdAt: 123,
});
});
it("assigns the winning side to the game's winner team and the losing side to the loser team", () => {
const rows = Scoreboards.reportedWeaponRowsFromEvents({
events: [testScoreboard()],
games: [testGame()],
createdAt: 123,
});
expect(
rows
.filter((row) => row.ingestedTeamId === WINNER_TEAM_ID)
.map((row) => row.ingestedInGameName),
).toEqual(["w1", "w2", "w3", "w4"]);
expect(
rows
.filter((row) => row.ingestedTeamId === LOSER_TEAM_ID)
.map((row) => row.ingestedInGameName),
).toEqual(["l1", "l2", "l3", "l4"]);
});
it("matches scoreboards to games by mode and stage", () => {
const rows = Scoreboards.reportedWeaponRowsFromEvents({
events: [
testScoreboard({ mode: "Rainmaker", stage: "Eeltail Alley", t: 60 }),
],
games: [
testGame({ mapIndex: 0, mode: "SZ", stageId: 0 as StageId }),
testGame({ mapIndex: 1, mode: "RM", stageId: 1 as StageId }),
],
createdAt: 123,
});
expect(new Set(rows.map((row) => row.mapIndex))).toEqual(new Set([1]));
});
it("assigns two games on the same mode and stage in chronological order", () => {
const rows = Scoreboards.reportedWeaponRowsFromEvents({
events: [
testScoreboard({
t: 60,
names: ["a", "b", "c", "d", "e", "f", "g", "h"],
}),
testScoreboard({
t: 5000,
names: ["i", "j", "k", "l", "m", "n", "o", "p"],
}),
],
games: [
testGame({ tournamentMatchId: 1, playedAt: 1000 }),
testGame({ tournamentMatchId: 2, playedAt: 2000 }),
],
createdAt: 123,
});
expect(
rows.find((row) => row.ingestedInGameName === "a")?.tournamentMatchId,
).toBe(1);
expect(
rows.find((row) => row.ingestedInGameName === "i")?.tournamentMatchId,
).toBe(2);
});
it("skips duplicate detections of the same scoreboard", () => {
const rows = Scoreboards.reportedWeaponRowsFromEvents({
events: [testScoreboard({ t: 60 }), testScoreboard({ t: 65 })],
games: [
testGame({ tournamentMatchId: 1, playedAt: 1000 }),
testGame({ tournamentMatchId: 2, playedAt: 2000 }),
],
createdAt: 123,
});
expect(rows).toHaveLength(8);
expect(rows[0]!.tournamentMatchId).toBe(1);
});
it("skips scoreboards from other lobbies", () => {
const rows = Scoreboards.reportedWeaponRowsFromEvents({
events: [testScoreboard({ lobby: "X Battle" })],
games: [testGame()],
createdAt: 123,
});
expect(rows).toHaveLength(0);
});
it("skips scoreboards with unreadable mode or stage", () => {
const rows = Scoreboards.reportedWeaponRowsFromEvents({
events: [
testScoreboard({ mode: null }),
testScoreboard({ stage: "Not A Stage" }),
],
games: [testGame()],
createdAt: 123,
});
expect(rows).toHaveLength(0);
});
it("skips players with unknown weapon or empty name", () => {
const rows = Scoreboards.reportedWeaponRowsFromEvents({
events: [
testScoreboard({
names: ["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"],
weapons: ["10", "10", "unknown", "10", "20", "20", "20", "20"],
}),
],
games: [testGame()],
createdAt: 123,
});
expect(rows).toHaveLength(6);
expect(rows.some((row) => row.ingestedInGameName === "w3")).toBe(false);
});
it("skips non-scoreboard events", () => {
const rows = Scoreboards.reportedWeaponRowsFromEvents({
events: [
{
type: "MapStart",
t: 10,
confidence: 0.9,
data: { mode: "Splat Zones", stage: "Scorch Gorge" },
},
],
games: [testGame()],
createdAt: 123,
});
expect(rows).toHaveLength(0);
});
it("skips scoreboards that have no matching game left", () => {
const rows = Scoreboards.reportedWeaponRowsFromEvents({
events: [
testScoreboard({ t: 60 }),
testScoreboard({
t: 5000,
names: ["i", "j", "k", "l", "m", "n", "o", "p"],
}),
],
games: [testGame()],
createdAt: 123,
});
expect(rows).toHaveLength(8);
});
it("uses ScoreboardReplay events too", () => {
const scoreboard = testScoreboard();
const rows = Scoreboards.reportedWeaponRowsFromEvents({
events: [
{
...scoreboard,
type: "ScoreboardReplay",
data: {
...(scoreboard.data as Extract<
IngestedEventInput,
{ type: "Scoreboard" }
>["data"]),
timestamp: "3/7/2026 22:28",
replayCode: "ABCD-EFGH-IJKL-MNOP",
matchScores: [100, 52],
},
},
],
games: [testGame()],
createdAt: 123,
});
expect(rows).toHaveLength(8);
});
it("skips a game whose known rosters contradict the scoreboard sides", () => {
const rows = Scoreboards.reportedWeaponRowsFromEvents({
events: [testScoreboard()],
games: [
testGame({
tournamentMatchId: 1,
// scoreboard winners are w1-w4 but this game was won by the l* players
winnerInGameNames: ["l1#1234", "l2"],
loserInGameNames: ["w1", "w2"],
playedAt: 1000,
}),
testGame({
tournamentMatchId: 2,
winnerInGameNames: ["w1", "w2"],
loserInGameNames: ["l1#1234", "l2"],
playedAt: 2000,
}),
],
createdAt: 123,
});
expect(rows.map((row) => row.tournamentMatchId)).not.toContain(1);
expect(rows.filter((row) => row.tournamentMatchId === 2)).toHaveLength(8);
});
it("matches known in-game names ignoring discriminator, case and unicode width", () => {
const rows = Scoreboards.reportedWeaponRowsFromEvents({
events: [
testScoreboard({
names: ["", "w2", "w3", "w4", "l1", "l2", "l3", "l4"],
}),
],
games: [
testGame({
winnerInGameNames: ["w1#1234"],
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);
});
it("skips players whose name appears twice on the same side", () => {
const rows = Scoreboards.reportedWeaponRowsFromEvents({
events: [
testScoreboard({
names: ["dupe", "dupe", "w3", "w4", "l1", "l2", "l3", "dupe"],
}),
],
games: [testGame()],
createdAt: 123,
});
expect(
rows.filter((row) => row.ingestedInGameName === "dupe"),
).toHaveLength(1);
expect(
rows.find((row) => row.ingestedInGameName === "dupe")?.ingestedTeamId,
).toBe(LOSER_TEAM_ID);
expect(rows).toHaveLength(6);
});
it("does not assign a game played before the previously assigned one", () => {
const rows = Scoreboards.reportedWeaponRowsFromEvents({
events: [
testScoreboard({ t: 60, mode: "Rainmaker", stage: "Eeltail Alley" }),
testScoreboard({ t: 1000, mode: "Splat Zones", stage: "Scorch Gorge" }),
],
games: [
testGame({
tournamentMatchId: 1,
mode: "SZ",
stageId: 0 as StageId,
playedAt: 1000,
}),
testGame({
tournamentMatchId: 2,
mode: "RM" as ModeShort,
stageId: 1 as StageId,
playedAt: 2000,
}),
],
createdAt: 123,
});
expect(rows.map((row) => row.tournamentMatchId)).not.toContain(1);
expect(rows.filter((row) => row.tournamentMatchId === 2)).toHaveLength(8);
});
});

View File

@@ -0,0 +1,235 @@
import type { Tables } from "~/db/tables";
import { modesShort } from "~/modules/in-game-lists/modes";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import gameMisc from "../../../../locales/en/game-misc.json";
import type {
IngestedEventInput,
ScoreboardEventInput,
} from "../ingest-schemas";
const STAGE_ID_BY_ENGLISH_NAME = new Map<string, StageId>(
stageIds.map((stageId) => [
(gameMisc as Record<string, string>)[`STAGE_${stageId}`]!,
stageId,
]),
);
const MODE_SHORT_BY_ENGLISH_NAME = new Map<string, ModeShort>(
modesShort.map((modeShort) => [
(gameMisc as Record<string, string>)[`MODE_LONG_${modeShort}`]!,
modeShort,
]),
);
const MAIN_WEAPON_IDS: ReadonlySet<number> = new Set(mainWeaponIds);
/** Lobby header value scoreboards of tournament games are expected to have. */
const TOURNAMENT_LOBBY = "Private Battle";
/**
* Two scoreboards this close in the source video with identical contents are
* considered duplicate detections of the same game.
*/
const DUPLICATE_SCOREBOARD_WINDOW_SECONDS = 300;
/** How many players on the winning (first) resp. losing side of a scoreboard. */
const PLAYERS_PER_TEAM = 4;
/** A game of a tournament match that ingested scoreboards can be matched against. */
export interface IngestableGame {
tournamentMatchId: number;
/** 0-based index of the game within its match */
mapIndex: number;
mode: ModeShort;
stageId: StageId;
winnerTeamId: number;
loserTeamId: number | null;
/** known in-game names of the winning team's roster, used to validate scoreboard sides */
winnerInGameNames: string[];
/** known in-game names of the losing team's roster, used to validate scoreboard sides */
loserInGameNames: string[];
/** database timestamp used to order games chronologically across matches */
playedAt: number;
}
export type IngestedWeaponRow = Pick<
Tables["ReportedWeapon"],
"tournamentMatchId" | "mapIndex" | "weaponSplId" | "createdAt"
> & {
ingestedInGameName: string;
ingestedTeamId: number | null;
};
/**
* Matches scoreboard events against the games the POV user played and turns
* them into insertable ReportedWeapon rows (identified by in-game name, not
* user id).
*
* Events and games are both walked in chronological order: each scoreboard is
* assigned to the next not-yet-assigned game with the same mode and stage
* whose sides don't contradict the teams' known in-game names (the winning
* scoreboard rows should overlap the game winner's roster, not the loser's).
* Scoreboards from other lobbies, with unreadable mode/stage or duplicated
* detections of the same game are skipped.
*/
export function reportedWeaponRowsFromEvents({
events,
games,
createdAt,
}: {
events: IngestedEventInput[];
games: IngestableGame[];
createdAt: number;
}): IngestedWeaponRow[] {
const scoreboards = dedupeScoreboards(
events
.filter(isScoreboardEvent)
.filter(
(event) => !event.data.lobby || event.data.lobby === TOURNAMENT_LOBBY,
)
.sort((a, b) => a.t - b.t),
);
const orderedGames = games.toSorted(
(a, b) => a.playedAt - b.playedAt || a.mapIndex - b.mapIndex,
);
const rows: IngestedWeaponRow[] = [];
let nextGameIdx = 0;
for (const scoreboard of scoreboards) {
const mode = scoreboard.data.mode
? MODE_SHORT_BY_ENGLISH_NAME.get(scoreboard.data.mode)
: undefined;
const stageId = scoreboard.data.stage
? STAGE_ID_BY_ENGLISH_NAME.get(scoreboard.data.stage)
: undefined;
if (mode === undefined || stageId === undefined) continue;
for (let i = nextGameIdx; i < orderedGames.length; i++) {
const game = orderedGames[i]!;
if (game.mode !== mode || game.stageId !== stageId) continue;
if (!sidesMatchKnownPlayers(scoreboard, game)) continue;
rows.push(...scoreboardToWeaponRows({ scoreboard, game, createdAt }));
nextGameIdx = i + 1;
break;
}
}
return rows;
}
function isScoreboardEvent(
event: IngestedEventInput,
): event is ScoreboardEventInput {
return event.type === "Scoreboard" || event.type === "ScoreboardReplay";
}
function dedupeScoreboards(sorted: ScoreboardEventInput[]) {
const result: ScoreboardEventInput[] = [];
for (const scoreboard of sorted) {
const isDuplicate = result.some(
(other) =>
Math.abs(other.t - scoreboard.t) <=
DUPLICATE_SCOREBOARD_WINDOW_SECONDS &&
other.data.mode === scoreboard.data.mode &&
other.data.stage === scoreboard.data.stage &&
other.data.players.every(
(player, i) => player.name === scoreboard.data.players[i]!.name,
),
);
if (!isDuplicate) result.push(scoreboard);
}
return result;
}
/**
* Checks that the scoreboard's sides don't contradict the teams' known
* rosters: the winning rows should overlap the game winner's in-game names at
* least as well as the losing team's (and vice versa). A contradiction means
* the scoreboard belongs to some other game. No overlap at all (e.g. no
* in-game names set) counts as a pass.
*/
function sidesMatchKnownPlayers(
scoreboard: ScoreboardEventInput,
game: IngestableGame,
) {
const winnerSide = scoreboard.data.players
.slice(0, PLAYERS_PER_TEAM)
.map((player) => normalizeInGameName(player.name));
const loserSide = scoreboard.data.players
.slice(PLAYERS_PER_TEAM)
.map((player) => normalizeInGameName(player.name));
const knownWinners = game.winnerInGameNames.map(normalizeInGameName);
const knownLosers = game.loserInGameNames.map(normalizeInGameName);
const straight =
nameOverlap(winnerSide, knownWinners) + nameOverlap(loserSide, knownLosers);
const flipped =
nameOverlap(winnerSide, knownLosers) + nameOverlap(loserSide, knownWinners);
return straight >= flipped;
}
function nameOverlap(names: string[], knownNames: string[]) {
const known = new Set(knownNames.filter(Boolean));
return names.filter((name) => name && known.has(name)).length;
}
function normalizeInGameName(name: string) {
return name.split("#")[0]!.normalize("NFKC").trim().toLowerCase();
}
function scoreboardToWeaponRows({
scoreboard,
game,
createdAt,
}: {
scoreboard: ScoreboardEventInput;
game: IngestableGame;
createdAt: number;
}): IngestedWeaponRow[] {
const rows: IngestedWeaponRow[] = [];
const sideNameCounts = new Map<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);
}
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;
}

View File

@@ -0,0 +1,55 @@
type MiddlewareArgs = {
request: Request;
context: unknown;
};
type MiddlewareFn = (
args: MiddlewareArgs,
next: () => Promise<Response>,
) => Promise<Response>;
const ALLOWED_ORIGIN_PATTERNS = [
/^https:\/\/emberz\.sendou\.ink$/,
/^http:\/\/localhost:\d+$/,
];
export const ingestCorsMiddleware: MiddlewareFn = async ({ request }, next) => {
const headers = corsHeaders(request.headers.get("Origin"));
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: headers ?? undefined });
}
const response = await next();
if (!headers) return response;
const newHeaders = new Headers(response.headers);
for (const [key, value] of Object.entries(headers)) {
newHeaders.set(key, value);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
};
function corsHeaders(origin: string | null) {
if (
!origin ||
!ALLOWED_ORIGIN_PATTERNS.some((pattern) => pattern.test(origin))
) {
return null;
}
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "POST, OPTIONS",
// xxx: we will also need to allow auth header eventually?
"Access-Control-Allow-Headers": "Content-Type, Lohi-Token",
"Access-Control-Max-Age": "86400",
Vary: "Origin",
};
}

View File

@@ -0,0 +1,88 @@
import { z } from "zod";
import { id } from "~/utils/zod";
// xxx: we can make these more strict e.g. validate is a proper mode etc.
const INGEST_MAX_EVENTS_PER_REQUEST = 1000;
const detectionText = z.string().max(500);
const scoreboardPlayerSchema = z.object({
name: detectionText,
weapon: detectionText,
paint: z.number().nullable(),
ka: z.number().nullable(),
d: z.number().nullable(),
s: z.number().nullable(),
});
const scoreboardDataSchema = z.object({
lobby: detectionText.nullable(),
mode: detectionText.nullable(),
stage: detectionText.nullable(),
scores: z.tuple([z.number().nullable(), z.number().nullable()]),
players: z.array(scoreboardPlayerSchema).length(8),
});
const scoreboardReplayDataSchema = scoreboardDataSchema.extend({
timestamp: detectionText.nullable(),
replayCode: detectionText.nullable(),
matchScores: z.tuple([z.number().nullable(), z.number().nullable()]),
});
const deathDataSchema = z.object({
weapon: detectionText.nullable(),
weaponId: detectionText.nullable(),
weaponType: z.enum(["MAIN", "SUB", "SPECIAL"]).nullable(),
abilities: z.array(z.array(detectionText)),
name: detectionText.nullable(),
});
const mapStartDataSchema = z.object({
mode: detectionText.nullable(),
stage: detectionText.nullable(),
});
const eventBaseSchema = z.object({
/** seconds into the stream/video the event was detected at */
t: z.number().min(0),
/** wall-clock timestamp (ms) of the detection */
detectedAt: z.number().int().positive().optional(),
confidence: z.number().min(0).max(1),
});
const ingestedEventSchema = z.discriminatedUnion("type", [
eventBaseSchema.extend({
type: z.literal("Scoreboard"),
data: scoreboardDataSchema,
}),
eventBaseSchema.extend({
type: z.literal("ScoreboardReplay"),
data: scoreboardReplayDataSchema,
}),
eventBaseSchema.extend({
type: z.literal("Death"),
data: deathDataSchema,
}),
eventBaseSchema.extend({
type: z.literal("MapStart"),
data: mapStartDataSchema,
}),
]);
export const ingestBodySchema = z.object({
/** the user whose point of view the events were detected from */
povUserId: id.optional(),
tournamentId: id.optional(),
events: z
.array(ingestedEventSchema)
.min(1)
.max(INGEST_MAX_EVENTS_PER_REQUEST),
});
export type IngestedEventInput = z.infer<typeof ingestedEventSchema>;
export type IngestedEventData = IngestedEventInput["data"];
export type ScoreboardEventInput = Extract<
IngestedEventInput,
{ type: "Scoreboard" | "ScoreboardReplay" }
>;

View File

@@ -0,0 +1,6 @@
import { ingestCorsMiddleware } from "../ingest-cors-middleware.server";
import type { Route } from "./+types/ingest";
export { action } from "../actions/ingest.server";
export const middleware: Route.MiddlewareFunction[] = [ingestCorsMiddleware];

View File

@@ -101,9 +101,10 @@ export async function findByMatchId(matchId: number) {
"ReportedWeapon.userId",
])
.where("ReportedWeapon.groupMatchId", "=", matchId)
.where("ReportedWeapon.userId", "is not", null)
.orderBy("ReportedWeapon.mapIndex", "asc")
.orderBy("ReportedWeapon.userId", "asc")
.$narrowType<{ groupMatchId: NotNull }>()
.$narrowType<{ groupMatchId: NotNull; userId: NotNull }>()
.execute();
if (rows.length === 0) return null;
@@ -183,9 +184,14 @@ export async function findByTournamentMatchId(matchId: number) {
"ReportedWeapon.userId",
])
.where("ReportedWeapon.tournamentMatchId", "=", matchId)
.where("ReportedWeapon.userId", "is not", null)
.orderBy("ReportedWeapon.mapIndex", "asc")
.orderBy("ReportedWeapon.userId", "asc")
.$narrowType<{ tournamentMatchId: NotNull; mapIndex: NotNull }>()
.$narrowType<{
tournamentMatchId: NotNull;
mapIndex: NotNull;
userId: NotNull;
}>()
.execute();
if (rows.length === 0) return null;

View File

@@ -127,7 +127,7 @@ export abstract class Bracket {
try {
const manager = getTournamentManager();
manager.import(this.data);
manager.importData(this.data);
const teamOrder = this.teamOrderForSimulation();

View File

@@ -112,6 +112,19 @@ export const matchSchema = z.union([
_action: _action("UNDO_WEAPON_REPORT"),
mapIndex: z.coerce.number().int().nonnegative(),
}),
z.object({
_action: _action("LINK_INGESTED_USERS"),
links: z
.array(
z.object({
ingestedInGameName: z.string().min(1).max(500),
ingestedTeamId: id.nullable(),
userId: id,
}),
)
.min(1)
.max(TOURNAMENT.INGESTED_USER_LINKS_MAX),
}),
]);
export const bracketIdx = z.coerce.number().int().min(0).max(100);

View File

@@ -3,6 +3,7 @@ import { sql } from "~/db/sql";
import { TournamentMatchStatus } from "~/db/tables";
import { requireUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import * as IngestRepository from "~/features/ingest/IngestRepository.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
@@ -28,6 +29,7 @@ import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import {
errorToast,
errorToastIfFalsy,
notFoundIfFalsy,
parseParams,
@@ -867,6 +869,68 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
case "LINK_INGESTED_USERS": {
const isMemberOfATeamInTheMatch = match.players.some(
(p) => p.id === user.id,
);
errorToastIfFalsy(
isMemberOfATeamInTheMatch || tournament.isOrganizer(user),
"Unauthorized",
);
const ingestedWeapons =
await IngestRepository.findIngestedWeaponsByTournamentMatchId(matchId);
const setParticipantUserIds = new Set(
(await TournamentMatchRepository.findResultsByMatchId(matchId)).flatMap(
(result) =>
result.participants.map((participant) => participant.userId),
),
);
for (const link of data.links) {
const player = match.players.find((p) => p.id === link.userId);
errorToastIfFalsy(player, "User is not in the match");
errorToastIfFalsy(
setParticipantUserIds.size === 0 ||
setParticipantUserIds.has(link.userId),
"User did not play in the match",
);
errorToastIfFalsy(
link.ingestedTeamId === null ||
player.tournamentTeamId === link.ingestedTeamId,
"User is not a member of the team",
);
errorToastIfFalsy(
ingestedWeapons.some(
(w) =>
w.userId === null &&
w.ingestedInGameName === link.ingestedInGameName &&
w.ingestedTeamId === link.ingestedTeamId,
),
"Unknown ingested in-game name",
);
}
for (const link of data.links) {
try {
await IngestRepository.linkIngestedUser({
tournamentId,
ingestedInGameName: link.ingestedInGameName,
ingestedTeamId: link.ingestedTeamId,
userId: link.userId,
});
} catch (error) {
if (error instanceof IngestRepository.IngestedLinkConflictError) {
errorToast(
`Could not link "${link.ingestedInGameName}": the selected user already has a weapon on one of the games`,
);
}
throw error;
}
}
break;
}
default: {
assertUnreachable(data);
}

View File

@@ -0,0 +1,55 @@
.teamName {
font-size: var(--font-sm);
color: var(--color-text-second);
border-bottom: var(--border-style);
padding-block-end: var(--s-1);
}
.memberRow {
display: flex;
align-items: center;
gap: var(--s-2);
font-size: var(--font-sm);
}
.memberName {
font-weight: var(--weight-semi);
}
.memberInGameName {
color: var(--color-text-second);
}
.ingestedRow {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
align-items: center;
gap: var(--s-3);
}
.ingestedInfo {
display: flex;
align-items: center;
gap: var(--s-2);
min-width: 0;
}
.ingestedName {
font-weight: var(--weight-semi);
font-size: var(--font-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.variantCount {
color: var(--color-text-second);
font-weight: var(--weight-body);
font-size: var(--font-xs);
margin-inline-start: var(--s-1);
}
.ingestedWeapons {
display: flex;
gap: var(--s-1);
}

View File

@@ -0,0 +1,270 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
import { WeaponImage } from "~/components/Image";
import { WeaponPool } from "~/components/match-page/WeaponPool";
import { useUser } from "~/features/auth/core/user";
import * as IngestedNames from "~/features/ingest/core/IngestedNames";
import { useTournament } from "~/features/tournament/routes/to.$id";
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
import styles from "./TournamentMatchIngestedUsers.module.css";
// xxx: do we really need linking ingested users yet?
// xxx: clear buttons overlap
export function TournamentMatchIngestedUsers({
data,
}: {
data: TournamentMatchLoaderData;
}) {
const { t } = useTranslation(["tournament"]);
const user = useUser();
const tournament = useTournament();
const unlinkedGroups = IngestedNames.unlinkedNameGroups(
data.ingestedWeapons.filter((weapon) => weapon.userId === null),
);
const canLink =
data.match.players.some((p) => p.id === user?.id) ||
tournament.isOrganizer(user);
if (unlinkedGroups.length === 0 || !canLink) return null;
return (
<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

@@ -7,6 +7,7 @@ import type {
TimelineMap,
TimelinePickBanEvent,
} from "~/components/match-page/MatchTimeline";
import type { WeaponPoolWeapon } from "~/components/match-page/WeaponPool";
import { useUser } from "~/features/auth/core/user";
import { useTournament } from "~/features/tournament/routes/to.$id";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
@@ -18,6 +19,7 @@ import { type MatchPageTeam, useMatch } from "../match-page-context";
import { TournamentMatchActionPickBanTab } from "./TournamentMatchActionPickBanTab";
import { TournamentMatchActionTab } from "./TournamentMatchActionTab";
import { TournamentMatchAdminTab } from "./TournamentMatchAdminTab";
import { TournamentMatchIngestedUsers } from "./TournamentMatchIngestedUsers";
export function TournamentMatchTabs({
data,
@@ -82,7 +84,9 @@ export function TournamentMatchTabs({
maps={timelineMaps}
pickBanRowsBySlot={pickBanData?.rowsBySlot}
isOngoing={!data.matchIsOver && data.results.length > 0}
/>
>
<TournamentMatchIngestedUsers data={data} />
</MatchResultTab>
) : null}
<TournamentMatchRosterTab data={data} />
{tabs.includes(TAB_KEYS.ACTION) ? (
@@ -164,8 +168,31 @@ function resolveTimelineMaps(
(w) => w.mapIndex === mapIndex && w.userId === userId,
)?.weaponSplId ?? null;
const alphaWeapons = alphaRoster.map((u) => weaponFor(u.id));
const bravoWeapons = bravoRoster.map((u) => weaponFor(u.id));
const weaponsFor = (
roster: ReturnType<typeof resolveRoster>,
tournamentTeamId: number,
): WeaponPoolWeapon[] => {
const unlinkedIngested = data.ingestedWeapons.filter(
(w) =>
w.mapIndex === mapIndex &&
w.userId === null &&
w.ingestedTeamId === tournamentTeamId,
);
let unlinkedIdx = 0;
return roster.map((u) => {
const linked = weaponFor(u.id);
if (linked !== null) return linked;
const ingested = unlinkedIngested[unlinkedIdx++];
return ingested
? { weaponSplId: ingested.weaponSplId, unverified: true }
: null;
});
};
const alphaWeapons = weaponsFor(alphaRoster, opponentOneId);
const bravoWeapons = weaponsFor(bravoRoster, opponentTwoId);
const hasAnyWeapon =
alphaWeapons.some((w) => w !== null) ||
bravoWeapons.some((w) => w !== null);

View File

@@ -3,6 +3,7 @@ import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { chatAccessible } from "~/features/chat/chat-utils";
import * as IngestRepository from "~/features/ingest/IngestRepository.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
@@ -60,6 +61,12 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
const reportedWeapons =
await ReportedWeaponRepository.findByTournamentMatchId(matchId);
const ingestedWeapons =
await IngestRepository.findIngestedWeaponsByTournamentMatchId(matchId);
const ingestedWeaponPools = ingestedWeapons.some((w) => w.userId === null)
? await UserRepository.weaponPoolsByUserIds(match.players.map((p) => p.id))
: [];
const matchIsOver =
match.opponentOne?.result === "win" || match.opponentTwo?.result === "win";
@@ -228,6 +235,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
match: hasPermsToSeeChat ? match : { ...match, chatCode: undefined },
results,
reportedWeapons,
ingestedWeapons,
ingestedWeaponPools,
mapList,
matchIsOver,
endedEarly,

View File

@@ -12,6 +12,7 @@ export const TOURNAMENT = {
MAX_GROUP_SIZE: 6,
MAX_BRACKETS_PER_TOURNAMENT: 10,
BRACKET_NAME_MAX_LENGTH: 32,
INGESTED_USER_LINKS_MAX: 32,
PLACEMENT_MAX: 100,
// just a fallback, normally this should be set by user explicitly
RR_DEFAULT_TEAM_COUNT_PER_GROUP: 4,

View File

@@ -1398,3 +1398,20 @@ export function weaponPoolByUserId(userId: number) {
.orderBy("UserWeaponPool.sortOrder", "asc")
.execute();
}
/** Returns weapon pool entries for the given users. */
export function weaponPoolsByUserIds(userIds: number[]) {
if (userIds.length === 0) return Promise.resolve([]);
return db
.selectFrom("UserWeaponPool")
.select([
"UserWeaponPool.userId",
"UserWeaponPool.weaponSplId",
"UserWeaponPool.isFavorite",
])
.where("UserWeaponPool.userId", "in", userIds)
.orderBy("UserWeaponPool.userId", "asc")
.orderBy("UserWeaponPool.sortOrder", "asc")
.execute();
}

View File

@@ -69,7 +69,7 @@ export class BracketsManager {
* @param data Data to import.
* @param normalizeIds Enable ID normalization: all IDs (and references to them) are remapped to consecutive IDs starting from 0.
*/
public import(rawData: Database, normalizeIds = false): void {
public importData(rawData: Database, normalizeIds = false): void {
const data = normalizeIds ? helpers.normalizeIds(rawData) : rawData;
if (!this.storage.delete("stage"))

View File

@@ -358,7 +358,7 @@ describe("Import / export", () => {
expect(storage.select<any>("match", 0).opponent1.result).toBe("win");
expect(storage.select<any>("match", 1).opponent1.result).toBe("win");
manager.import(initialData);
manager.importData(initialData);
expect(storage.select<any>("match", 0).opponent1.result).toBe(undefined);
expect(storage.select<any>("match", 1).opponent1.result).toBe(undefined);

View File

@@ -329,6 +329,8 @@ export default [
route("/seed", "features/api-private/routes/seed.ts"),
route("/users", "features/api-private/routes/users.ts"),
route("/ingest", "features/ingest/routes/ingest.ts"),
layout("features/api-public/routes/api.layout.tsx", [
...prefix("/api", [
route(

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,6 +179,11 @@
"match.endSet.selectWinner": "",
"match.endSet.randomWinner": "",
"match.deadline.explanation": "",
"match.ingest.showDialogButton": "",
"match.ingest.title": "",
"match.ingest.explanation": "",
"match.ingest.unknownTeam": "",
"match.ingest.selectUser": "",
"match.admin.cast": "",
"match.admin.castInfo": "",
"match.admin.castConfigureHint": "",

View File

@@ -179,6 +179,11 @@
"match.endSet.selectWinner": "",
"match.endSet.randomWinner": "",
"match.deadline.explanation": "",
"match.ingest.showDialogButton": "",
"match.ingest.title": "",
"match.ingest.explanation": "",
"match.ingest.unknownTeam": "",
"match.ingest.selectUser": "",
"match.admin.cast": "",
"match.admin.castInfo": "",
"match.admin.castConfigureHint": "",

View File

@@ -179,6 +179,11 @@
"match.endSet.selectWinner": "Select Winner",
"match.endSet.randomWinner": "Random (50/50)",
"match.deadline.explanation": "Please let tournament organizers know about any delays. Matches that go past their deadline may be ended early. Consult tournament rules for details.",
"match.ingest.showDialogButton": "Match Ingested Users",
"match.ingest.title": "Match ingested users",
"match.ingest.explanation": "Weapons were ingested from a scoreboard recording but some in-game names could not be connected to a sendou.ink user. Connect them below to show the weapons in the timeline.",
"match.ingest.unknownTeam": "Unknown team",
"match.ingest.selectUser": "Select user",
"match.admin.cast": "Cast",
"match.admin.castInfo": "Select the Twitch account that is currently casting this match. It is then indicated in the bracket view.",
"match.admin.castConfigureHint": "Configure streaming channels on the tournament admin page to enable casting.",

View File

@@ -181,6 +181,11 @@
"match.endSet.selectWinner": "Seleccionar ganador",
"match.endSet.randomWinner": "Aleatorio (50/50)",
"match.deadline.explanation": "Informa a los organizadores del torneo sobre cualquier retraso. Los partidos que superen su límite de tiempo pueden terminarse anticipadamente. Consulta las reglas del torneo para más detalles.",
"match.ingest.showDialogButton": "",
"match.ingest.title": "",
"match.ingest.explanation": "",
"match.ingest.unknownTeam": "",
"match.ingest.selectUser": "",
"match.admin.cast": "",
"match.admin.castInfo": "",
"match.admin.castConfigureHint": "",

View File

@@ -181,6 +181,11 @@
"match.endSet.selectWinner": "",
"match.endSet.randomWinner": "",
"match.deadline.explanation": "",
"match.ingest.showDialogButton": "",
"match.ingest.title": "",
"match.ingest.explanation": "",
"match.ingest.unknownTeam": "",
"match.ingest.selectUser": "",
"match.admin.cast": "",
"match.admin.castInfo": "",
"match.admin.castConfigureHint": "",

View File

@@ -181,6 +181,11 @@
"match.endSet.selectWinner": "",
"match.endSet.randomWinner": "",
"match.deadline.explanation": "",
"match.ingest.showDialogButton": "",
"match.ingest.title": "",
"match.ingest.explanation": "",
"match.ingest.unknownTeam": "",
"match.ingest.selectUser": "",
"match.admin.cast": "",
"match.admin.castInfo": "",
"match.admin.castConfigureHint": "",

View File

@@ -181,6 +181,11 @@
"match.endSet.selectWinner": "",
"match.endSet.randomWinner": "",
"match.deadline.explanation": "",
"match.ingest.showDialogButton": "",
"match.ingest.title": "",
"match.ingest.explanation": "",
"match.ingest.unknownTeam": "",
"match.ingest.selectUser": "",
"match.admin.cast": "",
"match.admin.castInfo": "",
"match.admin.castConfigureHint": "",

View File

@@ -181,6 +181,11 @@
"match.endSet.selectWinner": "",
"match.endSet.randomWinner": "",
"match.deadline.explanation": "",
"match.ingest.showDialogButton": "",
"match.ingest.title": "",
"match.ingest.explanation": "",
"match.ingest.unknownTeam": "",
"match.ingest.selectUser": "",
"match.admin.cast": "",
"match.admin.castInfo": "",
"match.admin.castConfigureHint": "",

View File

@@ -181,6 +181,11 @@
"match.endSet.selectWinner": "",
"match.endSet.randomWinner": "",
"match.deadline.explanation": "",
"match.ingest.showDialogButton": "",
"match.ingest.title": "",
"match.ingest.explanation": "",
"match.ingest.unknownTeam": "",
"match.ingest.selectUser": "",
"match.admin.cast": "",
"match.admin.castInfo": "",
"match.admin.castConfigureHint": "",

View File

@@ -175,6 +175,11 @@
"match.endSet.selectWinner": "",
"match.endSet.randomWinner": "",
"match.deadline.explanation": "",
"match.ingest.showDialogButton": "",
"match.ingest.title": "",
"match.ingest.explanation": "",
"match.ingest.unknownTeam": "",
"match.ingest.selectUser": "",
"match.admin.cast": "",
"match.admin.castInfo": "",
"match.admin.castConfigureHint": "",

View File

@@ -175,6 +175,11 @@
"match.endSet.selectWinner": "",
"match.endSet.randomWinner": "",
"match.deadline.explanation": "",
"match.ingest.showDialogButton": "",
"match.ingest.title": "",
"match.ingest.explanation": "",
"match.ingest.unknownTeam": "",
"match.ingest.selectUser": "",
"match.admin.cast": "",
"match.admin.castInfo": "",
"match.admin.castConfigureHint": "",

View File

@@ -179,6 +179,11 @@
"match.endSet.selectWinner": "",
"match.endSet.randomWinner": "",
"match.deadline.explanation": "",
"match.ingest.showDialogButton": "",
"match.ingest.title": "",
"match.ingest.explanation": "",
"match.ingest.unknownTeam": "",
"match.ingest.selectUser": "",
"match.admin.cast": "",
"match.admin.castInfo": "",
"match.admin.castConfigureHint": "",

View File

@@ -183,6 +183,11 @@
"match.endSet.selectWinner": "",
"match.endSet.randomWinner": "",
"match.deadline.explanation": "",
"match.ingest.showDialogButton": "",
"match.ingest.title": "",
"match.ingest.explanation": "",
"match.ingest.unknownTeam": "",
"match.ingest.selectUser": "",
"match.admin.cast": "",
"match.admin.castInfo": "",
"match.admin.castConfigureHint": "",

View File

@@ -181,6 +181,11 @@
"match.endSet.selectWinner": "",
"match.endSet.randomWinner": "",
"match.deadline.explanation": "",
"match.ingest.showDialogButton": "",
"match.ingest.title": "",
"match.ingest.explanation": "",
"match.ingest.unknownTeam": "",
"match.ingest.selectUser": "",
"match.admin.cast": "",
"match.admin.castInfo": "",
"match.admin.castConfigureHint": "",

View File

@@ -183,6 +183,11 @@
"match.endSet.selectWinner": "",
"match.endSet.randomWinner": "",
"match.deadline.explanation": "",
"match.ingest.showDialogButton": "",
"match.ingest.title": "",
"match.ingest.explanation": "",
"match.ingest.unknownTeam": "",
"match.ingest.selectUser": "",
"match.admin.cast": "",
"match.admin.castInfo": "",
"match.admin.castConfigureHint": "",

View File

@@ -177,6 +177,11 @@
"match.endSet.selectWinner": "选择获胜者",
"match.endSet.randomWinner": "随机产生 (50/50)",
"match.deadline.explanation": "若有任何延迟,请及时通知赛事组织者。超过截止时间的对局可能会被提前终止。详情请参阅赛事规则。",
"match.ingest.showDialogButton": "",
"match.ingest.title": "",
"match.ingest.explanation": "",
"match.ingest.unknownTeam": "",
"match.ingest.selectUser": "",
"match.admin.cast": "转播",
"match.admin.castInfo": "选择当前正在转播此对局的 Twitch 账号。随后它会在对战表视图中显示。",
"match.admin.castConfigureHint": "请在赛事管理页面配置直播频道以启用转播功能。",

98
migrations/155-ingest.js Normal file
View File

@@ -0,0 +1,98 @@
export function up(db) {
db.pragma("foreign_keys = OFF");
db.transaction(() => {
db.prepare(
/* sql */ `
create table "ReportedWeapon_new" (
"groupMatchId" integer,
"tournamentMatchId" integer,
"mapIndex" integer not null,
"weaponSplId" integer not null,
"userId" integer,
"ingestedInGameName" text,
"ingestedTeamId" integer,
"createdAt" integer default (strftime('%s', 'now')) not null,
foreign key ("groupMatchId") references "GroupMatch"("id") on delete cascade,
foreign key ("tournamentMatchId") references "TournamentMatch"("id") on delete cascade,
foreign key ("userId") references "User"("id") on delete restrict,
foreign key ("ingestedTeamId") references "TournamentTeam"("id") on delete set null,
unique("groupMatchId", "mapIndex", "userId") on conflict rollback,
unique("tournamentMatchId", "mapIndex", "userId") on conflict rollback,
check (("groupMatchId" is not null) <> ("tournamentMatchId" is not null)),
check ("userId" is not null or "ingestedInGameName" is not null)
) strict
`,
).run();
db.prepare(
/* sql */ `
insert into "ReportedWeapon_new"
("groupMatchId", "tournamentMatchId", "mapIndex", "weaponSplId", "userId", "createdAt")
select
"groupMatchId",
"tournamentMatchId",
"mapIndex",
"weaponSplId",
"userId",
"createdAt"
from "ReportedWeapon"
`,
).run();
db.prepare(/* sql */ `drop table "ReportedWeapon"`).run();
db.prepare(
/* sql */ `alter table "ReportedWeapon_new" rename to "ReportedWeapon"`,
).run();
db.prepare(
/* sql */ `create index reported_weapon_group_match_id on "ReportedWeapon"("groupMatchId")`,
).run();
db.prepare(
/* sql */ `create index reported_weapon_tournament_match_id on "ReportedWeapon"("tournamentMatchId")`,
).run();
db.prepare(
/* sql */ `create index reported_weapon_user_id on "ReportedWeapon"("userId")`,
).run();
db.prepare(
/* sql */ `create index reported_weapon_user_created_at_weapon on "ReportedWeapon"("userId", "createdAt", "weaponSplId")`,
).run();
db.prepare(
/* sql */ `
create table "IngestedEvent" (
"id" integer primary key,
"tournamentId" integer,
"povUserId" integer,
"submitterUserId" integer,
"type" text not null,
"t" real not null,
"confidence" real not null,
"data" text not null,
"detectedAt" integer,
"eventHash" text unique not null,
"createdAt" integer default (strftime('%s', 'now')) not null,
foreign key ("tournamentId") references "Tournament"("id") on delete cascade,
foreign key ("povUserId") references "User"("id") on delete set null,
foreign key ("submitterUserId") references "User"("id") on delete set null
) strict
`,
).run();
db.prepare(
/* sql */ `create index ingested_event_tournament_id on "IngestedEvent"("tournamentId")`,
).run();
db.prepare(
/* sql */ `create index ingested_event_pov_user_id on "IngestedEvent"("povUserId")`,
).run();
db.pragma("foreign_key_check");
})();
db.pragma("foreign_keys = ON");
}