This commit is contained in:
Kalle
2026-08-07 16:54:00 +03:00
parent 193d548abc
commit 35aec5ab1e
19 changed files with 1709 additions and 683 deletions

View File

@@ -30,6 +30,7 @@ import { Line } from "react-chartjs-2";
import { useTranslation } from "react-i18next";
import { useThemeColors } from "~/hooks/useThemeColors";
import styles from "./ObjectiveTimeline.module.css";
import { smoothPenalties } from "./objective-timeline-utils";
ChartJS.register(
LinearScale,
@@ -40,7 +41,6 @@ ChartJS.register(
Legend,
);
const PENALTY_BRIDGE_SECONDS = 6;
/** count-axis units of gutter kept below zero for the control lane */
const CONTROL_LANE_DEPTH = 13;
const CONTROL_LANE_Y = -6;
@@ -123,7 +123,12 @@ export function ObjectiveTimeline({
}));
// band between score and score + penalty; its thickness is the penalty
const penaltyDatasets = ([0, 1] as const).map((side) => {
const penalties = smoothPenalties(sorted, side);
const penalties = smoothPenalties(
sorted.map((event) => ({
t: event.t,
penalty: event.data.penalty[side],
})),
);
let lastScore: number | null = null;
return {
label: `${teamLabels[side]} penalty`,
@@ -261,67 +266,6 @@ function gridColor(
return value === 0 ? colors.borderHigh : colors.border;
}
/**
* The penalty pill is misread for a frame or two at a time: it flickers
* between a value and null, and occasionally drops a digit ("36" read as
* "6"). Median-filters isolated outlier values, drops one-off reads with no
* nearby confirmation and carries the previous value across short null gaps
* so the band renders as one steady shape instead of a picket fence.
*/
function smoothPenalties(
sorted: readonly ObjectiveTimelineEvent[],
side: 0 | 1,
): (number | null)[] {
const medianFiltered = medianFilterValues(
sorted.map((event) => event.data.penalty[side]),
);
const kept = sorted.map((event, i) => {
const value = medianFiltered[i]!;
if (value === null) return null;
const hasNearbyRead = sorted.some(
(other, j) =>
j !== i &&
other.data.penalty[side] !== null &&
Math.abs(other.t - event.t) <= PENALTY_BRIDGE_SECONDS,
);
return hasNearbyRead ? value : null;
});
const result = [...kept];
let prev = -1;
for (let i = 0; i < result.length; i++) {
if (result[i] !== null) {
prev = i;
continue;
}
if (prev === -1) continue;
const next = result.findIndex((value, j) => j > i && value !== null);
if (next === -1) continue;
if (sorted[next]!.t - sorted[prev]!.t <= PENALTY_BRIDGE_SECONDS) {
result[i] = result[prev];
}
}
return result;
}
function medianFilterValues(
values: readonly (number | null)[],
): (number | null)[] {
const nonNullIndexes = values.flatMap((value, i) =>
value !== null ? [i] : [],
);
const result = [...values];
for (let k = 1; k < nonNullIndexes.length - 1; k++) {
const window = [
values[nonNullIndexes[k - 1]!]!,
values[nonNullIndexes[k]!]!,
values[nonNullIndexes[k + 1]!]!,
].sort((a, b) => a - b);
result[nonNullIndexes[k]!] = window[1]!;
}
return result;
}
/** Position on the x-axis: m:ss, growing an hours part only when needed. */
function formatElapsed(seconds: number): string {
const hours = Math.floor(seconds / 3600);

View File

@@ -43,8 +43,8 @@ import { WeaponPool } from "./WeaponPool";
const LONG_TEAM_NAME_THRESHOLD = 16;
// xxx: make actual in-game score
/** In-game team scores run 0-500p; a knockout shows as 500p for the winner. */
const SCOREBOARD_KO_SCORE = 500;
/** Ingested team scores run 0-100; a knockout shows as 100 for the winner. */
const SCOREBOARD_KO_SCORE = 100;
const ABILITY_NAMES: ReadonlySet<string> = new Set(
abilities.map((ability) => ability.name),
@@ -87,7 +87,7 @@ export interface TimelineMap {
pickedBy?: MatchSide;
/** Ingested end-of-game scoreboard rendered as an expandable stats section below the map row. */
scoreboard?: {
/** [alpha, bravo] on the in-game 0-500p scale (500 = knockout) */
/** [alpha, bravo] on the ingested 0-100 scale (100 = knockout) */
scores: [number | null, number | null];
alpha: TimelineScoreboardPlayer[];
bravo: TimelineScoreboardPlayer[];
@@ -310,7 +310,7 @@ function SideResult({
}: {
result: "WIN" | "LOSS";
isKo?: boolean;
/** in-game 0-500p team score from an ingested scoreboard (500 = knockout) */
/** ingested 0-100 team score (100 = knockout) */
scoreboardScore?: number | null;
weapons?: WeaponPoolWeapon[];
isPicked?: boolean;

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import { type PenaltyRead, smoothPenalties } from "./objective-timeline-utils";
function reads(...pairs: Array<[t: number, penalty: number | null]>) {
return pairs.map(([t, penalty]): PenaltyRead => ({ t, penalty }));
}
describe("smoothPenalties", () => {
it("passes steady reads through", () => {
expect(smoothPenalties(reads([0, 10], [2, 10], [4, 10]))).toEqual([
10, 10, 10,
]);
});
it("median-filters an isolated dropped-digit misread", () => {
expect(smoothPenalties(reads([0, 36], [2, 6], [4, 36]))).toEqual([
36, 36, 36,
]);
});
it("bridges a short null gap with the previous value", () => {
expect(smoothPenalties(reads([0, 12], [2, null], [4, 12]))).toEqual([
12, 12, 12,
]);
});
it("does not bridge a gap longer than the bridge window", () => {
expect(
smoothPenalties(reads([0, 12], [1, 12], [20, null], [40, 8], [41, 8])),
).toEqual([12, 12, null, 8, 8]);
});
it("drops one-off reads with no nearby confirmation", () => {
expect(smoothPenalties(reads([0, 5], [30, 12], [60, 7]))).toEqual([
null,
null,
null,
]);
});
it("does not extend past the last read", () => {
expect(smoothPenalties(reads([0, 10], [2, 10], [4, null]))).toEqual([
10,
10,
null,
]);
});
it("keeps all-null reads null", () => {
expect(smoothPenalties(reads([0, null], [2, null]))).toEqual([null, null]);
});
});

View File

@@ -0,0 +1,69 @@
const PENALTY_BRIDGE_SECONDS = 6;
/** One penalty read: when it was made and the pill value seen (null = no pill or unreadable). */
export interface PenaltyRead {
/** whole seconds into the source (video, stream or game) the read was made at */
t: number;
penalty: number | null;
}
/**
* The penalty pill is misread for a frame or two at a time: it flickers
* between a value and null, and occasionally drops a digit ("36" read as
* "6"). Median-filters isolated outlier values, drops one-off reads with no
* nearby confirmation and carries the previous value across short null gaps
* so the band renders as one steady shape instead of a picket fence.
*
* @param reads one team's penalty reads, sorted by `t` ascending
* @returns the smoothed penalty per read, index-aligned with the input
*/
export function smoothPenalties(
reads: readonly PenaltyRead[],
): (number | null)[] {
const medianFiltered = medianFilterValues(reads.map((read) => read.penalty));
const kept = reads.map((read, i) => {
const value = medianFiltered[i]!;
if (value === null) return null;
const hasNearbyRead = reads.some(
(other, j) =>
j !== i &&
other.penalty !== null &&
Math.abs(other.t - read.t) <= PENALTY_BRIDGE_SECONDS,
);
return hasNearbyRead ? value : null;
});
const result = [...kept];
let prev = -1;
for (let i = 0; i < result.length; i++) {
if (result[i] !== null) {
prev = i;
continue;
}
if (prev === -1) continue;
const next = result.findIndex((value, j) => j > i && value !== null);
if (next === -1) continue;
if (reads[next]!.t - reads[prev]!.t <= PENALTY_BRIDGE_SECONDS) {
result[i] = result[prev];
}
}
return result;
}
function medianFilterValues(
values: readonly (number | null)[],
): (number | null)[] {
const nonNullIndexes = values.flatMap((value, i) =>
value !== null ? [i] : [],
);
const result = [...values];
for (let k = 1; k < nonNullIndexes.length - 1; k++) {
const window = [
values[nonNullIndexes[k - 1]!]!,
values[nonNullIndexes[k]!]!,
values[nonNullIndexes[k + 1]!]!,
].sort((a, b) => a - b);
result[nonNullIndexes[k]!] = window[1]!;
}
return result;
}

View File

@@ -699,7 +699,7 @@ export default function MatchPageTestRoute() {
},
scoreboard: {
objective: MOCK_OBJECTIVE_EVENTS,
scores: [500, 0],
scores: [100, 0],
alpha: [
{
name: "Sendou",

View File

@@ -0,0 +1,314 @@
import { describe, expect, test } from "vitest";
import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { db } from "~/db/sql";
import type {
ScannerMatch,
ScannerMatchPlayer,
} from "~/features/scanner/core/scanner-match";
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import * as Matches from "./core/Matches";
import type { IngestableGame } from "./core/Scoreboards";
import * as ScannerIngestRepository from "./ScannerIngestRepository.server";
const NAMES = ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"];
const WEAPONS: MainWeaponId[] = [10, 20, 30, 40, 50, 60, 70, 80];
const PLAYED_AT = Date.UTC(2026, 7, 1, 18, 0, 0);
describe("addOrMergeMatches", () => {
test("inserts a fresh match with hash, hints and playedAt", async () => {
const user = await UserFactory.create();
const { match: groupMatch } = await setupSendouqMatch();
const result = await ScannerIngestRepository.addOrMergeMatches({
povUserId: user.id,
submitterUserId: user.id,
matches: [testMatch()],
context: { type: "sendouq", groupMatchId: groupMatch.id },
});
expect(result.insertedCount).toBe(1);
expect(result.mergedCount).toBe(0);
expect(result.effectiveMatches).toHaveLength(1);
const rows = await fetchIngestedMatches();
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe(result.effectiveMatches[0].id);
expect(rows[0].povUserId).toBe(user.id);
expect(rows[0].submitterUserId).toBe(user.id);
expect(rows[0].playedAt).toBe(Math.floor(PLAYED_AT / 1000));
expect(rows[0].matchHash).toMatch(/^[0-9a-f]{64}$/);
expect(rows[0].groupMatchIdHint).toBe(groupMatch.id);
expect(rows[0].tournamentIdHint).toBeNull();
expect(rows[0].data).toEqual(Matches.canonicalMatch(testMatch()));
});
test("identical resend is a no-op that backfills missing hints", async () => {
const user = await UserFactory.create();
const { match: groupMatch } = await setupSendouqMatch();
const first = await ScannerIngestRepository.addOrMergeMatches({
povUserId: user.id,
submitterUserId: user.id,
matches: [testMatch()],
context: null,
});
expect((await fetchIngestedMatches())[0].groupMatchIdHint).toBeNull();
const second = await ScannerIngestRepository.addOrMergeMatches({
povUserId: user.id,
submitterUserId: user.id,
matches: [testMatch()],
context: { type: "sendouq", groupMatchId: groupMatch.id },
});
expect(second.insertedCount).toBe(0);
expect(second.mergedCount).toBe(0);
expect(second.effectiveMatches[0].id).toBe(first.effectiveMatches[0].id);
const rows = await fetchIngestedMatches();
expect(rows).toHaveLength(1);
expect(rows[0].groupMatchIdHint).toBe(groupMatch.id);
});
test("a fuller re-send of the same game merges into the stored partial", async () => {
const user = await UserFactory.create();
const partial = testMatch({
playedAt: PLAYED_AT + 5 * 60 * 1000,
mode: null,
matchScores: null,
teams: [{ players: [] }, { players: [] }],
winner: null,
});
const first = await ScannerIngestRepository.addOrMergeMatches({
povUserId: user.id,
submitterUserId: user.id,
matches: [partial],
context: null,
});
expect(first.insertedCount).toBe(1);
const storedHash = (await fetchIngestedMatches())[0].matchHash;
const second = await ScannerIngestRepository.addOrMergeMatches({
povUserId: user.id,
submitterUserId: user.id,
matches: [testMatch()],
context: null,
});
expect(second.insertedCount).toBe(0);
expect(second.mergedCount).toBe(1);
expect(second.effectiveMatches[0].id).toBe(first.effectiveMatches[0].id);
expect(second.effectiveMatches[0].data.mode).toBe("SZ");
const rows = await fetchIngestedMatches();
expect(rows).toHaveLength(1);
expect(rows[0].data.mode).toBe("SZ");
expect(rows[0].data.winner).toBe(0);
expect(rows[0].data.teams[0].players.map((p) => p.name)).toEqual(
NAMES.slice(0, 4),
);
expect(rows[0].playedAt).toBe(Math.floor(partial.playedAt! / 1000));
expect(rows[0].matchHash).not.toBe(storedHash);
});
});
describe("addLinks", () => {
test("creates link rows for group match maps", async () => {
const user = await UserFactory.create();
const { maps } = await setupSendouqMatch();
const { effectiveMatches } =
await ScannerIngestRepository.addOrMergeMatches({
povUserId: null,
submitterUserId: user.id,
matches: [
testMatch(),
testMatch({ playedAt: PLAYED_AT + 60 * 60 * 1000, stage: 1 }),
],
context: null,
});
const linkedCount = await ScannerIngestRepository.addLinks({
links: effectiveMatches.map((effective, i) => ({
ingestedMatchId: effective.id,
match: effective.data,
game: sendouqGame(maps[i]),
})),
povUserId: null,
});
expect(linkedCount).toBe(2);
const links = await fetchLinks();
expect(links).toHaveLength(2);
expect(links.map((link) => link.ingestedMatchId)).toEqual(
effectiveMatches.map((effective) => effective.id),
);
expect(links.map((link) => link.groupMatchMapId)).toEqual(
maps.slice(0, 2).map((map) => map.id),
);
expect(
links.every((link) => link.tournamentMatchGameResultId === null),
).toBe(true);
expect(await fetchReportedWeapons()).toHaveLength(0);
});
test("re-sends are no-ops and only newly created links are counted", async () => {
const user = await UserFactory.create();
const { maps } = await setupSendouqMatch();
const { effectiveMatches } =
await ScannerIngestRepository.addOrMergeMatches({
povUserId: null,
submitterUserId: user.id,
matches: [
testMatch(),
testMatch({ playedAt: PLAYED_AT + 60 * 60 * 1000, stage: 1 }),
],
context: null,
});
const links = effectiveMatches.map((effective, i) => ({
ingestedMatchId: effective.id,
match: effective.data,
game: sendouqGame(maps[i]),
}));
await ScannerIngestRepository.addLinks({
links: [links[0]],
povUserId: null,
});
const secondCount = await ScannerIngestRepository.addLinks({
links,
povUserId: null,
});
expect(secondCount).toBe(1);
expect(await fetchLinks()).toHaveLength(2);
});
test("reports the POV player's weapon once", async () => {
const povUser = await UserFactory.create();
const { match: groupMatch, maps } = await setupSendouqMatch();
const { effectiveMatches } =
await ScannerIngestRepository.addOrMergeMatches({
povUserId: povUser.id,
submitterUserId: povUser.id,
matches: [testMatch({ pov: { team: 0, index: 0 } })],
context: null,
});
const links = [
{
ingestedMatchId: effectiveMatches[0].id,
match: effectiveMatches[0].data,
game: sendouqGame(maps[0]),
},
];
await ScannerIngestRepository.addLinks({ links, povUserId: povUser.id });
await ScannerIngestRepository.addLinks({ links, povUserId: povUser.id });
const reportedWeapons = await fetchReportedWeapons();
expect(reportedWeapons).toHaveLength(1);
expect(reportedWeapons[0].groupMatchId).toBe(groupMatch.id);
expect(reportedWeapons[0].tournamentMatchId).toBeNull();
expect(reportedWeapons[0].mapIndex).toBe(maps[0].index);
expect(reportedWeapons[0].userId).toBe(povUser.id);
expect(reportedWeapons[0].weaponSplId).toBe(WEAPONS[0]);
});
});
function player(name: string, weaponId: MainWeaponId): ScannerMatchPlayer {
return {
name,
weaponId,
paint: 1000,
ka: 10,
d: 5,
s: 2,
};
}
function testMatch(partial: Partial<ScannerMatch> = {}): ScannerMatch {
return {
startsAt: 100,
endsAt: 400,
playedAt: PLAYED_AT,
lobby: "PRIVATE",
mode: "SZ",
stage: 0,
matchScores: [100, 52],
replayCode: null,
cast: false,
objective: null,
teams: [
{ players: NAMES.slice(0, 4).map((n, i) => player(n, WEAPONS[i]!)) },
{ players: NAMES.slice(4).map((n, i) => player(n, WEAPONS[4 + i]!)) },
],
winner: 0,
pov: null,
...partial,
};
}
async function setupSendouqMatch() {
const users = await UserFactory.createMany(FULL_GROUP_SIZE * 2);
const match = await SQMatchFactory.create({
alphaUserIds: users.slice(0, FULL_GROUP_SIZE).map((user) => user.id),
bravoUserIds: users.slice(FULL_GROUP_SIZE).map((user) => user.id),
});
const maps = await db
.selectFrom("GroupMatchMap")
.selectAll()
.where("matchId", "=", match.id)
.orderBy("index", "asc")
.execute();
return { match, maps };
}
function fetchIngestedMatches() {
return db
.selectFrom("IngestedMatch")
.selectAll()
.orderBy("id", "asc")
.execute();
}
function fetchLinks() {
return db
.selectFrom("IngestedMatchLink")
.selectAll()
.orderBy("id", "asc")
.execute();
}
function fetchReportedWeapons() {
return db.selectFrom("ReportedWeapon").selectAll().execute();
}
function sendouqGame(map: {
id: number;
matchId: number;
index: number;
mode: IngestableGame["mode"];
stageId: IngestableGame["stageId"];
}): IngestableGame {
return {
target: {
type: "sendouq",
groupMatchMapId: map.id,
groupMatchId: map.matchId,
},
mapIndex: map.index,
mode: map.mode,
stageId: map.stageId,
winnerInGameNames: [],
loserInGameNames: [],
playedAt: Math.floor(PLAYED_AT / 1000),
linkedPlayerNames: null,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,10 +3,10 @@ import type { ActionFunction } from "react-router";
import { Config } from "~/config";
import { requireUser } from "~/features/auth/core/user.server";
import type { ScannerMatch } from "~/features/scanner/core/scanner-match";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { isAdmin } from "~/modules/permissions/utils";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { logger } from "~/utils/logger";
import { badRequestIfFalsy, forbidden, parseBody } from "~/utils/remix.server";
import { forbidden, parseBody } from "~/utils/remix.server";
import * as Scoreboards from "../core/Scoreboards";
import * as ScannerIngestRepository from "../ScannerIngestRepository.server";
import {
@@ -28,11 +28,7 @@ export const action: ActionFunction = async ({ request }) => {
const data = await parseBody({ request, schema: ingestBodySchema });
const povUserId = data.povUserId ?? user?.id ?? null;
if (povUserId) {
badRequestIfFalsy(await UserRepository.findLeanById(povUserId));
}
const povUserId = user.id;
const indexedMatches = data.matches
.map((match, requestIndex) => ({ match, requestIndex }))
@@ -50,13 +46,13 @@ export const action: ActionFunction = async ({ request }) => {
const resolved = await resolveIngestContext({
matches,
povUserId,
casterUserId: user?.id ?? null,
casterUserId: user.id,
});
const { insertedCount, mergedCount, effectiveMatches } =
await ScannerIngestRepository.addOrMergeMatches({
povUserId,
submitterUserId: user?.id ?? null,
submitterUserId: user.id,
matches,
context: resolved?.context ?? null,
});
@@ -238,8 +234,8 @@ async function resolveIngestContext({
}
if (povUserId && hasPovMatches && countAttachableMatches(matches) >= 2) {
const since = Math.floor(
subDays(new Date(), CONTENT_RESOLUTION_WINDOW_DAYS).getTime() / 1000,
const since = dateToDatabaseTimestamp(
subDays(new Date(), CONTENT_RESOLUTION_WINDOW_DAYS),
);
const games = [
...(await ScannerIngestRepository.gamesPlayedByUserSince({

View File

@@ -325,22 +325,22 @@ function mergeTeam(
return hit.player;
},
);
existing.players.forEach((player, i) => {
if (counterparts[i] || player.weaponId === null) return;
for (const [i, player] of existing.players.entries()) {
if (counterparts[i] || player.weaponId === null) continue;
const hits = pool.filter(
(entry) => !entry.used && entry.player.weaponId === player.weaponId,
);
if (hits.length !== 1) return;
if (hits.length !== 1) continue;
hits[0]!.used = true;
counterparts[i] = hits[0]!.player;
});
existing.players.forEach((_, i) => {
if (counterparts[i]) return;
}
for (const i of existing.players.keys()) {
if (counterparts[i]) continue;
const hit = pool[i]?.used === false ? pool[i]! : pool.find((e) => !e.used);
if (!hit) return;
if (!hit) continue;
hit.used = true;
counterparts[i] = hit.player;
});
}
const players = existing.players.map((player, i) => {
const counterpart = counterparts[i];

View File

@@ -279,6 +279,25 @@ describe("matchedGames", () => {
expect(tournamentMatchIdOf(matched[0]!)).toBe(1);
});
it("skips a duplicate detection despite a couple of OCR-misread names", () => {
const matched = Scoreboards.matchedGames({
matches: [
testMatch({ t: 60 }),
testMatch({
t: 65,
names: ["w1", "vv2", "w3", "w4", "l1", "l2", "l3", "I4"],
}),
],
games: [
testGame({ tournamentMatchId: 1, playedAt: 1000 }),
testGame({ tournamentMatchId: 2, playedAt: 2000 }),
],
});
expect(matched).toHaveLength(1);
expect(tournamentMatchIdOf(matched[0]!)).toBe(1);
});
it("skips matches from other lobbies", () => {
const matched = Scoreboards.matchedGames({
matches: [testMatch({ lobby: "X" })],
@@ -550,6 +569,21 @@ describe("deriveScoreboardData", () => {
expect(data!.players[2]!.userId).toBe(42);
});
it("does not attribute a POV whose read name contradicts its seat's merged row", () => {
const data = derive([
{ data: testMatch(), povUserId: null },
{
data: testMatch({
povIndex: 2,
names: ["w1", "w2", "x9", "w4", "l1", "l2", "l3", "l4"],
}),
povUserId: 42,
},
]);
expect(data!.players.some((p) => p.userId === 42)).toBe(false);
});
it("merges a later partial's fields under the first link's values", () => {
const withoutScores: ScannerMatch = {
...testMatch(),

View File

@@ -431,7 +431,9 @@ function attributionIndex(
/**
* Drops re-detections of the same game within one request: same mode and
* stage with every player row carrying the same name.
* stage with enough player rows carrying the same readable name in the same
* position — the same OCR-jitter tolerance as the cross-request duplicate
* check (isLinkedDuplicate).
*/
function dedupeViews(sorted: IndexedView[]): IndexedView[] {
const result: IndexedView[] = [];
@@ -441,8 +443,9 @@ function dedupeViews(sorted: IndexedView[]): IndexedView[] {
(other) =>
other.mode === view.mode &&
other.stage === view.stage &&
other.players.every(
(player, i) => player.name === view.players[i]!.name,
isLinkedDuplicate(
view,
other.players.map((player) => player.name),
),
);
if (!isDuplicate) result.push(view);

View File

@@ -1,17 +1,15 @@
import { z } from "zod";
import { scannerMatchSchema } from "~/features/scanner/scanner-schemas";
import { id } from "~/utils/zod";
const MAX_MATCHES_PER_REQUEST = 50;
/**
* The ScannerMatch shape comes from the producer
* (~/features/scanner/scanner-schemas — the single source of truth for the
* scanner domain); this module only adds the ingest-specific envelope.
* scanner domain); this module only adds the ingest-specific envelope. The
* POV user is always the session user, never client-supplied.
*/
export const ingestBodySchema = z.object({
/** the user whose point of view the matches were detected from */
povUserId: id.optional(),
matches: z.array(scannerMatchSchema).min(1).max(MAX_MATCHES_PER_REQUEST),
});

View File

@@ -14,11 +14,11 @@ import type { IngestedScoreboardData } from "~/features/scanner-ingest/core/Scor
import { useTournament } from "~/features/tournament/routes/to.$id";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { databaseTimestampToJavascriptTimestamp } from "~/utils/dates";
import { tournamentTeamPage } from "~/utils/urls";
import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server";
import { type MatchPageTeam, useMatch } from "../match-page-context";
import { resolveTimelineWeapons } from "../tournament-match-utils";
import { TournamentMatchActionPickBanTab } from "./TournamentMatchActionPickBanTab";
import { TournamentMatchActionTab } from "./TournamentMatchActionTab";
import { TournamentMatchAdminTab } from "./TournamentMatchAdminTab";
@@ -176,50 +176,12 @@ function resolveTimelineMaps(
const weaponsFor = (
roster: ReturnType<typeof resolveRoster>,
tournamentTeamId: number,
): WeaponPoolWeapon[] => {
const linkedWeapons = roster.map((u) => weaponFor(u.id));
// an ingested row without a user is only unaccounted for if no roster
// member already reported its weapon, otherwise it is that member's row
// and reusing it would show their weapon twice
const accountedForCounts = new Map<MainWeaponId, number>();
for (const weapon of linkedWeapons) {
if (weapon === null) continue;
accountedForCounts.set(
weapon,
(accountedForCounts.get(weapon) ?? 0) + 1,
);
}
const unlinkedIngested =
ingestedScoreboard?.data.players.flatMap((player) => {
if (
player.userId !== undefined ||
player.weaponSplId === null ||
player.tournamentTeamId !== tournamentTeamId
) {
return [];
}
const accountedFor = accountedForCounts.get(player.weaponSplId) ?? 0;
if (accountedFor > 0) {
accountedForCounts.set(player.weaponSplId, accountedFor - 1);
return [];
}
return [player.weaponSplId];
}) ?? [];
let unlinkedIdx = 0;
return linkedWeapons.map((linked) => {
if (linked !== null) return linked;
const ingested = unlinkedIngested[unlinkedIdx++];
return ingested !== undefined
? { weaponSplId: ingested, unverified: true }
: null;
): WeaponPoolWeapon[] =>
resolveTimelineWeapons({
linkedWeapons: roster.map((u) => weaponFor(u.id)),
ingestedPlayers: ingestedScoreboard?.data.players ?? [],
tournamentTeamId,
});
};
const alphaWeapons = weaponsFor(alphaRoster, opponentOneId);
const bravoWeapons = weaponsFor(bravoRoster, opponentTwoId);

View File

@@ -1,5 +1,10 @@
import { describe, expect, test } from "vitest";
import { mapCountPlayedInSetWithCertainty } from "./tournament-match-utils";
import { describe, expect, it, test } from "vitest";
import type { IngestedScoreboardPlayer } from "~/features/scanner-ingest/core/Scoreboards";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import {
mapCountPlayedInSetWithCertainty,
resolveTimelineWeapons,
} from "./tournament-match-utils";
const mapCountParamsToResult: {
bestOf: number;
@@ -26,3 +31,91 @@ describe("mapCountPlayedInSetWithCertainty()", () => {
});
}
});
const TEAM_ID = 1;
const OTHER_TEAM_ID = 2;
function ingestedPlayer(
partial: Partial<IngestedScoreboardPlayer>,
): IngestedScoreboardPlayer {
return {
name: "player",
tournamentTeamId: TEAM_ID,
weaponSplId: 10 as MainWeaponId,
ka: 10,
d: 5,
s: 2,
paint: 1000,
...partial,
};
}
describe("resolveTimelineWeapons()", () => {
it("passes reported weapons through and leaves gaps null without ingested rows", () => {
expect(
resolveTimelineWeapons({
linkedWeapons: [10, null, 20, null],
ingestedPlayers: [],
tournamentTeamId: TEAM_ID,
}),
).toEqual([10, null, 20, null]);
});
it("fills gaps from unaccounted ingested rows, marked unverified", () => {
expect(
resolveTimelineWeapons({
linkedWeapons: [10, null, null, null],
ingestedPlayers: [
ingestedPlayer({ weaponSplId: 30 }),
ingestedPlayer({ weaponSplId: 40 }),
],
tournamentTeamId: TEAM_ID,
}),
).toEqual([
10,
{ weaponSplId: 30, unverified: true },
{ weaponSplId: 40, unverified: true },
null,
]);
});
it("does not reuse an ingested row whose weapon a roster member already reported", () => {
expect(
resolveTimelineWeapons({
linkedWeapons: [10, null, null, null],
ingestedPlayers: [ingestedPlayer({ weaponSplId: 10 })],
tournamentTeamId: TEAM_ID,
}),
).toEqual([10, null, null, null]);
});
it("keeps the extra ingested row of a weapon two players ran when only one reported it", () => {
expect(
resolveTimelineWeapons({
linkedWeapons: [10, null, null, null],
ingestedPlayers: [
ingestedPlayer({ weaponSplId: 10 }),
ingestedPlayer({ weaponSplId: 10 }),
],
tournamentTeamId: TEAM_ID,
}),
).toEqual([10, { weaponSplId: 10, unverified: true }, null, null]);
});
it("skips ingested rows already attributed to a user, from the other team or without a weapon", () => {
expect(
resolveTimelineWeapons({
linkedWeapons: [null, null, null, null],
ingestedPlayers: [
ingestedPlayer({ weaponSplId: 30, userId: 42 }),
ingestedPlayer({
weaponSplId: 40,
tournamentTeamId: OTHER_TEAM_ID,
}),
ingestedPlayer({ weaponSplId: null }),
],
tournamentTeamId: TEAM_ID,
}),
).toEqual([null, null, null, null]);
});
});

View File

@@ -1,9 +1,15 @@
import type { TFunction } from "i18next";
import * as R from "remeda";
import type { WeaponPoolWeapon } from "~/components/match-page/WeaponPool";
import type { TournamentRoundMaps } from "~/db/tables-json";
import type { IngestedScoreboardPlayer } from "~/features/scanner-ingest/core/Scoreboards";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator/types";
import { logger } from "~/utils/logger";
@@ -88,6 +94,62 @@ export function pickInfoText({
return "";
}
/**
* One team's weapons for a map row: each roster member's reported weapon,
* with the gaps filled from the map's ingested scoreboard rows that no
* member accounts for. An ingested row without a user is only unaccounted
* for if no roster member already reported its weapon, otherwise it is that
* member's row and reusing it would show their weapon twice — a multiset
* count, so two ingested rows of a weapon survive one report of it.
*
* @param linkedWeapons per roster member, the weapon they reported for the map (null = none)
* @param ingestedPlayers the map's ingested scoreboard rows (empty when none ingested)
* @returns index-aligned with `linkedWeapons`; ingested fills are marked unverified
*/
export function resolveTimelineWeapons({
linkedWeapons,
ingestedPlayers,
tournamentTeamId,
}: {
linkedWeapons: (MainWeaponId | null)[];
ingestedPlayers: IngestedScoreboardPlayer[];
tournamentTeamId: number;
}): WeaponPoolWeapon[] {
const accountedForCounts = new Map<MainWeaponId, number>();
for (const weapon of linkedWeapons) {
if (weapon === null) continue;
accountedForCounts.set(weapon, (accountedForCounts.get(weapon) ?? 0) + 1);
}
const unlinkedIngested = ingestedPlayers.flatMap((player) => {
if (
player.userId !== undefined ||
player.weaponSplId === null ||
player.tournamentTeamId !== tournamentTeamId
) {
return [];
}
const accountedFor = accountedForCounts.get(player.weaponSplId) ?? 0;
if (accountedFor > 0) {
accountedForCounts.set(player.weaponSplId, accountedFor - 1);
return [];
}
return [player.weaponSplId];
});
let unlinkedIdx = 0;
return linkedWeapons.map((linked) => {
if (linked !== null) return linked;
const ingested = unlinkedIngested[unlinkedIdx++];
return ingested !== undefined
? { weaponSplId: ingested, unverified: true }
: null;
});
}
export function isSetOverByResults({
results,
count,

View File

@@ -22,6 +22,7 @@ import {
userSearch,
} from "./fields";
import { SendouForm, useFormFieldContext } from "./SendouForm";
import type { ArrayItemRenderContext } from "./types";
let mockFetcherData: { fieldErrors?: Record<string, string> } | undefined;
@@ -1415,6 +1416,269 @@ describe("SendouForm", () => {
});
});
describe("array field with custom-rendered items", () => {
const memberSchema = () =>
z.object({
members: array({
label: "labels.members",
min: 0,
max: 10,
field: fieldset({
fields: z.object({
name: textField({ label: "labels.name", maxLength: 100 }),
role: select({
label: "labels.staffRole",
items: [
{ value: "ORGANIZER", label: "options.staffRole.ORGANIZER" },
{ value: "STREAMER", label: "options.staffRole.STREAMER" },
],
}),
}),
}),
}),
});
function renderCustomArrayForm(options?: {
defaultValues?: Record<string, unknown>;
onApply?: (values: Record<string, unknown>) => void;
}) {
const router = createMemoryRouter(
[
{
path: "/",
element: (
<SendouForm
schema={memberSchema()}
defaultValues={options?.defaultValues}
onApply={options?.onApply}
>
<FormField name="members">
{(ctx: ArrayItemRenderContext) => (
<div>
<div data-testid={`member-${ctx.index}`}>
{ctx.values.name as string} /{" "}
{ctx.values.role as string}
</div>
<button
type="button"
onClick={() =>
ctx.setItemField(
"name",
`${ctx.values.name as string} edited`,
)
}
>
Edit member {ctx.index + 1}
</button>
{ctx.canRemove ? (
<button type="button" onClick={() => ctx.remove()}>
Remove member {ctx.index + 1}
</button>
) : null}
</div>
)}
</FormField>
</SendouForm>
),
},
],
{ initialEntries: ["/"] },
);
return render(<RouterProvider router={router} />);
}
const memberTestIds = (screen: Awaited<ReturnType<typeof render>>) =>
screen.container.querySelectorAll('[data-testid^="member-"]');
test("renders each item through the render function with its values", async () => {
const screen = await renderCustomArrayForm({
defaultValues: {
members: [
{ name: "Alice", role: "ORGANIZER" },
{ name: "Bob", role: "STREAMER" },
],
},
});
await expect
.element(screen.getByTestId("member-0"))
.toHaveTextContent("Alice / ORGANIZER");
await expect
.element(screen.getByTestId("member-1"))
.toHaveTextContent("Bob / STREAMER");
});
test("add button appends a new custom-rendered item", async () => {
const screen = await renderCustomArrayForm();
expect(memberTestIds(screen).length).toBe(1);
await screen.getByRole("button", { name: "Add" }).click();
await expect
.element(screen.getByTestId("member-1"))
.toHaveTextContent("/ ORGANIZER");
expect(memberTestIds(screen).length).toBe(2);
});
test("remove removes exactly the clicked item", async () => {
const onApply = vi.fn();
const screen = await renderCustomArrayForm({
defaultValues: {
members: [
{ name: "Alice", role: "ORGANIZER" },
{ name: "Bob", role: "STREAMER" },
{ name: "Carol", role: "STREAMER" },
],
},
onApply,
});
await screen.getByRole("button", { name: "Remove member 2" }).click();
expect(memberTestIds(screen).length).toBe(2);
await expect
.element(screen.getByTestId("member-0"))
.toHaveTextContent("Alice / ORGANIZER");
await expect
.element(screen.getByTestId("member-1"))
.toHaveTextContent("Carol / STREAMER");
await screen.getByRole("button", { name: "Submit" }).click();
expect(onApply).toHaveBeenCalledWith({
members: [
expect.objectContaining({ name: "Alice", role: "ORGANIZER" }),
expect.objectContaining({ name: "Carol", role: "STREAMER" }),
],
});
});
test("remove after add acts on the grown array, not a stale one", async () => {
const screen = await renderCustomArrayForm({
defaultValues: {
members: [
{ name: "Alice", role: "ORGANIZER" },
{ name: "Bob", role: "STREAMER" },
],
},
});
await screen.getByRole("button", { name: "Add" }).click();
await screen.getByRole("button", { name: "Remove member 1" }).click();
// A stale remove would have filtered the pre-add two-item array and
// dropped the freshly added row along with Alice.
expect(memberTestIds(screen).length).toBe(2);
await expect
.element(screen.getByTestId("member-0"))
.toHaveTextContent("Bob / STREAMER");
});
test("remove after editing a different item keeps the edit", async () => {
const onApply = vi.fn();
const screen = await renderCustomArrayForm({
defaultValues: {
members: [
{ name: "Alice", role: "ORGANIZER" },
{ name: "Bob", role: "STREAMER" },
{ name: "Carol", role: "STREAMER" },
],
},
onApply,
});
// Editing item 1 does not re-render the memoized item 3, so its remove
// callback must read the current array instead of a stale closure.
await screen.getByRole("button", { name: "Edit member 1" }).click();
await screen.getByRole("button", { name: "Remove member 3" }).click();
await screen.getByRole("button", { name: "Submit" }).click();
expect(onApply).toHaveBeenCalledWith({
members: [
expect.objectContaining({ name: "Alice edited", role: "ORGANIZER" }),
expect.objectContaining({ name: "Bob", role: "STREAMER" }),
],
});
});
test("setItemField updates only the targeted item's field", async () => {
const onApply = vi.fn();
const screen = await renderCustomArrayForm({
defaultValues: {
members: [
{ name: "Alice", role: "ORGANIZER" },
{ name: "Bob", role: "STREAMER" },
],
},
onApply,
});
await screen.getByRole("button", { name: "Edit member 2" }).click();
await expect
.element(screen.getByTestId("member-1"))
.toHaveTextContent("Bob edited / STREAMER");
await expect
.element(screen.getByTestId("member-0"))
.toHaveTextContent("Alice / ORGANIZER");
await screen.getByRole("button", { name: "Submit" }).click();
expect(onApply).toHaveBeenCalledWith({
members: [
expect.objectContaining({ name: "Alice", role: "ORGANIZER" }),
expect.objectContaining({ name: "Bob edited", role: "STREAMER" }),
],
});
});
test("itemName renders a nested FormField bound to the item", async () => {
const onApply = vi.fn();
const schema = memberSchema();
const router = createMemoryRouter(
[
{
path: "/",
element: (
<SendouForm
schema={schema}
defaultValues={{
members: [{ name: "Alice", role: "ORGANIZER" }],
}}
onApply={onApply}
>
<FormField name="members">
{(ctx: ArrayItemRenderContext) => (
<FormField name={`${ctx.itemName}.name`} />
)}
</FormField>
</SendouForm>
),
},
],
{ initialEntries: ["/"] },
);
const screen = await render(<RouterProvider router={router} />);
const input = screen.getByLabelText("Name");
await expect.element(input).toHaveValue("Alice");
await userEvent.type(input.element(), " Smith");
await screen.getByRole("button", { name: "Submit" }).click();
expect(onApply).toHaveBeenCalledWith({
members: [
expect.objectContaining({ name: "Alice Smith", role: "ORGANIZER" }),
],
});
});
});
describe("array field item removal preserves remaining items", () => {
test("removing a middle member preserves userSearch values of members below", async () => {
let latestValues: Record<string, unknown> = {};

View File

@@ -84,5 +84,12 @@ export async function up(db: Kysely<any>): Promise<void> {
.on("IngestedMatchLink")
.column("groupMatchMapId")
.execute();
// ingest context resolution prunes reported games by a createdAt window
await trx.schema
.createIndex("tournament_match_game_result_created_at")
.on("TournamentMatchGameResult")
.column("createdAt")
.execute();
});
}

View File

@@ -17,6 +17,7 @@ import * as SkillRepository from "~/features/mmr/SkillRepository.server";
import * as NotificationRepository from "~/features/notifications/NotificationRepository.server";
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
import * as ScannerIngestRepository from "~/features/scanner-ingest/ScannerIngestRepository.server";
import * as ScrimMapListRepository from "~/features/scrims/ScrimMapListRepository.server";
import * as ScrimMapRepository from "~/features/scrims/ScrimMapRepository.server";
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
@@ -416,6 +417,78 @@ export function buildCases(fx: Fixtures): {
}),
);
// ScannerIngestRepository
add(
"ScannerIngestRepository.gamesPlayedByUserInTournament",
fx.scannerIngest,
(ingest) =>
ScannerIngestRepository.gamesPlayedByUserInTournament({
userId: ingest.povUserId,
tournamentId: ingest.tournamentId,
}),
);
add(
"ScannerIngestRepository.gamesPlayedByUserSince",
fx.scannerIngest,
(ingest) =>
ScannerIngestRepository.gamesPlayedByUserSince({
userId: ingest.povUserId,
since: ingest.sinceTimestamp,
}),
);
add(
"ScannerIngestRepository.castedGamesInTournament",
fx.castedTournamentId,
(tournamentId) =>
ScannerIngestRepository.castedGamesInTournament(tournamentId),
);
add(
"ScannerIngestRepository.gamesInGroupMatch",
fx.heavyGroupMatchId,
(groupMatchId) => ScannerIngestRepository.gamesInGroupMatch(groupMatchId),
);
add(
"ScannerIngestRepository.sendouqGamesPlayedByUserSince",
fx.scannerIngestSendouq,
(sendouq) =>
ScannerIngestRepository.sendouqGamesPlayedByUserSince({
userId: sendouq.userId,
since: sendouq.sinceTimestamp,
}),
);
add("ScannerIngestRepository.tournamentIdAt", fx.scannerIngest, (ingest) =>
ScannerIngestRepository.tournamentIdAt({
userId: ingest.povUserId,
at: ingest.atMs,
}),
);
add(
"ScannerIngestRepository.groupMatchIdAt",
fx.scannerIngestSendouq,
(sendouq) =>
ScannerIngestRepository.groupMatchIdAt({
userId: sendouq.userId,
at: sendouq.atMs,
}),
);
add(
"ScannerIngestRepository.staffTournamentIdsAt",
both(fx.calendarAuthorId, fx.scannerIngest),
([userId, ingest]) =>
ScannerIngestRepository.staffTournamentIdsAt({
userId,
at: ingest.atMs,
}),
);
add(
"ScannerIngestRepository.findScoreboardsByTournamentMatchId",
fx.heavyTournamentMatchId,
(tournamentMatchId) =>
ScannerIngestRepository.findScoreboardsByTournamentMatchId(
tournamentMatchId,
),
);
// ScrimMapListRepository
add(
"ScrimMapListRepository.findMapListsByScrimPostId",

View File

@@ -1,4 +1,5 @@
import { sub } from "date-fns";
import { sql } from "kysely";
import { db } from "~/db/sql";
import type { Tables } from "~/db/tables";
import type { SkillTeamIdentifier } from "~/features/mmr/mmr-utils";
@@ -93,6 +94,18 @@ export interface Fixtures {
apiTokenUserId: number | null;
logInLinkCode: string | null;
modNoteId: number | null;
scannerIngest: {
povUserId: number;
tournamentId: number;
atMs: number;
sinceTimestamp: number;
} | null;
scannerIngestSendouq: {
userId: number;
atMs: number;
sinceTimestamp: number;
} | null;
castedTournamentId: number | null;
}
/**
@@ -164,6 +177,9 @@ export async function resolveFixtures(): Promise<Fixtures> {
apiTokenUserId: await resolveApiTokenUserId(),
logInLinkCode: await resolveLogInLinkCode(),
modNoteId: await resolveModNoteId(),
scannerIngest: await resolveScannerIngest(),
scannerIngestSendouq: await resolveScannerIngestSendouq(),
castedTournamentId: await resolveCastedTournamentId(),
};
const nullFixtures = Object.entries(fixtures)
@@ -1094,3 +1110,113 @@ async function resolveModNoteId() {
return row?.id ?? null;
}
const SCANNER_INGEST_SINCE_WINDOW_SECONDS = 365 * 24 * 60 * 60;
async function resolveScannerIngest() {
const participantRow = await db
.selectFrom("TournamentMatchGameResultParticipant")
.select(({ fn }) => ["userId", fn.countAll<number>().as("count")])
.groupBy("userId")
.orderBy("count", "desc")
.limit(1)
.executeTakeFirst();
if (!participantRow) return null;
const latestGame = await db
.selectFrom("TournamentMatchGameResultParticipant")
.innerJoin(
"TournamentMatchGameResult",
"TournamentMatchGameResult.id",
"TournamentMatchGameResultParticipant.matchGameResultId",
)
.innerJoin(
"TournamentMatch",
"TournamentMatch.id",
"TournamentMatchGameResult.matchId",
)
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.select([
"TournamentMatchGameResult.createdAt",
"TournamentStage.tournamentId",
])
.where(
"TournamentMatchGameResultParticipant.userId",
"=",
participantRow.userId,
)
.orderBy("TournamentMatchGameResult.createdAt", "desc")
.limit(1)
.executeTakeFirst();
if (!latestGame) return null;
return {
povUserId: participantRow.userId,
tournamentId: latestGame.tournamentId,
atMs: latestGame.createdAt * 1000,
sinceTimestamp: latestGame.createdAt - SCANNER_INGEST_SINCE_WINDOW_SECONDS,
};
}
async function resolveScannerIngestSendouq() {
const memberRow = await db
.selectFrom("GroupMember")
.select(({ fn }) => ["userId", fn.countAll<number>().as("count")])
.groupBy("userId")
.orderBy("count", "desc")
.limit(1)
.executeTakeFirst();
if (!memberRow) return null;
const latestMatch = await db
.selectFrom("GroupMatch")
.select("GroupMatch.createdAt")
.where((eb) =>
eb.exists(
eb
.selectFrom("GroupMember")
.select("GroupMember.userId")
.where("GroupMember.userId", "=", memberRow.userId)
.where((memberEb) =>
memberEb.or([
memberEb(
"GroupMember.groupId",
"=",
memberEb.ref("GroupMatch.alphaGroupId"),
),
memberEb(
"GroupMember.groupId",
"=",
memberEb.ref("GroupMatch.bravoGroupId"),
),
]),
),
),
)
.orderBy("GroupMatch.createdAt", "desc")
.limit(1)
.executeTakeFirst();
if (!latestMatch) return null;
return {
userId: memberRow.userId,
atMs: latestMatch.createdAt * 1000,
sinceTimestamp: latestMatch.createdAt - SCANNER_INGEST_SINCE_WINDOW_SECONDS,
};
}
async function resolveCastedTournamentId() {
const row = await db
.selectFrom("Tournament")
.select("id")
.where("castedMatchesInfo", "is not", null)
.orderBy(sql`length("castedMatchesInfo")`, "desc")
.limit(1)
.executeTakeFirst();
return row?.id ?? null;
}