diff --git a/app/components/ObjectiveTimeline.tsx b/app/components/ObjectiveTimeline.tsx index 81170132c..3e3dea5aa 100644 --- a/app/components/ObjectiveTimeline.tsx +++ b/app/components/ObjectiveTimeline.tsx @@ -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); diff --git a/app/components/match-page/MatchTimeline.tsx b/app/components/match-page/MatchTimeline.tsx index a3f567cc3..8bddbb3e3 100644 --- a/app/components/match-page/MatchTimeline.tsx +++ b/app/components/match-page/MatchTimeline.tsx @@ -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 = 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; diff --git a/app/components/objective-timeline-utils.test.ts b/app/components/objective-timeline-utils.test.ts new file mode 100644 index 000000000..e52516c4d --- /dev/null +++ b/app/components/objective-timeline-utils.test.ts @@ -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]); + }); +}); diff --git a/app/components/objective-timeline-utils.ts b/app/components/objective-timeline-utils.ts new file mode 100644 index 000000000..3ca6cc165 --- /dev/null +++ b/app/components/objective-timeline-utils.ts @@ -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; +} diff --git a/app/features/match-page-test/routes/match-page-test.tsx b/app/features/match-page-test/routes/match-page-test.tsx index 01865fe78..d4222351e 100644 --- a/app/features/match-page-test/routes/match-page-test.tsx +++ b/app/features/match-page-test/routes/match-page-test.tsx @@ -699,7 +699,7 @@ export default function MatchPageTestRoute() { }, scoreboard: { objective: MOCK_OBJECTIVE_EVENTS, - scores: [500, 0], + scores: [100, 0], alpha: [ { name: "Sendou", diff --git a/app/features/scanner-ingest/ScannerIngestRepository.server.test.ts b/app/features/scanner-ingest/ScannerIngestRepository.server.test.ts new file mode 100644 index 000000000..7e73041b6 --- /dev/null +++ b/app/features/scanner-ingest/ScannerIngestRepository.server.test.ts @@ -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 { + 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, + }; +} diff --git a/app/features/scanner-ingest/ScannerIngestRepository.server.ts b/app/features/scanner-ingest/ScannerIngestRepository.server.ts index d8afd2fd8..b293db6a9 100644 --- a/app/features/scanner-ingest/ScannerIngestRepository.server.ts +++ b/app/features/scanner-ingest/ScannerIngestRepository.server.ts @@ -4,6 +4,7 @@ import { sql, type Transaction } from "kysely"; import { db } from "~/db/sql"; import type { DB } from "~/db/tables"; import type { ScannerMatch } from "~/features/scanner/core/scanner-match"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; import * as Matches from "./core/Matches"; import type { IngestableGame, @@ -25,210 +26,15 @@ const MERGE_CANDIDATE_PLAYED_AT_WINDOW_DAYS = 1; const MERGE_CANDIDATE_CREATED_AT_WINDOW_DAYS = 7; const MERGE_CANDIDATE_LIMIT = 50; -/** - * Stores ingested matches, merging partials: a match that - * `Matches.isSameMatch` recognizes as an already stored one (same POV user - * scope) enriches that row instead of inserting. Identical resends are - * no-ops via the content hash. The resolved context is stamped onto the - * rows as tournamentIdHint/groupMatchIdHint (existing hints win; missing - * ones are backfilled even on no-op resends). - * - * @returns counts plus the post-merge rows (a partial arriving after an - * earlier richer send links downstream with the merged, fuller data) - */ -export async function addOrMergeMatches({ - povUserId, - submitterUserId, - matches, - context, -}: { - povUserId: number | null; - submitterUserId: number | null; - matches: ScannerMatch[]; - context: IngestContext | null; -}) { - const hints = { - tournamentIdHint: - context?.type === "tournament" ? context.tournamentId : null, - groupMatchIdHint: context?.type === "sendouq" ? context.groupMatchId : null, - }; +/** How long before the events' timestamp their match may have started (long sets, swiss rounds get startedAt at creation). */ +const MATCH_WINDOW_BEFORE_SECONDS = 4 * 60 * 60; +/** Event timestamps come from client clocks, so allow the match to have "started" a little after them. */ +const MATCH_WINDOW_AFTER_SECONDS = 60 * 60; - let insertedCount = 0; - let mergedCount = 0; - const effectiveMatches: Array<{ id: number; data: ScannerMatch }> = []; - - for (const match of matches) { - const canonical = Matches.canonicalMatch(match); - const hash = matchHash({ povUserId, match: canonical }); - - const effective = await db.transaction().execute(async (trx) => { - const identical = await trx - .selectFrom("IngestedMatch") - .select(["id", "data", "tournamentIdHint", "groupMatchIdHint"]) - .where("matchHash", "=", hash) - .executeTakeFirst(); - if (identical) { - await backfillHints(trx, identical, hints); - return { id: identical.id, data: identical.data }; - } - - const stored = await findMergeCandidate(trx, { - povUserId, - match: canonical, - }); - if (!stored) { - const inserted = await trx - .insertInto("IngestedMatch") - .values({ - povUserId, - submitterUserId, - playedAt: toDbTimestamp(canonical.playedAt), - data: JSON.stringify(canonical), - matchHash: hash, - ...hints, - }) - .returning("id") - .executeTakeFirstOrThrow(); - insertedCount++; - return { id: inserted.id, data: canonical }; - } - - const { merged, changed } = Matches.mergeMatches(stored.data, canonical); - if (!changed) { - await backfillHints(trx, stored, hints); - return { id: stored.id, data: stored.data }; - } - - const mergedCanonical = Matches.canonicalMatch(merged); - await trx - .updateTable("IngestedMatch") - .set({ - playedAt: toDbTimestamp(mergedCanonical.playedAt), - data: JSON.stringify(mergedCanonical), - matchHash: matchHash({ povUserId, match: mergedCanonical }), - tournamentIdHint: stored.tournamentIdHint ?? hints.tournamentIdHint, - groupMatchIdHint: stored.groupMatchIdHint ?? hints.groupMatchIdHint, - }) - .where("id", "=", stored.id) - .execute(); - mergedCount++; - return { id: stored.id, data: mergedCanonical }; - }); - - effectiveMatches.push(effective); - } - - return { insertedCount, mergedCount, effectiveMatches }; -} - -async function backfillHints( - trx: Transaction, - stored: { - id: number; - tournamentIdHint: number | null; - groupMatchIdHint: number | null; - }, - hints: { tournamentIdHint: number | null; groupMatchIdHint: number | null }, -) { - const tournamentIdHint = stored.tournamentIdHint ?? hints.tournamentIdHint; - const groupMatchIdHint = stored.groupMatchIdHint ?? hints.groupMatchIdHint; - if ( - tournamentIdHint === stored.tournamentIdHint && - groupMatchIdHint === stored.groupMatchIdHint - ) { - return; - } - - await trx - .updateTable("IngestedMatch") - .set({ tournamentIdHint, groupMatchIdHint }) - .where("id", "=", stored.id) - .execute(); -} - -/** - * The stored match the incoming one describes the same game as, if any: - * rows in the same POV user scope, near in play time (or recent when either - * side has none), content-checked by Matches.isSameMatch. - */ -async function findMergeCandidate( - trx: Transaction, - { - povUserId, - match, - }: { - povUserId: number | null; - match: ScannerMatch; - }, -) { - const createdAfter = Math.floor( - subDays(new Date(), MERGE_CANDIDATE_CREATED_AT_WINDOW_DAYS).getTime() / - 1000, - ); - - const candidates = await trx - .selectFrom("IngestedMatch") - .select(["id", "data", "tournamentIdHint", "groupMatchIdHint"]) - .$if(povUserId === null, (qb) => qb.where("povUserId", "is", null)) - .$if(povUserId !== null, (qb) => qb.where("povUserId", "=", povUserId!)) - .$if(match.playedAt !== null, (qb) => - qb.where((eb) => - eb.or([ - eb.and([ - eb( - "playedAt", - ">=", - toDbTimestamp( - subDays( - match.playedAt!, - MERGE_CANDIDATE_PLAYED_AT_WINDOW_DAYS, - ).getTime(), - ), - ), - eb( - "playedAt", - "<=", - toDbTimestamp(match.playedAt!)! + - MERGE_CANDIDATE_PLAYED_AT_WINDOW_DAYS * 24 * 60 * 60, - ), - ]), - eb.and([ - eb("playedAt", "is", null), - eb("createdAt", ">=", createdAfter), - ]), - ]), - ), - ) - .$if(match.playedAt === null, (qb) => - qb.where("createdAt", ">=", createdAfter), - ) - .orderBy("createdAt", "desc") - .limit(MERGE_CANDIDATE_LIMIT) - .execute(); - - return ( - candidates.find((candidate) => - Matches.isSameMatch(candidate.data, match), - ) ?? null - ); -} - -/** wall-clock ms → database timestamp (seconds) */ -function toDbTimestamp(ms: number | null): number | null { - return ms === null ? null : Math.floor(ms / 1000); -} - -function matchHash({ - povUserId, - match, -}: { - povUserId: number | null; - match: ScannerMatch; -}) { - return createHash("sha256") - .update(JSON.stringify([povUserId, match])) - .digest("hex"); -} +/** SendouQ sets run well under this long; matches created further before the events cannot be theirs. */ +const GROUP_MATCH_WINDOW_BEFORE_SECONDS = 2 * 60 * 60; +/** Event timestamps come from client clocks, so allow the match to have been created a little after them. */ +const GROUP_MATCH_WINDOW_AFTER_SECONDS = 60 * 60; /** Returns the games a user played in a tournament, in chronological order. */ export function gamesPlayedByUserInTournament(params: { @@ -279,6 +85,568 @@ export async function castedGamesInTournament(tournamentId: number) { return tournamentGames({ tournamentId, tournamentMatchIds }); } +/** Returns a SendouQ match's games (its whole map list), in map order. */ +export function gamesInGroupMatch(groupMatchId: number) { + return sendouqGames({ groupMatchId }); +} + +/** + * Returns the reported games of SendouQ matches a user played in since the + * given database timestamp, in chronological order — SendouQ candidates for + * content-based context resolution (Scoreboards.resolveContext). + */ +export function sendouqGamesPlayedByUserSince(params: { + userId: number; + /** database timestamp (seconds) */ + since: number; +}) { + return sendouqGames(params); +} + +/** + * The tournament the user was (probably) playing in at the given wall-clock + * time: their team is in a match whose `startedAt` is close enough before + * `at`. When several qualify (rare) the latest-started one wins. + */ +// xxx: change it to more generic. user id + stage + mode + startedAt -> what scrim, sq, tournament if any? +export async function tournamentIdAt({ + userId, + at, +}: { + userId: number; + /** wall-clock ms */ + at: number; +}) { + const atSeconds = toDbTimestamp(at)!; + + const row = await db + .selectFrom("TournamentTeamMember") + .innerJoin( + "TournamentTeam", + "TournamentTeam.id", + "TournamentTeamMember.tournamentTeamId", + ) + .innerJoin( + "TournamentStage", + "TournamentStage.tournamentId", + "TournamentTeam.tournamentId", + ) + .innerJoin( + "TournamentMatch", + "TournamentMatch.stageId", + "TournamentStage.id", + ) + .select("TournamentTeam.tournamentId") + .where("TournamentTeamMember.userId", "=", userId) + .where((eb) => + eb.or([ + eb(opponentOneId, "=", eb.ref("TournamentTeam.id")), + eb(opponentTwoId, "=", eb.ref("TournamentTeam.id")), + ]), + ) + .where( + "TournamentMatch.startedAt", + "<=", + atSeconds + MATCH_WINDOW_AFTER_SECONDS, + ) + .where( + "TournamentMatch.startedAt", + ">=", + atSeconds - MATCH_WINDOW_BEFORE_SECONDS, + ) + .orderBy("TournamentMatch.startedAt", "desc") + .executeTakeFirst(); + + return row?.tournamentId ?? null; +} + +/** + * The SendouQ match the user was (probably) playing at the given wall-clock + * time: a group they are a member of is in a non-canceled match created + * close enough before `at`. When several qualify the latest-created wins. + */ +export async function groupMatchIdAt({ + userId, + at, +}: { + userId: number; + /** wall-clock ms */ + at: number; +}) { + const atSeconds = toDbTimestamp(at)!; + + const row = await db + .selectFrom("GroupMatch") + .select("GroupMatch.id") + .where((eb) => + eb.exists( + eb + .selectFrom("GroupMember") + .select("GroupMember.userId") + .where("GroupMember.userId", "=", userId) + .where((memberEb) => + memberEb.or([ + memberEb( + "GroupMember.groupId", + "=", + memberEb.ref("GroupMatch.alphaGroupId"), + ), + memberEb( + "GroupMember.groupId", + "=", + memberEb.ref("GroupMatch.bravoGroupId"), + ), + ]), + ), + ), + ) + .where( + "GroupMatch.createdAt", + "<=", + atSeconds + GROUP_MATCH_WINDOW_AFTER_SECONDS, + ) + .where( + "GroupMatch.createdAt", + ">=", + atSeconds - GROUP_MATCH_WINDOW_BEFORE_SECONDS, + ) + .where("GroupMatch.cancelAcceptedByUserId", "is", null) + .orderBy("GroupMatch.createdAt", "desc") + .executeTakeFirst(); + + return row?.id ?? null; +} + +/** + * Tournaments running a match around the given wall-clock time that the + * user helps run: they authored the event, are on its staff (organizer or + * streamer), or hold an admin/organizer/streamer role in its organization. + * The candidate contexts for cast footage. + */ +export async function staffTournamentIdsAt({ + userId, + at, +}: { + userId: number; + /** wall-clock ms */ + at: number; +}): Promise { + const atSeconds = toDbTimestamp(at)!; + + const rows = await db + .selectFrom("TournamentMatch") + .innerJoin( + "TournamentStage", + "TournamentStage.id", + "TournamentMatch.stageId", + ) + .innerJoin( + "CalendarEvent", + "CalendarEvent.tournamentId", + "TournamentStage.tournamentId", + ) + .select("TournamentStage.tournamentId") + .distinct() + .where( + "TournamentMatch.startedAt", + "<=", + atSeconds + MATCH_WINDOW_AFTER_SECONDS, + ) + .where( + "TournamentMatch.startedAt", + ">=", + atSeconds - MATCH_WINDOW_BEFORE_SECONDS, + ) + .where((eb) => + eb.or([ + eb("CalendarEvent.authorId", "=", userId), + eb.exists( + eb + .selectFrom("TournamentStaff") + .select("TournamentStaff.userId") + .whereRef( + "TournamentStaff.tournamentId", + "=", + "TournamentStage.tournamentId", + ) + .where("TournamentStaff.userId", "=", userId), + ), + eb.exists( + eb + .selectFrom("TournamentOrganizationMember") + .select("TournamentOrganizationMember.userId") + .whereRef( + "TournamentOrganizationMember.organizationId", + "=", + "CalendarEvent.organizationId", + ) + .where("TournamentOrganizationMember.userId", "=", userId) + .where("TournamentOrganizationMember.role", "in", [ + "ADMIN", + "ORGANIZER", + "STREAMER", + ]), + ), + ]), + ) + .execute(); + + return rows.map((row) => row.tournamentId); +} + +/** + * Returns a tournament match's ingested scoreboards with their 0-based map + * indexes, each derived from the game's linked ingested matches. + */ +export async function findScoreboardsByTournamentMatchId( + tournamentMatchId: number, +) { + const rows = await db + .selectFrom("IngestedMatchLink") + .innerJoin( + "IngestedMatch", + "IngestedMatch.id", + "IngestedMatchLink.ingestedMatchId", + ) + .innerJoin( + "TournamentMatchGameResult", + "TournamentMatchGameResult.id", + "IngestedMatchLink.tournamentMatchGameResultId", + ) + .innerJoin( + "TournamentMatch", + "TournamentMatch.id", + "TournamentMatchGameResult.matchId", + ) + .select([ + "TournamentMatchGameResult.id as matchGameResultId", + "TournamentMatchGameResult.number", + "TournamentMatchGameResult.winnerTeamId", + opponentOneId.as("opponentOneId"), + opponentTwoId.as("opponentTwoId"), + "IngestedMatch.data", + "IngestedMatch.povUserId", + ]) + .where("TournamentMatchGameResult.matchId", "=", tournamentMatchId) + .orderBy("TournamentMatchGameResult.number", "asc") + .orderBy("IngestedMatchLink.createdAt", "asc") + .orderBy("IngestedMatchLink.id", "asc") + .execute(); + + const byGame = new Map(); + for (const row of rows) { + const gameRows = byGame.get(row.matchGameResultId) ?? []; + gameRows.push(row); + byGame.set(row.matchGameResultId, gameRows); + } + + return [...byGame.values()].flatMap((gameRows) => { + const first = gameRows[0]!; + const loserTeamId = + first.winnerTeamId === first.opponentOneId + ? first.opponentTwoId + : first.winnerTeamId === first.opponentTwoId + ? first.opponentOneId + : null; + + const data = Scoreboards.deriveScoreboardData({ + linked: gameRows.map((row) => ({ + data: row.data, + povUserId: row.povUserId, + })), + winnerTeamId: first.winnerTeamId, + loserTeamId, + }); + if (!data) return []; + + return [{ mapIndex: first.number - 1, data }]; + }); +} + +/** + * Stores ingested matches, merging partials: a match that + * `Matches.isSameMatch` recognizes as an already stored one (same POV user + * scope) enriches that row instead of inserting. Identical resends are + * no-ops via the content hash. The resolved context is stamped onto the + * rows as tournamentIdHint/groupMatchIdHint (existing hints win; missing + * ones are backfilled even on no-op resends). + * + * @returns counts plus the post-merge rows (a partial arriving after an + * earlier richer send links downstream with the merged, fuller data) + */ +export async function addOrMergeMatches({ + povUserId, + submitterUserId, + matches, + context, +}: { + povUserId: number | null; + submitterUserId: number | null; + matches: ScannerMatch[]; + context: IngestContext | null; +}) { + const hints = { + tournamentIdHint: + context?.type === "tournament" ? context.tournamentId : null, + groupMatchIdHint: context?.type === "sendouq" ? context.groupMatchId : null, + }; + + return db.transaction().execute(async (trx) => { + let insertedCount = 0; + let mergedCount = 0; + const effectiveMatches: Array<{ id: number; data: ScannerMatch }> = []; + + for (const match of matches) { + const effective = await addOrMergeMatch(trx, { + povUserId, + submitterUserId, + match, + hints, + }); + if (effective.outcome === "inserted") insertedCount++; + if (effective.outcome === "merged") mergedCount++; + effectiveMatches.push({ id: effective.id, data: effective.data }); + } + + return { insertedCount, mergedCount, effectiveMatches }; + }); +} + +/** + * Links ingested matches to the game results they were matched to. A row + * links to at most one game (re-sends are no-ops); one game may collect + * links from many rows (each POV's scan of it). When the row's POV player + * is known, their weapon is reported as a regular ReportedWeapon, unless + * the user already has one for that game. + * + * @returns count of newly created links + */ +export async function addLinks({ + links, + povUserId, +}: { + links: Array<{ + ingestedMatchId: number; + match: ScannerMatch; + game: IngestableGame; + }>; + povUserId: number | null; +}) { + return db.transaction().execute(async (trx) => { + let linkedCount = 0; + + for (const link of links) { + const insertResult = await trx + .insertInto("IngestedMatchLink") + .values({ + ingestedMatchId: link.ingestedMatchId, + tournamentMatchGameResultId: + link.game.target.type === "tournament" + ? link.game.target.matchGameResultId + : null, + groupMatchMapId: + link.game.target.type === "sendouq" + ? link.game.target.groupMatchMapId + : null, + }) + .onConflict((oc) => oc.column("ingestedMatchId").doNothing()) + .executeTakeFirst(); + + await reportPovWeapon(trx, link, povUserId); + + if (Number(insertResult.numInsertedOrUpdatedRows ?? 0) > 0) { + linkedCount++; + } + } + + return linkedCount; + }); +} + +async function addOrMergeMatch( + trx: Transaction, + { + povUserId, + submitterUserId, + match, + hints, + }: { + povUserId: number | null; + submitterUserId: number | null; + match: ScannerMatch; + hints: { tournamentIdHint: number | null; groupMatchIdHint: number | null }; + }, +): Promise<{ + id: number; + data: ScannerMatch; + outcome: "inserted" | "merged" | "unchanged"; +}> { + const canonical = Matches.canonicalMatch(match); + const hash = matchHash({ povUserId, match: canonical }); + + const identical = await trx + .selectFrom("IngestedMatch") + .select(["id", "data", "tournamentIdHint", "groupMatchIdHint"]) + .where("matchHash", "=", hash) + .executeTakeFirst(); + if (identical) { + await backfillHints(trx, identical, hints); + return { id: identical.id, data: identical.data, outcome: "unchanged" }; + } + + const stored = await findMergeCandidate(trx, { + povUserId, + match: canonical, + }); + if (!stored) { + const inserted = await trx + .insertInto("IngestedMatch") + .values({ + povUserId, + submitterUserId, + playedAt: toDbTimestamp(canonical.playedAt), + data: JSON.stringify(canonical), + matchHash: hash, + ...hints, + }) + .returning("id") + .executeTakeFirstOrThrow(); + return { id: inserted.id, data: canonical, outcome: "inserted" }; + } + + const { merged, changed } = Matches.mergeMatches(stored.data, canonical); + if (!changed) { + await backfillHints(trx, stored, hints); + return { id: stored.id, data: stored.data, outcome: "unchanged" }; + } + + const mergedCanonical = Matches.canonicalMatch(merged); + await trx + .updateTable("IngestedMatch") + .set({ + playedAt: toDbTimestamp(mergedCanonical.playedAt), + data: JSON.stringify(mergedCanonical), + matchHash: matchHash({ povUserId, match: mergedCanonical }), + tournamentIdHint: stored.tournamentIdHint ?? hints.tournamentIdHint, + groupMatchIdHint: stored.groupMatchIdHint ?? hints.groupMatchIdHint, + }) + .where("id", "=", stored.id) + .execute(); + return { id: stored.id, data: mergedCanonical, outcome: "merged" }; +} + +async function backfillHints( + trx: Transaction, + stored: { + id: number; + tournamentIdHint: number | null; + groupMatchIdHint: number | null; + }, + hints: { tournamentIdHint: number | null; groupMatchIdHint: number | null }, +) { + const tournamentIdHint = stored.tournamentIdHint ?? hints.tournamentIdHint; + const groupMatchIdHint = stored.groupMatchIdHint ?? hints.groupMatchIdHint; + if ( + tournamentIdHint === stored.tournamentIdHint && + groupMatchIdHint === stored.groupMatchIdHint + ) { + return; + } + + await trx + .updateTable("IngestedMatch") + .set({ tournamentIdHint, groupMatchIdHint }) + .where("id", "=", stored.id) + .execute(); +} + +/** + * The stored match the incoming one describes the same game as, if any: + * rows in the same POV user scope, near in play time (or recent when either + * side has none), content-checked by Matches.isSameMatch. + */ +async function findMergeCandidate( + trx: Transaction, + { + povUserId, + match, + }: { + povUserId: number | null; + match: ScannerMatch; + }, +) { + const createdAfter = dateToDatabaseTimestamp( + subDays(new Date(), MERGE_CANDIDATE_CREATED_AT_WINDOW_DAYS), + ); + + // one query per branch (playedAt window / playedAt-less recent rows) + // instead of an OR, so each can use the (povUserId, playedAt) index + const baseQuery = trx + .selectFrom("IngestedMatch") + .select(["id", "data", "tournamentIdHint", "groupMatchIdHint", "createdAt"]) + .$if(povUserId === null, (qb) => qb.where("povUserId", "is", null)) + .$if(povUserId !== null, (qb) => qb.where("povUserId", "=", povUserId!)) + .orderBy("createdAt", "desc") + .limit(MERGE_CANDIDATE_LIMIT); + + const candidates = + match.playedAt === null + ? await baseQuery.where("createdAt", ">=", createdAfter).execute() + : newestFirst( + await baseQuery + .where( + "playedAt", + ">=", + toDbTimestamp( + subDays( + match.playedAt, + MERGE_CANDIDATE_PLAYED_AT_WINDOW_DAYS, + ).getTime(), + ), + ) + .where( + "playedAt", + "<=", + toDbTimestamp(match.playedAt)! + + MERGE_CANDIDATE_PLAYED_AT_WINDOW_DAYS * 24 * 60 * 60, + ) + .execute(), + await baseQuery + .where("playedAt", "is", null) + .where("createdAt", ">=", createdAfter) + .execute(), + ); + + return ( + candidates.find((candidate) => + Matches.isSameMatch(candidate.data, match), + ) ?? null + ); +} + +function newestFirst(a: T[], b: T[]): T[] { + return [...a, ...b] + .sort((x, y) => y.createdAt - x.createdAt) + .slice(0, MERGE_CANDIDATE_LIMIT); +} + +/** wall-clock ms → database timestamp (seconds) */ +function toDbTimestamp(ms: number | null): number | null { + return ms === null ? null : Math.floor(ms / 1000); +} + +function matchHash({ + povUserId, + match, +}: { + povUserId: number | null; + match: ScannerMatch; +}) { + return createHash("sha256") + .update(JSON.stringify([povUserId, match])) + .digest("hex"); +} + async function tournamentGames({ userId, tournamentId, @@ -410,24 +778,6 @@ async function teamInGameNames(teamIds: Array) { return result; } -/** Returns a SendouQ match's games (its whole map list), in map order. */ -export function gamesInGroupMatch(groupMatchId: number) { - return sendouqGames({ groupMatchId }); -} - -/** - * Returns the reported games of SendouQ matches a user played in since the - * given database timestamp, in chronological order — SendouQ candidates for - * content-based context resolution (Scoreboards.resolveContext). - */ -export function sendouqGamesPlayedByUserSince(params: { - userId: number; - /** database timestamp (seconds) */ - since: number; -}) { - return sendouqGames(params); -} - async function sendouqGames({ groupMatchId, userId, @@ -584,258 +934,6 @@ async function linkedPlayerNamesByTarget( return result; } -/** How long before the events' timestamp their match may have started (long sets, swiss rounds get startedAt at creation). */ -const MATCH_WINDOW_BEFORE_SECONDS = 4 * 60 * 60; -/** Event timestamps come from client clocks, so allow the match to have "started" a little after them. */ -const MATCH_WINDOW_AFTER_SECONDS = 60 * 60; - -/** - * The tournament the user was (probably) playing in at the given wall-clock - * time: their team is in a match whose `startedAt` is close enough before - * `at`. When several qualify (rare) the latest-started one wins. - */ -// xxx: change it to more generic. user id + stage + mode + startedAt -> what scrim, sq, tournament if any? -export async function tournamentIdAt({ - userId, - at, -}: { - userId: number; - /** wall-clock ms */ - at: number; -}) { - const atSeconds = Math.floor(at / 1000); - - const row = await db - .selectFrom("TournamentTeamMember") - .innerJoin( - "TournamentTeam", - "TournamentTeam.id", - "TournamentTeamMember.tournamentTeamId", - ) - .innerJoin( - "TournamentStage", - "TournamentStage.tournamentId", - "TournamentTeam.tournamentId", - ) - .innerJoin( - "TournamentMatch", - "TournamentMatch.stageId", - "TournamentStage.id", - ) - .select("TournamentTeam.tournamentId") - .where("TournamentTeamMember.userId", "=", userId) - .where((eb) => - eb.or([ - eb(opponentOneId, "=", eb.ref("TournamentTeam.id")), - eb(opponentTwoId, "=", eb.ref("TournamentTeam.id")), - ]), - ) - .where( - "TournamentMatch.startedAt", - "<=", - atSeconds + MATCH_WINDOW_AFTER_SECONDS, - ) - .where( - "TournamentMatch.startedAt", - ">=", - atSeconds - MATCH_WINDOW_BEFORE_SECONDS, - ) - .orderBy("TournamentMatch.startedAt", "desc") - .executeTakeFirst(); - - return row?.tournamentId ?? null; -} - -/** SendouQ sets run well under this long; matches created further before the events cannot be theirs. */ -const GROUP_MATCH_WINDOW_BEFORE_SECONDS = 2 * 60 * 60; -/** Event timestamps come from client clocks, so allow the match to have been created a little after them. */ -const GROUP_MATCH_WINDOW_AFTER_SECONDS = 60 * 60; - -/** - * The SendouQ match the user was (probably) playing at the given wall-clock - * time: a group they are a member of is in a non-canceled match created - * close enough before `at`. When several qualify the latest-created wins. - */ -export async function groupMatchIdAt({ - userId, - at, -}: { - userId: number; - /** wall-clock ms */ - at: number; -}) { - const atSeconds = Math.floor(at / 1000); - - const row = await db - .selectFrom("GroupMatch") - .select("GroupMatch.id") - .where((eb) => - eb.exists( - eb - .selectFrom("GroupMember") - .select("GroupMember.userId") - .where("GroupMember.userId", "=", userId) - .where((memberEb) => - memberEb.or([ - memberEb( - "GroupMember.groupId", - "=", - memberEb.ref("GroupMatch.alphaGroupId"), - ), - memberEb( - "GroupMember.groupId", - "=", - memberEb.ref("GroupMatch.bravoGroupId"), - ), - ]), - ), - ), - ) - .where( - "GroupMatch.createdAt", - "<=", - atSeconds + GROUP_MATCH_WINDOW_AFTER_SECONDS, - ) - .where( - "GroupMatch.createdAt", - ">=", - atSeconds - GROUP_MATCH_WINDOW_BEFORE_SECONDS, - ) - .where("GroupMatch.cancelAcceptedByUserId", "is", null) - .orderBy("GroupMatch.createdAt", "desc") - .executeTakeFirst(); - - return row?.id ?? null; -} - -/** - * Tournaments running a match around the given wall-clock time that the - * user helps run: they authored the event, are on its staff (organizer or - * streamer), or hold an admin/organizer/streamer role in its organization. - * The candidate contexts for cast footage. - */ -export async function staffTournamentIdsAt({ - userId, - at, -}: { - userId: number; - /** wall-clock ms */ - at: number; -}): Promise { - const atSeconds = Math.floor(at / 1000); - - const rows = await db - .selectFrom("TournamentMatch") - .innerJoin( - "TournamentStage", - "TournamentStage.id", - "TournamentMatch.stageId", - ) - .innerJoin( - "CalendarEvent", - "CalendarEvent.tournamentId", - "TournamentStage.tournamentId", - ) - .select("TournamentStage.tournamentId") - .distinct() - .where( - "TournamentMatch.startedAt", - "<=", - atSeconds + MATCH_WINDOW_AFTER_SECONDS, - ) - .where( - "TournamentMatch.startedAt", - ">=", - atSeconds - MATCH_WINDOW_BEFORE_SECONDS, - ) - .where((eb) => - eb.or([ - eb("CalendarEvent.authorId", "=", userId), - eb.exists( - eb - .selectFrom("TournamentStaff") - .select("TournamentStaff.userId") - .whereRef( - "TournamentStaff.tournamentId", - "=", - "TournamentStage.tournamentId", - ) - .where("TournamentStaff.userId", "=", userId), - ), - eb.exists( - eb - .selectFrom("TournamentOrganizationMember") - .select("TournamentOrganizationMember.userId") - .whereRef( - "TournamentOrganizationMember.organizationId", - "=", - "CalendarEvent.organizationId", - ) - .where("TournamentOrganizationMember.userId", "=", userId) - .where("TournamentOrganizationMember.role", "in", [ - "ADMIN", - "ORGANIZER", - "STREAMER", - ]), - ), - ]), - ) - .execute(); - - return rows.map((row) => row.tournamentId); -} - -/** - * Links ingested matches to the game results they were matched to. A row - * links to at most one game (re-sends are no-ops); one game may collect - * links from many rows (each POV's scan of it). When the row's POV player - * is known, their weapon is reported as a regular ReportedWeapon, unless - * the user already has one for that game. - * - * @returns count of newly created links - */ -export async function addLinks({ - links, - povUserId, -}: { - links: Array<{ - ingestedMatchId: number; - match: ScannerMatch; - game: IngestableGame; - }>; - povUserId: number | null; -}) { - let linkedCount = 0; - - for (const link of links) { - const wasInserted = await db.transaction().execute(async (trx) => { - const insertResult = await trx - .insertInto("IngestedMatchLink") - .values({ - ingestedMatchId: link.ingestedMatchId, - tournamentMatchGameResultId: - link.game.target.type === "tournament" - ? link.game.target.matchGameResultId - : null, - groupMatchMapId: - link.game.target.type === "sendouq" - ? link.game.target.groupMatchMapId - : null, - }) - .onConflict((oc) => oc.column("ingestedMatchId").doNothing()) - .executeTakeFirst(); - - await reportPovWeapon(trx, link, povUserId); - - return Number(insertResult.numInsertedOrUpdatedRows ?? 0) > 0; - }); - - if (wasInserted) linkedCount++; - } - - return linkedCount; -} - async function reportPovWeapon( trx: Transaction, { match, game }: { match: ScannerMatch; game: IngestableGame }, @@ -870,72 +968,3 @@ async function reportPovWeapon( ) .execute(); } - -/** - * Returns a tournament match's ingested scoreboards with their 0-based map - * indexes, each derived from the game's linked ingested matches. - */ -export async function findScoreboardsByTournamentMatchId( - tournamentMatchId: number, -) { - const rows = await db - .selectFrom("IngestedMatchLink") - .innerJoin( - "IngestedMatch", - "IngestedMatch.id", - "IngestedMatchLink.ingestedMatchId", - ) - .innerJoin( - "TournamentMatchGameResult", - "TournamentMatchGameResult.id", - "IngestedMatchLink.tournamentMatchGameResultId", - ) - .innerJoin( - "TournamentMatch", - "TournamentMatch.id", - "TournamentMatchGameResult.matchId", - ) - .select([ - "TournamentMatchGameResult.id as matchGameResultId", - "TournamentMatchGameResult.number", - "TournamentMatchGameResult.winnerTeamId", - opponentOneId.as("opponentOneId"), - opponentTwoId.as("opponentTwoId"), - "IngestedMatch.data", - "IngestedMatch.povUserId", - ]) - .where("TournamentMatchGameResult.matchId", "=", tournamentMatchId) - .orderBy("TournamentMatchGameResult.number", "asc") - .orderBy("IngestedMatchLink.createdAt", "asc") - .orderBy("IngestedMatchLink.id", "asc") - .execute(); - - const byGame = new Map(); - for (const row of rows) { - const gameRows = byGame.get(row.matchGameResultId) ?? []; - gameRows.push(row); - byGame.set(row.matchGameResultId, gameRows); - } - - return [...byGame.values()].flatMap((gameRows) => { - const first = gameRows[0]!; - const loserTeamId = - first.winnerTeamId === first.opponentOneId - ? first.opponentTwoId - : first.winnerTeamId === first.opponentTwoId - ? first.opponentOneId - : null; - - const data = Scoreboards.deriveScoreboardData({ - linked: gameRows.map((row) => ({ - data: row.data, - povUserId: row.povUserId, - })), - winnerTeamId: first.winnerTeamId, - loserTeamId, - }); - if (!data) return []; - - return [{ mapIndex: first.number - 1, data }]; - }); -} diff --git a/app/features/scanner-ingest/actions/scanner-ingest.server.ts b/app/features/scanner-ingest/actions/scanner-ingest.server.ts index 0a3c9d63b..006bfac50 100644 --- a/app/features/scanner-ingest/actions/scanner-ingest.server.ts +++ b/app/features/scanner-ingest/actions/scanner-ingest.server.ts @@ -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({ diff --git a/app/features/scanner-ingest/core/Matches.ts b/app/features/scanner-ingest/core/Matches.ts index 3e0f1cf03..442da49da 100644 --- a/app/features/scanner-ingest/core/Matches.ts +++ b/app/features/scanner-ingest/core/Matches.ts @@ -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]; diff --git a/app/features/scanner-ingest/core/Scoreboards.test.ts b/app/features/scanner-ingest/core/Scoreboards.test.ts index 4376d791c..4b2a8525e 100644 --- a/app/features/scanner-ingest/core/Scoreboards.test.ts +++ b/app/features/scanner-ingest/core/Scoreboards.test.ts @@ -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(), diff --git a/app/features/scanner-ingest/core/Scoreboards.ts b/app/features/scanner-ingest/core/Scoreboards.ts index f3553fa4f..23272fc39 100644 --- a/app/features/scanner-ingest/core/Scoreboards.ts +++ b/app/features/scanner-ingest/core/Scoreboards.ts @@ -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); diff --git a/app/features/scanner-ingest/scanner-ingest-schemas.ts b/app/features/scanner-ingest/scanner-ingest-schemas.ts index 0cb880486..7e03a0dbe 100644 --- a/app/features/scanner-ingest/scanner-ingest-schemas.ts +++ b/app/features/scanner-ingest/scanner-ingest-schemas.ts @@ -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), }); diff --git a/app/features/tournament-match/components/TournamentMatchTabs.tsx b/app/features/tournament-match/components/TournamentMatchTabs.tsx index dcbbc0db3..612ded882 100644 --- a/app/features/tournament-match/components/TournamentMatchTabs.tsx +++ b/app/features/tournament-match/components/TournamentMatchTabs.tsx @@ -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, 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(); - 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); diff --git a/app/features/tournament-match/tournament-match-utils.test.ts b/app/features/tournament-match/tournament-match-utils.test.ts index e884be5ab..00a0ed831 100644 --- a/app/features/tournament-match/tournament-match-utils.test.ts +++ b/app/features/tournament-match/tournament-match-utils.test.ts @@ -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 { + 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]); + }); +}); diff --git a/app/features/tournament-match/tournament-match-utils.ts b/app/features/tournament-match/tournament-match-utils.ts index d7ba71bdd..8325e33f0 100644 --- a/app/features/tournament-match/tournament-match-utils.ts +++ b/app/features/tournament-match/tournament-match-utils.ts @@ -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(); + 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, diff --git a/app/form/SendouForm.browser.test.tsx b/app/form/SendouForm.browser.test.tsx index cf528ad54..e813e4217 100644 --- a/app/form/SendouForm.browser.test.tsx +++ b/app/form/SendouForm.browser.test.tsx @@ -22,6 +22,7 @@ import { userSearch, } from "./fields"; import { SendouForm, useFormFieldContext } from "./SendouForm"; +import type { ArrayItemRenderContext } from "./types"; let mockFetcherData: { fieldErrors?: Record } | 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; + onApply?: (values: Record) => void; + }) { + const router = createMemoryRouter( + [ + { + path: "/", + element: ( + + + {(ctx: ArrayItemRenderContext) => ( +
+
+ {ctx.values.name as string} /{" "} + {ctx.values.role as string} +
+ + {ctx.canRemove ? ( + + ) : null} +
+ )} +
+
+ ), + }, + ], + { initialEntries: ["/"] }, + ); + + return render(); + } + + const memberTestIds = (screen: Awaited>) => + 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: ( + + + {(ctx: ArrayItemRenderContext) => ( + + )} + + + ), + }, + ], + { initialEntries: ["/"] }, + ); + + const screen = await render(); + 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 = {}; diff --git a/migrations/20260804000000-ingest.ts b/migrations/20260804000000-ingest.ts index 19bd3fafb..db1d710b3 100644 --- a/migrations/20260804000000-ingest.ts +++ b/migrations/20260804000000-ingest.ts @@ -84,5 +84,12 @@ export async function up(db: Kysely): Promise { .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(); }); } diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index d8c52070b..8fa249911 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -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", diff --git a/scripts/benchmark-db/fixtures.ts b/scripts/benchmark-db/fixtures.ts index 300f0e959..0c2dea2fb 100644 --- a/scripts/benchmark-db/fixtures.ts +++ b/scripts/benchmark-db/fixtures.ts @@ -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 { 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().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().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; +}