diff --git a/.env.example b/.env.example index 1ae987ffc..f40055661 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,9 @@ SQL_LOG=none VITE_SHOW_LUTI_NAV_ITEM=false +// If false the scanner page and its ingest endpoint are admin only +VITE_SCANNER_ENABLED=false + // Push notification. Generate values here https://vapidkeys.com/ VITE_VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= diff --git a/.gitignore b/.gitignore index 844ddee71..473235b62 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ dump .e2e-build-marker notepad.txt + +# proprietary game fonts for the scanner glyph-atlas builders (scripts/scanner) +/assets/fonts/ diff --git a/AGENTS.md b/AGENTS.md index 7a515827c..bf000a8a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,3 +98,11 @@ - use the template `/github/pull_request_template.md` - do not mention claude or claude code in the description + +## Scanner feature (app/features/scanner) + +- computer-vision match-event detection; full docs in `app/features/scanner/README.md` — read it before touching detector/recognition code +- OpenCV ROI-view gotcha: `.data`/`.clone()` are broken on ROI views — always `view.copyTo(freshMat)` before pixel access +- fixture workflow: every live misread becomes a fixture under `app/features/scanner/tests/fixtures/`; ground-truth labels are hand-corrected by the maintainer and definitive over any matcher output +- test with `pnpm test:scanner`; accuracy report with `pnpm scanner:report`; atlas regen commands and the assets-repo/CDN flow are in the README +- events, snap tables, and fixtures speak sendou ids (`ModeShort`/`StageId`/weapon ids/`Ability`) — never reintroduce English game-name literals outside the generated localized snap tables diff --git a/app/components/FormWithConfirm.tsx b/app/components/FormWithConfirm.tsx index 8661e677c..bc06fa3bb 100644 --- a/app/components/FormWithConfirm.tsx +++ b/app/components/FormWithConfirm.tsx @@ -2,7 +2,10 @@ import * as React from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { type FetcherWithComponents, useFetcher } from "react-router"; -import type { SendouButtonProps } from "~/components/elements/Button"; +import { + SendouButton, + type SendouButtonProps, +} from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; import { useHydrated } from "~/hooks/useHydrated"; import invariant from "~/utils/invariant"; @@ -26,6 +29,7 @@ export function FormWithConfirm({ fetcher: _fetcher, isOpen, onOpenChange, + onConfirm, }: { fields?: ( | [name: string, value: string | number] @@ -43,6 +47,8 @@ export function FormWithConfirm({ /** Controls the dialog open state. When provided, no child trigger is needed. */ isOpen?: boolean; onOpenChange?: (isOpen: boolean) => void; + /** Confirming runs this callback instead of submitting a form (client only action) */ + onConfirm?: () => void; }) { const componentsFetcher = useFetcher(); const fetcher = _fetcher ?? componentsFetcher; @@ -69,7 +75,7 @@ export function FormWithConfirm({ return ( <> - {isHydrated + {isHydrated && !onConfirm ? // using portal here makes nesting this component in another form work createPortal( {description} ) : null}
- - {submitButtonText ?? t("common:actions.delete")} - + {onConfirm ? ( + { + closeDialog(); + onConfirm(); + }} + > + {submitButtonText ?? t("common:actions.delete")} + + ) : ( + + {submitButtonText ?? t("common:actions.delete")} + + )}
diff --git a/app/components/ObjectiveTimeline.module.css b/app/components/ObjectiveTimeline.module.css new file mode 100644 index 000000000..3a3e04d56 --- /dev/null +++ b/app/components/ObjectiveTimeline.module.css @@ -0,0 +1,6 @@ +.container { + height: 300px; + background-color: var(--color-bg-high); + border-radius: var(--radius-box); + padding: var(--s-2-5) var(--s-3); +} diff --git a/app/components/ObjectiveTimeline.tsx b/app/components/ObjectiveTimeline.tsx new file mode 100644 index 000000000..42584e904 --- /dev/null +++ b/app/components/ObjectiveTimeline.tsx @@ -0,0 +1,284 @@ +/** + * Line chart of a game's objective-counter reads: one line per team + * (remaining count over match time, so lines fall toward 0). Control is a + * state rather than a count, so it gets its own lane in a gutter below the + * zero gridline instead of sharing the count axis — a strip in the + * controlling team's color, absent while neither team controls. The + * zero gridline is drawn in the stronger border color to read as the + * divider between the counts above and the lane below. Penalty is a + * translucent band filled between score and score + penalty — its thickness + * is the extra count the team must burn through before its score moves + * again, so it grows when a penalty lands and shrinks as it counts down. + * Control state and exact values stay in the shared hover tooltip. + * + * Series colors are the chart tokens from vars.css — the theme's text-tier + * colors are too pastel to tell apart as marks; these are the same two hues + * re-stepped per theme and validated for CVD separation and surface + * contrast. + */ + +import { + Chart as ChartJS, + Filler, + Legend, + LinearScale, + LineElement, + PointElement, + Tooltip, +} from "chart.js"; +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, + PointElement, + LineElement, + Filler, + Tooltip, + Legend, +); + +/** count-axis units of gutter kept below zero for the control lane */ +const CONTROL_LANE_DEPTH = 13; +const CONTROL_LANE_Y = -6; +const CONTROL_LANE_WIDTH = 6; +const COUNT_TICK_STEP = 25; + +/** One objective-counter read, values in `[alpha, bravo]` order. */ +export interface ObjectiveTimelineSample { + /** seconds shown on the match timer at the read ("3:35" = 215); null = unreadable */ + time: number | null; + /** displayed count per team; null = unreadable */ + score: [number | null, number | null]; + /** penalty pill value per team; null = no pill (or unreadable) */ + penalty: [number | null, number | null]; + /** which team held the objective at the read */ + control: [boolean, boolean]; +} + +export interface ObjectiveTimelineEvent { + /** whole seconds into the source (video, stream or game) the read was made at */ + t: number; + data: ObjectiveTimelineSample; +} + +export function ObjectiveTimeline({ + events, + teamLabels, +}: { + events: readonly ObjectiveTimelineEvent[]; + teamLabels: readonly [string, string]; +}) { + const { t } = useTranslation(["common"]); + const colors = useThemeColors({ + alpha: "--color-chart-alpha", + bravo: "--color-chart-bravo", + border: "--color-border", + borderHigh: "--color-border-high", + text: "--color-text-high", + }); + const sorted = events.toSorted((a, b) => a.t - b.t); + if (sorted.length === 0) return null; + + const teamColors = [colors.alpha, colors.bravo]; + const scoreDatasets = ([0, 1] as const).map((side) => ({ + label: teamLabels[side], + data: sorted.map((event) => ({ + x: event.t, + y: event.data.score[side], + })), + borderColor: teamColors[side], + backgroundColor: teamColors[side], + pointBackgroundColor: teamColors[side], + pointBorderColor: teamColors[side], + borderWidth: 2, + pointRadius: 0, + pointHoverRadius: 4, + hitRadius: 20, + spanGaps: true, + cubicInterpolationMode: "monotone" as const, + })); + // strip along the lane while the team is in control; the losing edge is + // kept in the lane too so the strip extends exactly to where control ended + const controlDatasets = ([0, 1] as const).map((side) => ({ + label: `${teamLabels[side]} control`, + data: sorted.map((event, i) => ({ + x: event.t, + y: + event.data.control[side] || sorted[i - 1]?.data.control[side] + ? CONTROL_LANE_Y + : null, + })), + borderColor: teamColors[side], + borderWidth: CONTROL_LANE_WIDTH, + borderCapStyle: "round" as const, + pointRadius: 0, + pointHoverRadius: 0, + hitRadius: 0, + spanGaps: false, + stepped: "after" as const, + })); + // band between score and score + penalty; its thickness is the penalty + const penaltyDatasets = ([0, 1] as const).map((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`, + data: sorted.map((event, i) => { + lastScore = event.data.score[side] ?? lastScore; + return { + x: event.t, + y: lastScore === null ? null : lastScore + (penalties[i] ?? 0), + }; + }), + borderColor: `${teamColors[side]}8c`, + backgroundColor: `${teamColors[side]}38`, + borderWidth: 1, + pointRadius: 0, + pointHoverRadius: 0, + cubicInterpolationMode: "monotone" as const, + fill: { target: side }, + // edge only where a penalty exists so zero-height bands stay invisible + segment: { + borderColor: (ctx: { p0DataIndex: number; p1DataIndex: number }) => + (penalties[ctx.p0DataIndex] ?? 0) > 0 || + (penalties[ctx.p1DataIndex] ?? 0) > 0 + ? undefined + : "transparent", + }, + }; + }); + const datasets = [...scoreDatasets, ...penaltyDatasets, ...controlDatasets]; + + return ( +
+ formatElapsed(Number(value)), + }, + }, + y: { + min: -CONTROL_LANE_DEPTH, + suggestedMax: 100, + bounds: "data", + grid: { + color: (ctx) => gridColor(ctx.tick?.value ?? 0, colors), + tickColor: (ctx) => gridColor(ctx.tick?.value ?? 0, colors), + }, + border: { color: colors.borderHigh }, + afterBuildTicks: (axis) => { + axis.ticks = countAxisTicks(axis.max); + }, + ticks: { color: colors.text, autoSkip: false }, + }, + }, + plugins: { + legend: { + labels: { + color: colors.text, + boxWidth: 10, + boxHeight: 10, + filter: (item) => (item.datasetIndex ?? 0) < 2, + }, + }, + tooltip: { + filter: (item) => item.datasetIndex < 2, + callbacks: { + title: (items) => { + if (!items[0]) return ""; + const clock = sorted[items[0].dataIndex]?.data.time; + const elapsed = formatElapsed(items[0].parsed.x ?? 0); + return clock != null + ? `${elapsed} · ${t("common:objectiveTimeline.timeLeft", { + time: formatClock(clock), + })}` + : elapsed; + }, + label: (item) => { + const event = sorted[item.dataIndex]; + if (!event) return ""; + const side = item.datasetIndex as 0 | 1; + const { score, penalty, control } = event.data; + return [ + `${teamLabels[side]}: ${score[side] ?? "?"}`, + penalty[side] !== null + ? t("common:objectiveTimeline.penalty", { + value: penalty[side], + }) + : null, + control[side] + ? t("common:objectiveTimeline.inControl") + : null, + ] + .filter(Boolean) + .join(" · "); + }, + }, + }, + }, + }} + /> +
+ ); +} + +/** + * One tick every 25 up to the top of the data and none below zero, so the + * control gutter stays free of axis furniture. + */ +function countAxisTicks(max: number) { + const ticks = []; + for (let value = 0; value <= max; value += COUNT_TICK_STEP) { + ticks.push({ value }); + } + return ticks; +} + +/** zero divides counts from the lane, so it is drawn stronger; the gutter has no grid */ +function gridColor( + value: number, + colors: { border: string; borderHigh: string }, +) { + if (value < 0) return "transparent"; + return value === 0 ? colors.borderHigh : colors.border; +} + +/** 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); + const minutes = Math.floor((seconds % 3600) / 60); + const rest = String(Math.floor(seconds % 60)).padStart(2, "0"); + return hours > 0 + ? `${hours}:${String(minutes).padStart(2, "0")}:${rest}` + : `${minutes}:${rest}`; +} + +/** the match timer's M:SS (215 → "3:35") */ +function formatClock(seconds: number): string { + const minutes = Math.floor(seconds / 60); + const rest = String(Math.floor(seconds % 60)).padStart(2, "0"); + return `${minutes}:${rest}`; +} diff --git a/app/components/StageBannerBox.module.css b/app/components/StageBannerBox.module.css new file mode 100644 index 000000000..065799eb2 --- /dev/null +++ b/app/components/StageBannerBox.module.css @@ -0,0 +1,17 @@ +.banner { + background-image: + linear-gradient( + to right, + var(--stage-banner-fade, var(--color-bg-high)) 35%, + transparent 80% + ), + var(--stage-banner); + background-origin: border-box; + background-position: right center; + /* fade layer is grown a pixel past the box so subpixel box sizes can't + leave a sliver of the banner showing at the edges */ + background-size: + calc(100% + 2px) calc(100% + 2px), + cover; + background-repeat: no-repeat; +} diff --git a/app/components/StageBannerBox.tsx b/app/components/StageBannerBox.tsx new file mode 100644 index 000000000..8c5e33aeb --- /dev/null +++ b/app/components/StageBannerBox.tsx @@ -0,0 +1,33 @@ +import clsx from "clsx"; +import type * as React from "react"; +import type { StageId } from "~/modules/in-game-lists/types"; +import { stageBannerImageUrl } from "~/utils/urls"; +import styles from "./StageBannerBox.module.css"; + +/** + * Box with a stage banner image fading in from the right. The fade color + * defaults to `--color-bg-high`; override per use with the + * `--stage-banner-fade` CSS variable. + */ +export function StageBannerBox({ + stageId, + className, + children, +}: { + stageId: StageId; + className?: string; + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/app/components/WeaponSelect.tsx b/app/components/WeaponSelect.tsx index 2921084e5..db9d6c276 100644 --- a/app/components/WeaponSelect.tsx +++ b/app/components/WeaponSelect.tsx @@ -1,3 +1,4 @@ +import type { TFunction } from "i18next"; import * as React from "react"; import type { Key } from "react-aria-components"; import { useTranslation } from "react-i18next"; @@ -74,23 +75,25 @@ export function WeaponSelect< : value && typeof value === "object" && value.type === "MAIN" ? (value.id as MainWeaponId) : null; + const isControlled = value !== undefined; + const [isOpen, setIsOpen] = React.useState(false); + const [lastUncontrolledKey, setLastUncontrolledKey] = React.useState< + string | null + >(() => keyify(initialValue) ?? null); + const selectedKey = isControlled ? keyify(value) : lastUncontrolledKey; const { items, filterValue, setFilterValue } = useWeaponItems({ includeSubSpecial, quickSelectWeaponsIds, selectedWeaponId, + isOpen, + selectedKey, }); const filter = useWeaponFilter(); - const isControlled = value !== undefined; - - const keyify = (value?: MainWeaponId | AnyWeapon | null) => { - if (typeof value === "number") return `MAIN_${value}`; - if (!value) return value; - - return `${value.type}_${value.id}`; - }; - const handleOnChange = (key: Key | null) => { + if (!isControlled) { + setLastUncontrolledKey(key === null ? null : String(key)); + } if (key === null) return onChange?.(null as any); const [type, id] = (key as string).split("_"); const weapon = { @@ -117,6 +120,7 @@ export function WeaponSelect< }} searchInputValue={filterValue} onSearchInputChange={setFilterValue} + onOpenChange={setIsOpen} selectedKey={isControlled ? keyify(value) : undefined} defaultSelectedKey={ isControlled ? undefined : (keyify(initialValue) as Key) @@ -191,26 +195,16 @@ export function WeaponSelect< ); } +const weaponNameToWeaponMapCache = new Map>(); + function useWeaponFilter() { - const { t } = useTranslation(["weapons"]); + const { t, i18n } = useTranslation(["weapons"]); - const weaponNameToWeaponMap = (() => { - const map = new Map(); - - for (const id of mainWeaponIds) { - map.set(t(`weapons:MAIN_${id}`), { id, type: "MAIN" }); - } - - for (const id of subWeaponIds) { - map.set(t(`weapons:SUB_${id}`), { id, type: "SUB" }); - } - - for (const id of specialWeaponIds) { - map.set(t(`weapons:SPECIAL_${id}`), { id, type: "SPECIAL" }); - } - - return map; - })(); + const cached = weaponNameToWeaponMapCache.get(i18n.language); + const weaponNameToWeaponMap = cached ?? buildWeaponNameToWeaponMap(t); + if (!cached && i18n.hasLoadedNamespace("weapons")) { + weaponNameToWeaponMapCache.set(i18n.language, weaponNameToWeaponMap); + } return (value: string, searchValue: string) => { const weapon = weaponNameToWeaponMap.get(value); @@ -224,19 +218,52 @@ function useWeaponFilter() { }; } +function buildWeaponNameToWeaponMap(t: TFunction<["weapons"]>) { + const map = new Map(); + + for (const id of mainWeaponIds) { + map.set(t(`weapons:MAIN_${id}`), { id, type: "MAIN" }); + } + + for (const id of subWeaponIds) { + map.set(t(`weapons:SUB_${id}`), { id, type: "SUB" }); + } + + for (const id of specialWeaponIds) { + map.set(t(`weapons:SPECIAL_${id}`), { id, type: "SPECIAL" }); + } + + return map; +} + function useWeaponItems({ includeSubSpecial, quickSelectWeaponsIds, selectedWeaponId, + isOpen, + selectedKey, }: { includeSubSpecial: boolean | undefined; quickSelectWeaponsIds?: Array; selectedWeaponId?: MainWeaponId | null; + isOpen: boolean; + selectedKey: string | null | undefined; }) { const items = useAllWeaponCategories(includeSubSpecial); const [filterValue, setFilterValue] = React.useState(""); const { t } = useTranslation(["common"]); + // While closed only the selected item is needed (the trigger's value + // display); react-aria renders every item passed to it into a hidden + // collection even when the popover is closed. + if (!isOpen) { + return { + items: collapseToSelectedItem(items, selectedKey), + filterValue, + setFilterValue, + }; + } + const showQuickSelectWeapons = filterValue === "" && quickSelectWeaponsIds?.length; @@ -285,9 +312,29 @@ function useWeaponItems({ }; } -function useAllWeaponCategories(withSubSpecial = false) { - const { t } = useTranslation(["weapons"]); +const allWeaponCategoriesCache = new Map< + string, + ReturnType +>(); +function useAllWeaponCategories(withSubSpecial = false) { + const { t, i18n } = useTranslation(["weapons"]); + + const cacheKey = `${i18n.language}-${withSubSpecial}`; + const cached = allWeaponCategoriesCache.get(cacheKey); + if (cached) return cached; + + const categories = buildAllWeaponCategories(t, withSubSpecial); + if (i18n.hasLoadedNamespace("weapons")) { + allWeaponCategoriesCache.set(cacheKey, categories); + } + return categories; +} + +function buildAllWeaponCategories( + t: TFunction<["weapons"]>, + withSubSpecial: boolean, +) { const mainWeaponCategories = weaponCategories.map((category, idx) => ({ name: category.name, key: category.name, @@ -343,3 +390,37 @@ function useAllWeaponCategories(withSubSpecial = false) { ...mainWeaponCategories.map((c) => ({ ...c, idx: c.idx + 2 })), ]; } + +function keyify(value?: MainWeaponId | AnyWeapon | null) { + if (typeof value === "number") return `MAIN_${value}`; + if (!value) return value; + + return `${value.type}_${value.id}`; +} + +function collapseToSelectedItem< + Category extends { items: Array<{ weapon: { anyWeaponId: string } }> }, +>(categories: Category[], selectedKey: string | null | undefined): Category[] { + // react-stately refuses to open a select whose collection is empty, so even + // with nothing selected the closed collection keeps one item around. + const fallbackItems = () => { + const firstCategory = categories[0]; + if (!firstCategory) return []; + return [ + { ...firstCategory, items: firstCategory.items.slice(0, 1) } as Category, + ]; + }; + + if (!selectedKey) return fallbackItems(); + + for (const category of categories) { + const selectedItem = category.items.find( + (item) => item.weapon.anyWeaponId === selectedKey, + ); + if (selectedItem) { + return [{ ...category, items: [selectedItem] } as Category]; + } + } + + return fallbackItems(); +} diff --git a/app/components/elements/Select.tsx b/app/components/elements/Select.tsx index 04de433d7..76812b05f 100644 --- a/app/components/elements/Select.tsx +++ b/app/components/elements/Select.tsx @@ -78,6 +78,7 @@ export function SendouSelect({ clearable = false, className, filter, + onOpenChange, ...props }: SendouSelectProps) { const { t } = useTranslation(["common"]); @@ -86,6 +87,8 @@ export function SendouSelect({ const isControlled = !!onSearchInputChange; const handleOpenChange = (isOpen: boolean) => { + onOpenChange?.(isOpen); + if (!isControlled) return; if (!isOpen) { diff --git a/app/components/match-page/MatchTimeline.module.css b/app/components/match-page/MatchTimeline.module.css index 358f25839..1970f427b 100644 --- a/app/components/match-page/MatchTimeline.module.css +++ b/app/components/match-page/MatchTimeline.module.css @@ -67,14 +67,7 @@ display: grid; grid-template-rows: auto 1fr auto; align-self: stretch; - - &:first-child { - justify-self: end; - } - - &:last-child { - justify-self: start; - } + container: weapon-pool / inline-size; } .mapCenter { @@ -110,6 +103,21 @@ align-items: center; justify-content: center; gap: var(--s-2-5); + + .mapSideBravo & { + justify-self: start; + } + + .mapSide:not(.mapSideBravo) & { + justify-self: end; + } +} + +.resultHeaderGroup { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-0-5); } .resultHeader { @@ -119,14 +127,168 @@ } .resultLabel { - font-size: var(--font-xs); + font-size: var(--font-sm); font-weight: var(--weight-extra); text-transform: uppercase; } .resultPoints { + font-size: var(--font-xs); + font-weight: var(--weight-semi); + color: var(--color-text); +} + +.scoreboard { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: var(--s-2); + margin-top: calc(-1 * var(--s-4)); +} + +.scoreboardToggle { + display: flex; + align-items: center; + gap: var(--s-2); + width: 100%; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + font-size: var(--font-2xs); + font-weight: var(--weight-semi); + text-transform: uppercase; + letter-spacing: 0.05em; + + &::before, + &::after { + content: ""; + height: 1px; + flex: 1; + background-color: var(--color-border); + } +} + +.scoreboardChevron { + transition: transform 0.15s ease; +} + +.scoreboardChevronOpen { + transform: rotate(180deg); +} + +.scoreboardPanel { + display: flex; + flex-direction: column; + gap: var(--s-3); + background-color: var(--color-bg); + border: var(--border-style); + border-radius: var(--radius-box); + padding: var(--s-3); + font-size: var(--font-xs); +} + +.scoreboardTables { + display: flex; + flex-direction: column; + gap: var(--s-4); + overflow-x: auto; + overscroll-behavior-x: contain; +} + +.scoreboardTable { + width: 100%; + min-width: 24rem; + table-layout: fixed; + border-collapse: collapse; +} + +.scoreboardWeaponColumn, +.scoreboardBuildColumn, +.scoreboardStatHeader, +.scoreboardTeamName, +.scoreboardWeaponCell, +.scoreboardPlayerName, +.scoreboardStat, +.scoreboardBuildCell { + padding: var(--s-1) var(--s-2); + vertical-align: middle; +} + +.scoreboardHeaderRow { + border-bottom: var(--border-style); +} + +.scoreboardPlayerRow:nth-child(even) { + background-color: var(--color-bg-high); +} + +.scoreboardWeaponColumn { + width: 3rem; +} + +.scoreboardBuildColumn { + width: 2.5rem; +} + +.scoreboardStatHeader { + width: 3.5rem; + text-align: center; font-size: var(--font-3xs); font-weight: var(--weight-semi); + color: var(--color-text-high); +} + +.scoreboardTeamName { + text-align: start; + font-size: var(--font-3xs); + font-weight: var(--weight-semi); + color: var(--color-text-high); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.scoreboardWeaponCell { + line-height: 0; +} + +.scoreboardPlayerName { + text-align: start; + font-weight: var(--weight-semi); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.scoreboardStat { + text-align: center; + font-variant-numeric: tabular-nums; +} + +.scoreboardBuildCell { + text-align: center; + line-height: 0; +} + +.scoreboardAbilities { + display: flex; + flex-direction: column; + gap: var(--s-1); +} + +.scoreboardAbilityRow { + display: flex; + align-items: center; + gap: var(--s-1); +} + +.scoreboardUnknownWeapon { + opacity: 0.6; +} + +:global(html.light) .scoreboardUnknownWeapon { + filter: drop-shadow(0 0 1px var(--color-text)); } .eventRow { diff --git a/app/components/match-page/MatchTimeline.tsx b/app/components/match-page/MatchTimeline.tsx index bfb78c12c..41c3d82c2 100644 --- a/app/components/match-page/MatchTimeline.tsx +++ b/app/components/match-page/MatchTimeline.tsx @@ -1,36 +1,55 @@ import clsx from "clsx"; import { ArrowRight, + ChevronDown, MousePointerClick, RefreshCcw, TrendingUp, Users, X, } from "lucide-react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { LocaleTime } from "~/components/LocaleTime"; import type { GroupSkillDifference, UserSkillDifference, } from "~/db/tables-json"; +import { abilities } from "~/modules/in-game-lists/abilities"; import { shortStageName } from "~/modules/in-game-lists/stage-ids"; import type { + AbilityWithUnknown, MainWeaponId, ModeShort, StageId, } from "~/modules/in-game-lists/types"; import type { CommonUser } from "~/utils/kysely.server"; import { roundToNDecimalPlaces } from "~/utils/number"; +import { abilityImageUrl, navIconUrl } from "~/utils/urls"; +import { Ability } from "../Ability"; import { Avatar } from "../Avatar"; import { SendouButton } from "../elements/Button"; import { SendouPopover } from "../elements/Popover"; -import { ModeImage, StageImage } from "../Image"; +import { Image, ModeImage, StageImage, WeaponImage } from "../Image"; +import { + ObjectiveTimeline, + type ObjectiveTimelineEvent, +} from "../ObjectiveTimeline"; +import { matchScoresFromObjective } from "../objective-timeline-utils"; import styles from "./MatchTimeline.module.css"; import { type InferredSubstitution, inferSubstitutions } from "./utils"; +import type { WeaponPoolWeapon } from "./WeaponPool"; import { WeaponPool } from "./WeaponPool"; const LONG_TEAM_NAME_THRESHOLD = 16; +/** 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), +); + type MatchSide = "ALPHA" | "BRAVO"; export interface TimelineTeam { @@ -38,6 +57,17 @@ export interface TimelineTeam { avatar?: string; } +export interface TimelineScoreboardPlayer { + name: string; + weaponSplId: MainWeaponId | null; + ka: number | null; + d: number | null; + s: number | null; + paint: number | null; + /** [head, clothes, shoes] ability rows (main + subs) as ability codes */ + abilities?: string[][]; +} + export interface TimelineMap { stageId: StageId; mode: ModeShort; @@ -48,13 +78,22 @@ export interface TimelineMap { bravo: CommonUser[]; }; weapons?: { - alpha: Array; - bravo: Array; + alpha: WeaponPoolWeapon[]; + bravo: WeaponPoolWeapon[]; }; /** Whether the game ended in a knockout. Undefined if not collected. */ ko?: boolean; /** Side that picked this map (counterpick / postGame map PICK). Renders a click indicator next to that side's WIN/LOSS label. */ pickedBy?: MatchSide; + /** Ingested end-of-game scoreboard rendered as an expandable stats section below the map row. */ + scoreboard?: { + /** [alpha, bravo] on the ingested 0-100 scale (100 = knockout) */ + scores: [number | null, number | null]; + alpha: TimelineScoreboardPlayer[]; + bravo: TimelineScoreboardPlayer[]; + /** Objective-counter reads ([alpha, bravo] values) charted above the stats tables. */ + objective?: ObjectiveTimelineEvent[]; + }; } interface TimelineSpMember { @@ -133,7 +172,7 @@ export function MatchTimeline({ {substitutions.map((sub, j) => ( ))} - + ); })} @@ -210,8 +249,20 @@ function TimelineHeader({ ); } -function TimelineMapRow({ map }: { map: TimelineMap }) { +function TimelineMapRow({ + map, + teams, +}: { + map: TimelineMap; + teams: MatchTimelineProps["teams"]; +}) { const { t } = useTranslation(["game-misc"]); + const objectiveScores = matchScoresFromObjective( + (map.scoreboard?.objective ?? []).map((event) => ({ + t: event.t, + score: event.data.score, + })), + ); return (
@@ -219,6 +270,8 @@ function TimelineMapRow({ map }: { map: TimelineMap }) { @@ -239,14 +292,19 @@ function TimelineMapRow({ map }: { map: TimelineMap }) { {shortStageName(t(`game-misc:STAGE_${map.stageId}`))}
-
+
+ {map.scoreboard ? ( + + ) : null}
); } @@ -254,49 +312,268 @@ function TimelineMapRow({ map }: { map: TimelineMap }) { function SideResult({ result, isKo, + scoreboardScore, + objectiveScore, weapons, isPicked, }: { result: "WIN" | "LOSS"; isKo?: boolean; - weapons?: Array; + /** ingested 0-100 team score (100 = knockout) */ + scoreboardScore?: number | null; + /** 0-100 team score implied by the last objective-counter read */ + objectiveScore?: number | null; + weapons?: WeaponPoolWeapon[]; isPicked?: boolean; }) { const { t } = useTranslation(["q"]); + const score = resolveSideScore(scoreboardScore, objectiveScore); return (
-
- {isPicked ? ( - - } - description={t("q:match.timeline.explainer.picked")} - /> - ) : null} - - {result === "WIN" - ? t("q:match.timeline.win") - : t("q:match.timeline.loss")} - - {isKo ? ( - {t("q:match.action.ko")} - ) : null} +
+
+ {isPicked ? ( + + } + description={t("q:match.timeline.explainer.picked")} + /> + ) : null} + + {result === "WIN" + ? t("q:match.timeline.win") + : t("q:match.timeline.loss")} + + {isKo && score === null ? ( + + {t("q:match.action.ko")} + + ) : null} +
+ {score ? : null}
{weapons ? : null}
); } +interface SideScore { + /** 0-100 (100 = knockout) */ + value: number; + /** read off the objective counter rather than the results screen */ + fromObjective: boolean; +} + +/** + * A knockout's loser is reported with no score of its own, so the count it + * took is only known from the objective counter — prefer that read over a + * scoreless 0, and mark it as the video-sourced value it is. + */ +function resolveSideScore( + scoreboardScore?: number | null, + objectiveScore?: number | null, +): SideScore | null { + if (typeof scoreboardScore === "number" && scoreboardScore > 0) { + return { value: scoreboardScore, fromObjective: false }; + } + if (typeof objectiveScore === "number") { + return { value: objectiveScore, fromObjective: true }; + } + if (typeof scoreboardScore === "number") { + return { value: scoreboardScore, fromObjective: false }; + } + + return null; +} + +function ResultPoints({ score }: { score: SideScore }) { + const { t } = useTranslation(["q"]); + + if (score.value === SCOREBOARD_KO_SCORE) { + return ( + {t("q:match.action.ko")} + ); + } + + return ( + + {score.fromObjective + ? `(${score.value})` + : t("q:match.timeline.points", { points: score.value })} + + ); +} + +function TimelineScoreboardSection({ + scoreboard, + teams, +}: { + scoreboard: NonNullable; + teams: MatchTimelineProps["teams"]; +}) { + const { t } = useTranslation(["q"]); + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+ + {isExpanded ? ( +
+ {scoreboard.objective && scoreboard.objective.length > 0 ? ( + + ) : null} +
+ + +
+
+ ) : null} +
+ ); +} + +function ScoreboardTable({ + name, + players, +}: { + name: string; + players: TimelineScoreboardPlayer[]; +}) { + const { t } = useTranslation(["q"]); + + return ( + + + + + + + + + + + + {players.map((player, i) => ( + + + + + + + + + + ))} + +
+ + {name} + + {t("q:match.timeline.stats.paint")} + + {t("q:match.timeline.stats.kills")} + + {t("q:match.timeline.stats.deaths")} + + {t("q:match.timeline.stats.specials")} + +
+ {player.weaponSplId !== null ? ( + + ) : ( + ? + )} + + {player.name} + + {player.paint !== null + ? t("q:match.timeline.points", { points: player.paint }) + : "–"} + {player.ka ?? "–"}{player.d ?? "–"}{player.s ?? "–"} + {player.abilities && player.abilities.length > 0 ? ( + + ) : null} +
+ ); +} + +function ScoreboardBuildPopover({ abilities }: { abilities: string[][] }) { + const { t } = useTranslation(["common"]); + + return ( + + {t("common:pages.builds")} + + } + > +
+ {abilities.map((row, i) => ( +
+ {row.map((ability, j) => ( + + ))} +
+ ))} +
+
+ ); +} + +function toAbility(value: string): AbilityWithUnknown { + return ABILITY_NAMES.has(value) ? (value as AbilityWithUnknown) : "UNKNOWN"; +} + function TimelineEventRow({ icon, alphaContent, diff --git a/app/components/match-page/WeaponPool.module.css b/app/components/match-page/WeaponPool.module.css index 9b5e459ee..f91b85002 100644 --- a/app/components/match-page/WeaponPool.module.css +++ b/app/components/match-page/WeaponPool.module.css @@ -6,6 +6,13 @@ border-radius: var(--radius-full); padding: var(--s-0-5) var(--s-1-5); cursor: pointer; + + @container weapon-pool (max-width: 150px) { + display: grid; + grid-template-columns: repeat(2, auto); + justify-items: center; + border-radius: var(--radius-box); + } } :global(html.light) .unknownWeapon { @@ -25,3 +32,7 @@ font-size: var(--font-xs); font-weight: var(--weight-semi); } + +.unverifiedWeapon { + opacity: 0.7; +} diff --git a/app/components/match-page/WeaponPool.tsx b/app/components/match-page/WeaponPool.tsx index 100ede8df..12514a5fd 100644 --- a/app/components/match-page/WeaponPool.tsx +++ b/app/components/match-page/WeaponPool.tsx @@ -1,3 +1,4 @@ +import clsx from "clsx"; import { Button } from "react-aria-components"; import { useTranslation } from "react-i18next"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; @@ -6,26 +7,42 @@ import { SendouPopover } from "../elements/Popover"; import { Image, WeaponImage } from "../Image"; import styles from "./WeaponPool.module.css"; +export type WeaponPoolWeapon = + | MainWeaponId + | { + weaponSplId: MainWeaponId; + /** renders faded, e.g. an ingested weapon not yet linked to its user */ + unverified?: boolean; + } + | null; + export function WeaponPool({ weapons, - size = 24, + size = 32, }: { - weapons: Array; + weapons: WeaponPoolWeapon[]; size?: number; }) { const { t } = useTranslation(["weapons"]); + const entries = weapons.map((weapon) => + typeof weapon === "number" ? { weaponSplId: weapon } : weapon, + ); + return ( - {weapons.map((weaponId, i) => - weaponId !== null ? ( + {entries.map((weapon, i) => + weapon !== null ? ( ) : (
- {weapons.map((weaponId, i) => - weaponId !== null ? ( + {entries.map((weapon, i) => + weapon !== null ? (
- - {t(`weapons:MAIN_${weaponId}` as any)} + + {t(`weapons:MAIN_${weapon.weaponSplId}`)}
) : null, )} diff --git a/app/components/objective-timeline-utils.test.ts b/app/components/objective-timeline-utils.test.ts new file mode 100644 index 000000000..03226fdcf --- /dev/null +++ b/app/components/objective-timeline-utils.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { + matchScoresFromObjective, + type ObjectiveScoreRead, + type PenaltyRead, + smoothPenalties, +} from "./objective-timeline-utils"; + +function reads(...pairs: Array<[t: number, penalty: number | null]>) { + return pairs.map(([t, penalty]): PenaltyRead => ({ t, penalty })); +} + +function counterReads( + ...entries: Array<[t: number, alpha: number | null, bravo: number | null]> +) { + return entries.map( + ([t, alpha, bravo]): ObjectiveScoreRead => ({ t, score: [alpha, bravo] }), + ); +} + +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]); + }); +}); + +describe("matchScoresFromObjective", () => { + it("inverts the last counter read of each team", () => { + expect( + matchScoresFromObjective( + counterReads([0, 100, 100], [60, 80, 92], [120, 55, 0]), + ), + ).toEqual([45, 100]); + }); + + it("falls back to the latest readable count", () => { + expect( + matchScoresFromObjective( + counterReads([0, 100, 100], [60, 55, 40], [120, null, null]), + ), + ).toEqual([45, 60]); + }); + + it("ignores counts outside the counter's range", () => { + expect( + matchScoresFromObjective(counterReads([0, 100, 100], [60, 155, 40])), + ).toEqual([0, 60]); + }); + + it("reads the last count regardless of the order given", () => { + expect( + matchScoresFromObjective( + counterReads([120, 55, 0], [0, 100, 100], [60, 80, 92]), + ), + ).toEqual([45, 100]); + }); + + it("reports nothing when no count was read", () => { + expect(matchScoresFromObjective(counterReads([0, null, null]))).toEqual([ + null, + null, + ]); + expect(matchScoresFromObjective([])).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..ebee39873 --- /dev/null +++ b/app/components/objective-timeline-utils.ts @@ -0,0 +1,109 @@ +const PENALTY_BRIDGE_SECONDS = 6; + +/** The count a knockout wins at: the counter runs out and the team takes all of it. */ +const FULL_COUNT = 100; + +/** 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; +} + +/** One counter read: when it was made and the count displayed per team. */ +export interface ObjectiveScoreRead { + /** whole seconds into the source (video, stream or game) the read was made at */ + t: number; + /** displayed count per team; null = unreadable */ + score: readonly [number | null, number | null]; +} + +/** + * Match scores implied by each team's last readable counter read. The counter + * counts down from 100 while match scores run the other way (100 = knockout), + * so a read is inverted into the count the team took. Stands in where the + * results screen reports no score of its own — a knockout's loser — but the + * last read is only as late as the last frame the counter was seen in, so it + * can trail the count the team ended on. + * + * @param reads counter reads, in any order + * @returns per-team match score (0-100); null where nothing was read + */ +export function matchScoresFromObjective( + reads: readonly ObjectiveScoreRead[], +): [number | null, number | null] { + const sorted = reads.toSorted((a, b) => a.t - b.t); + const lastCountTaken = (side: 0 | 1) => { + for (let i = sorted.length - 1; i >= 0; i--) { + const count = sorted[i]!.score[side]; + // a count outside the counter's range is a misread, not a state + if (count !== null && count >= 0 && count <= FULL_COUNT) { + return FULL_COUNT - count; + } + } + return null; + }; + + return [lastCountTaken(0), lastCountTaken(1)]; +} + +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/config.ts b/app/config.ts index bae212fac..c2c2d98a1 100644 --- a/app/config.ts +++ b/app/config.ts @@ -42,6 +42,7 @@ const values = { VITE_PROD_MODE: stringBool("VITE_PROD_MODE"), VITE_SHOW_LUTI_NAV_ITEM: stringBool("VITE_SHOW_LUTI_NAV_ITEM"), VITE_FUSE_ENABLED: stringBool("VITE_FUSE_ENABLED"), + VITE_SCANNER_ENABLED: stringBool("VITE_SCANNER_ENABLED"), VITE_LEAGUE_GOOGLE_FORM_URL: env.VITE_LEAGUE_GOOGLE_FORM_URL, VITE_SHOW_BANNER_FOR_SEASON: env.VITE_SHOW_BANNER_FOR_SEASON, VITE_SENTRY_DSN: env.VITE_SENTRY_DSN, @@ -66,6 +67,8 @@ export const Config = { /** Whether to show the LUTI navigation item. */ showLutiNavItem: values.VITE_SHOW_LUTI_NAV_ITEM, fuseEnabled: values.VITE_FUSE_ENABLED, + /** Whether the scanner is available to everyone. While false only the admin and devs can use the scanner page and its ingest endpoint. */ + scannerEnabled: values.VITE_SCANNER_ENABLED, /** Google Form URL for league registration, if configured. */ leagueGoogleFormUrl: values.VITE_LEAGUE_GOOGLE_FORM_URL, /** Season identifier to show the registration banner for, if any. */ diff --git a/app/db/tables.ts b/app/db/tables.ts index 76e053852..a161c054a 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -34,6 +34,7 @@ import type { CalendarEventTag } from "~/features/calendar/calendar-types"; import type { LFGType } from "~/features/lfg/lfg-constants"; import type { SkillTeamIdentifier } from "~/features/mmr/mmr-utils"; import type { Notification as NotificationValue } from "~/features/notifications/notifications-types"; +import type { ScannerMatch } from "~/features/scanner/core/scanner-match"; import type { SplatoonRotationType } from "~/features/splatoon-rotations/splatoon-rotations-constants"; import type { MemberRole, @@ -505,6 +506,30 @@ export interface ReportedWeapon { createdAt: Generated; } +export interface IngestedMatch { + id: GeneratedAlways; + povUserId: number | null; + submitterUserId: number | null; + /** database timestamp (seconds) the match was played at, when known */ + playedAt: number | null; + data: JSONColumnType; + matchHash: string; + /** server-resolved tournament the match probably belongs to; aids future linking */ + tournamentIdHint: number | null; + /** server-resolved SendouQ match the match probably belongs to; aids future linking */ + groupMatchIdHint: number | null; + createdAt: Generated; +} + +/** Links an ingested match to the game result it describes (exactly one target). */ +export interface IngestedMatchLink { + id: GeneratedAlways; + ingestedMatchId: number; + tournamentMatchGameResultId: number | null; + groupMatchMapId: number | null; + createdAt: Generated; +} + export interface Skill { groupMatchId: number | null; id: GeneratedAlways; @@ -1286,6 +1311,8 @@ export interface DB { GroupReadyCheck: GroupReadyCheck; GroupReadyCheckConfirmation: GroupReadyCheckConfirmation; GroupSuggestion: GroupSuggestion; + IngestedMatch: IngestedMatch; + IngestedMatchLink: IngestedMatchLink; PrivateUserNote: PrivateUserNote; LogInLink: LogInLink; LFGPost: LFGPost; diff --git a/app/features/calendar/components/BracketProgressionFormFields.tsx b/app/features/calendar/components/BracketProgressionFormFields.tsx index b6a2e5fca..7a839c777 100644 --- a/app/features/calendar/components/BracketProgressionFormFields.tsx +++ b/app/features/calendar/components/BracketProgressionFormFields.tsx @@ -4,7 +4,7 @@ import { InfoPopover } from "~/components/InfoPopover"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import * as Swiss from "~/features/tournament-bracket/core/engine/swiss/team-status"; import { FormField } from "~/form/FormField"; -import { useFormFieldContext } from "~/form/SendouForm"; +import { useFormFieldContext, useFormValue } from "~/form/SendouForm"; import type { ArrayItemRenderContext } from "~/form/types"; import { type BracketFormValue, @@ -110,9 +110,10 @@ function BracketFields({ isDisabled: boolean; }) { const { t } = useTranslation(["forms"]); - const { index, itemName, values, formValues, setItemField } = renderContext; + const { index, itemName, values, setItemField } = renderContext; const bracket = values as unknown as BracketFormValue; - const progression = (formValues.progression ?? []) as ProgressionFormValue[]; + const progression = (useFormValue("progression") ?? + []) as ProgressionFormValue[]; const isFollowUp = index > 0 && progression[index]?.source === "BRACKET"; @@ -226,9 +227,9 @@ function ProgressionEntryFields({ isSourceLocked: boolean; }) { const { t } = useTranslation(["forms"]); - const { index, itemName, values, formValues, setItemField } = renderContext; + const { index, itemName, values, setItemField } = renderContext; const entry = values as unknown as ProgressionFormValue; - const brackets = (formValues.brackets ?? []) as BracketFormValue[]; + const brackets = (useFormValue("brackets") ?? []) as BracketFormValue[]; const sources = entry.sources ?? []; const isFirstBracket = index === 0; @@ -301,10 +302,11 @@ function SourceFields({ destinationBracketIdx: number; isDisabled: boolean; }) { - const { index, itemName, values, formValues } = renderContext; + const { index, itemName, values } = renderContext; const source = values as unknown as ProgressionSourceFormValue; - const brackets = (formValues.brackets ?? []) as BracketFormValue[]; - const progression = (formValues.progression ?? []) as ProgressionFormValue[]; + const brackets = (useFormValue("brackets") ?? []) as BracketFormValue[]; + const progression = (useFormValue("progression") ?? + []) as ProgressionFormValue[]; const siblingSources = progression[destinationBracketIdx]?.sources ?? []; // a bracket can be sourced only once, so the brackets taken by the other rows diff --git a/app/features/img-export/components/Graphic.tsx b/app/features/img-export/components/Graphic.tsx index e474eb492..6309fe5a4 100644 --- a/app/features/img-export/components/Graphic.tsx +++ b/app/features/img-export/components/Graphic.tsx @@ -149,19 +149,25 @@ export function GraphicTeamRow({ ))}
-
- {team.weapons.map((weaponSplId, index) => ( -
- - -
- ))} -
+ {team.weapons.length > 0 ? ( +
+ {team.weapons.map((weaponSplId, index) => ( +
+ + +
+ ))} +
+ ) : null} ); } diff --git a/app/features/img-export/components/SeasonSummaryGraphic.module.css b/app/features/img-export/components/SeasonSummaryGraphic.module.css index db6bc5612..c0ab198e3 100644 --- a/app/features/img-export/components/SeasonSummaryGraphic.module.css +++ b/app/features/img-export/components/SeasonSummaryGraphic.module.css @@ -55,13 +55,7 @@ } .bestStageRow { - background-image: - linear-gradient(to right, var(--graphic-row-bg) 35%, transparent 80%), - var(--best-stage-banner); - background-origin: border-box; - background-position: right center; - background-size: cover; - background-repeat: no-repeat; + --stage-banner-fade: var(--graphic-row-bg); } .bestStageName { diff --git a/app/features/img-export/components/SeasonSummaryGraphic.tsx b/app/features/img-export/components/SeasonSummaryGraphic.tsx index c28216ebd..edb1cc194 100644 --- a/app/features/img-export/components/SeasonSummaryGraphic.tsx +++ b/app/features/img-export/components/SeasonSummaryGraphic.tsx @@ -12,11 +12,12 @@ import { Avatar } from "~/components/Avatar"; import { Flag } from "~/components/Flag"; import { TierImage, WeaponImage } from "~/components/Image"; import { LocaleTimeRange } from "~/components/LocaleTimeRange"; +import { StageBannerBox } from "~/components/StageBannerBox"; import { TierPill } from "~/components/TierPill"; import type { TierName } from "~/features/mmr/mmr-constants"; import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types"; -import { stageBannerImageUrl, userSeasonsPage } from "~/utils/urls"; +import { userSeasonsPage } from "~/utils/urls"; import { GRAPHIC_DATE_FORMAT_OPTIONS, GraphicContainer, @@ -264,13 +265,9 @@ export function SeasonSummaryGraphic({ ) : null} {bestStage ? ( -
{t("user:seasons.summary.bestStage")} @@ -281,7 +278,7 @@ export function SeasonSummaryGraphic({ {Math.round(bestStage.winratePercentage)}%
-
+ ) : null}
diff --git a/app/features/img-export/components/TournamentResultsGraphic.module.css b/app/features/img-export/components/TournamentResultsGraphic.module.css index 98ddb4cea..9e8d05b29 100644 --- a/app/features/img-export/components/TournamentResultsGraphic.module.css +++ b/app/features/img-export/components/TournamentResultsGraphic.module.css @@ -1,3 +1,9 @@ +.tierPillContainer { + display: inline-flex; + vertical-align: middle; + margin-inline-start: var(--s-2); +} + .organizationName { max-width: 12rem; overflow: hidden; diff --git a/app/features/img-export/components/TournamentResultsGraphic.tsx b/app/features/img-export/components/TournamentResultsGraphic.tsx index fa7a1a455..8937ff465 100644 --- a/app/features/img-export/components/TournamentResultsGraphic.tsx +++ b/app/features/img-export/components/TournamentResultsGraphic.tsx @@ -90,7 +90,11 @@ export function TournamentGraphicHeader({ titleRow={ <> {tournamentName} - {typeof tier === "number" ? : null} + {typeof tier === "number" ? ( + + + + ) : null} } subtitle={ diff --git a/app/features/img-export/core/RunComps.test.ts b/app/features/img-export/core/RunComps.test.ts new file mode 100644 index 000000000..423e30922 --- /dev/null +++ b/app/features/img-export/core/RunComps.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import * as RunComps from "./RunComps"; + +const SHOOTER = 40 as MainWeaponId; +const ROLLER = 1010 as MainWeaponId; +const CHARGER = 2010 as MainWeaponId; +/** a kit that runs Tacticooler as the special */ +const TACTICOOLER_WEAPON = 60 as MainWeaponId; + +const observation = ( + playerKey: string, + weaponSplId: MainWeaponId, + mapOrder: number, +): RunComps.CompObservation => ({ playerKey, weaponSplId, mapOrder }); + +describe("buildComp", () => { + it("returns an empty comp for no observations", () => { + expect(RunComps.buildComp([])).toEqual([]); + }); + + it("picks each player's most played weapon", () => { + expect( + RunComps.buildComp([ + observation("a", SHOOTER, 0), + observation("a", SHOOTER, 1), + observation("a", CHARGER, 2), + ]), + ).toEqual([SHOOTER]); + }); + + it("breaks a most played tie by the most recently played weapon", () => { + expect( + RunComps.buildComp([ + observation("a", CHARGER, 0), + observation("a", SHOOTER, 1), + ]), + ).toEqual([SHOOTER]); + }); + + it("sorts the comp by weapon id with Tacticooler weapons last", () => { + expect( + RunComps.buildComp([ + observation("a", ROLLER, 0), + observation("b", TACTICOOLER_WEAPON, 0), + observation("c", SHOOTER, 0), + ]), + ).toEqual([SHOOTER, ROLLER, TACTICOOLER_WEAPON]); + }); + + it("keeps the players that played the most maps when there are more than four", () => { + const fullSet = (playerKey: string, weaponSplId: MainWeaponId) => [ + observation(playerKey, weaponSplId, 0), + observation(playerKey, weaponSplId, 1), + ]; + + expect( + RunComps.buildComp([ + ...fullSet("a", SHOOTER), + ...fullSet("b", ROLLER), + ...fullSet("c", CHARGER), + ...fullSet("d", TACTICOOLER_WEAPON), + observation("sub", 5010 as MainWeaponId, 1), + ]), + ).toEqual([SHOOTER, ROLLER, CHARGER, TACTICOOLER_WEAPON]); + }); +}); + +describe("mapObservations", () => { + it("keeps reported weapons and ingested rows of other players", () => { + expect( + RunComps.mapObservations({ + mapOrder: 3, + reported: [{ userId: 1, weaponSplId: SHOOTER }], + ingested: [{ name: "opponent", weaponSplId: ROLLER }], + }), + ).toEqual([ + observation("user-1", SHOOTER, 3), + observation("name-opponent", ROLLER, 3), + ]); + }); + + it("drops an ingested row linked to a user that already reported", () => { + expect( + RunComps.mapObservations({ + mapOrder: 0, + reported: [{ userId: 1, weaponSplId: SHOOTER }], + ingested: [{ name: "player", userId: 1, weaponSplId: ROLLER }], + }), + ).toEqual([observation("user-1", SHOOTER, 0)]); + }); + + it("drops an unlinked ingested row whose weapon a report accounts for, counting duplicates as a multiset", () => { + expect( + RunComps.mapObservations({ + mapOrder: 0, + reported: [{ userId: 1, weaponSplId: SHOOTER }], + ingested: [ + { name: "one", weaponSplId: SHOOTER }, + { name: "two", weaponSplId: SHOOTER }, + ], + }), + ).toEqual([ + observation("user-1", SHOOTER, 0), + observation("name-two", SHOOTER, 0), + ]); + }); + + it("skips ingested rows without a weapon", () => { + expect( + RunComps.mapObservations({ + mapOrder: 0, + reported: [], + ingested: [{ name: "unknown", weaponSplId: null }], + }), + ).toEqual([]); + }); +}); diff --git a/app/features/img-export/core/RunComps.ts b/app/features/img-export/core/RunComps.ts new file mode 100644 index 000000000..e88c8973d --- /dev/null +++ b/app/features/img-export/core/RunComps.ts @@ -0,0 +1,132 @@ +import * as R from "remeda"; +import { weaponParams } from "~/features/build-analyzer/core/utils"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; + +const TACTICOOLER_SPECIAL_WEAPON_ID = 15; +const COMP_SIZE = 4; + +export interface CompObservation { + /** Identity of the player within the aggregated maps: user id when known, otherwise the ingested scoreboard name */ + playerKey: string; + weaponSplId: MainWeaponId; + /** Chronological index of the map the weapon was played in */ + mapOrder: number; +} + +/** + * Builds a team's weapon comp from per map weapon observations. Each player + * contributes the weapon they played the most (ties broken by the most + * recently played one). The comp is in weapon id order, except weapons with + * Tacticooler as the special go last. When more than {@link COMP_SIZE} + * players were observed, the ones that played the most maps make the comp. + */ +export function buildComp(observations: CompObservation[]): MainWeaponId[] { + const byPlayer = new Map(); + for (const observation of observations) { + const playerObservations = byPlayer.get(observation.playerKey) ?? []; + playerObservations.push(observation); + byPlayer.set(observation.playerKey, playerObservations); + } + + const compPlayers = R.sortBy( + [...byPlayer.values()], + [(playerObservations) => playerObservations.length, "desc"], + (playerObservations) => + Math.min(...playerObservations.map((o) => o.mapOrder)), + ).slice(0, COMP_SIZE); + + return R.sortBy( + compPlayers.map(mostPlayedWeapon), + (weaponSplId) => (hasTacticooler(weaponSplId) ? 1 : 0), + (weaponSplId) => weaponSplId, + ); +} + +/** + * Converts one map's reported and ingested weapon rows of a team into comp + * observations. Ingested rows that duplicate a reported weapon are dropped: + * a row linked to a user that already reported, or an unlinked row whose + * weapon a report already accounts for (a multiset, matching how the match + * page timeline merges the two sources). + */ +export function mapObservations({ + mapOrder, + reported, + ingested, +}: { + mapOrder: number; + reported: Array<{ userId: number; weaponSplId: MainWeaponId }>; + ingested: Array<{ + name: string; + userId?: number; + weaponSplId: MainWeaponId | null; + }>; +}): CompObservation[] { + const reportedUserIds = new Set(reported.map((row) => row.userId)); + const accountedForCounts = new Map(); + for (const row of reported) { + accountedForCounts.set( + row.weaponSplId, + (accountedForCounts.get(row.weaponSplId) ?? 0) + 1, + ); + } + + const observations: CompObservation[] = reported.map((row) => ({ + playerKey: `user-${row.userId}`, + weaponSplId: row.weaponSplId, + mapOrder, + })); + + for (const row of ingested) { + if (row.weaponSplId === null) continue; + if (row.userId !== undefined && reportedUserIds.has(row.userId)) continue; + + if (row.userId === undefined) { + const accountedFor = accountedForCounts.get(row.weaponSplId) ?? 0; + if (accountedFor > 0) { + accountedForCounts.set(row.weaponSplId, accountedFor - 1); + continue; + } + } + + observations.push({ + playerKey: + row.userId !== undefined ? `user-${row.userId}` : `name-${row.name}`, + weaponSplId: row.weaponSplId, + mapOrder, + }); + } + + return observations; +} + +function mostPlayedWeapon(playerObservations: CompObservation[]): MainWeaponId { + const counts = new Map(); + const lastPlayedAt = new Map(); + for (const observation of playerObservations) { + counts.set( + observation.weaponSplId, + (counts.get(observation.weaponSplId) ?? 0) + 1, + ); + lastPlayedAt.set( + observation.weaponSplId, + Math.max( + lastPlayedAt.get(observation.weaponSplId) ?? -1, + observation.mapOrder, + ), + ); + } + + return R.sortBy( + [...counts.keys()], + [(weaponSplId) => counts.get(weaponSplId)!, "desc"], + [(weaponSplId) => lastPlayedAt.get(weaponSplId)!, "desc"], + )[0]; +} + +function hasTacticooler(weaponSplId: MainWeaponId) { + return ( + weaponParams().weaponKits[weaponSplId].specialWeaponId === + TACTICOOLER_SPECIAL_WEAPON_ID + ); +} 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 2938f2f79..d4222351e 100644 --- a/app/features/match-page-test/routes/match-page-test.tsx +++ b/app/features/match-page-test/routes/match-page-test.tsx @@ -22,9 +22,13 @@ import { MatchPageHeader } from "~/components/match-page/MatchPageHeader"; import { MatchResultTab } from "~/components/match-page/MatchResultTab"; import { MatchRosterTab } from "~/components/match-page/MatchRosterTab"; import { MatchTabs } from "~/components/match-page/MatchTabs"; +import type { ObjectiveTimelineEvent } from "~/components/ObjectiveTimeline"; import { logger } from "~/utils/logger"; import type { SendouRouteHandle } from "~/utils/remix.server"; +/** Counter reads of a made-up zones game, for previewing the timeline chart. */ +const MOCK_OBJECTIVE_EVENTS = mockObjectiveEvents(); + type ActionVariant = | "winner" | "counterpick-stage" @@ -693,6 +697,88 @@ export default function MatchPageTestRoute() { alpha: [40, null, 1100, 3040], bravo: [null, 210, null, 4010], }, + scoreboard: { + objective: MOCK_OBJECTIVE_EVENTS, + scores: [100, 0], + alpha: [ + { + name: "Sendou", + weaponSplId: 40, + ka: 12, + d: 4, + s: 3, + paint: 1102, + abilities: [ + ["LDE", "IRU", "IRU", "SCU"], + ["SPU", "ISM", "ISM", "SCU"], + ["SCU", "QSJ", "SRU", "SCU"], + ], + }, + { + name: "Lean", + weaponSplId: 1100, + ka: 9, + d: 6, + s: 2, + paint: 987, + }, + { + name: "Kiver", + weaponSplId: 3040, + ka: 7, + d: 5, + s: 4, + paint: 1345, + }, + { + name: "Brian", + weaponSplId: null, + ka: null, + d: null, + s: null, + paint: null, + }, + ], + bravo: [ + { + name: "Naga", + weaponSplId: 210, + ka: 8, + d: 7, + s: 1, + paint: 876, + abilities: [ + ["CB", "SPU", "SPU", "SPU"], + ["SCU", "SCU", "SS", "SCU"], + ["SJ", "SRU", "QSJ", "QSJ"], + ], + }, + { + name: "Grey", + weaponSplId: 4010, + ka: 5, + d: 8, + s: 2, + paint: 1204, + }, + { + name: "Poppy", + weaponSplId: 50, + ka: 6, + d: 9, + s: 3, + paint: 743, + }, + { + name: "Lime", + weaponSplId: 2010, + ka: 4, + d: 10, + s: 1, + paint: 654, + }, + ], + }, rosters: { alpha: [ { @@ -771,3 +857,58 @@ export default function MatchPageTestRoute() { ); } + +/** + * Plays out a zones game second by second: the controlling side burns its + * penalty before its count moves, and losing the zone after counting hands + * the side a penalty to burn next time. + */ +function mockObjectiveEvents(): ObjectiveTimelineEvent[] { + const PHASES: Array<{ seconds: number; control: [boolean, boolean] }> = [ + { seconds: 12, control: [false, false] }, + { seconds: 30, control: [true, false] }, + { seconds: 14, control: [false, false] }, + { seconds: 44, control: [false, true] }, + { seconds: 10, control: [false, false] }, + { seconds: 80, control: [true, false] }, + ]; + const PENALTY_ON_LOSING_ZONE = 12; + const SAMPLE_EVERY_SECONDS = 2; + + const score: [number, number] = [100, 100]; + const penalty: [number, number] = [0, 0]; + const events: ObjectiveTimelineEvent[] = []; + let previousControl: [boolean, boolean] = [false, false]; + let t = 0; + + for (const phase of PHASES) { + for (const side of [0, 1] as const) { + if (previousControl[side] && !phase.control[side]) { + penalty[side] += PENALTY_ON_LOSING_ZONE; + } + } + previousControl = phase.control; + + for (let second = 0; second < phase.seconds; second++) { + for (const side of [0, 1] as const) { + if (!phase.control[side]) continue; + if (penalty[side] > 0) penalty[side] -= 1; + else score[side] = Math.max(0, score[side] - 1); + } + + t += 1; + if (t % SAMPLE_EVERY_SECONDS !== 0) continue; + events.push({ + t, + data: { + time: 300 - t, + score: [score[0], score[1]], + penalty: [penalty[0] || null, penalty[1] || null], + control: [phase.control[0], phase.control[1]], + }, + }); + } + } + + return events; +} 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..771119045 --- /dev/null +++ b/app/features/scanner-ingest/ScannerIngestRepository.server.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, test } from "vitest"; +import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; +import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; +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); +/** enough teams for the bracket winner to play more than one match */ +const TOURNAMENT_TEAM_COUNT = 4; + +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]); + }); +}); + +describe("gamesInTournamentMatch", () => { + test("returns the match's own games only, leaving the rest of the tournament out", async () => { + const users = await UserFactory.createMany(TOURNAMENT_TEAM_COUNT); + const tournament = await TournamentFactory.createPlayed( + { authorId: users[0]!.id, minMembersPerTeam: 1 }, + { + teamRosters: users.map((user) => [user.id]), + playedOut: 0, + }, + ); + // the bracket winner plays every round on the same map list, so its + // earlier round's games are the ones a live send could wrongly take + const [firstMatch, ...laterMatches] = tournament.matches; + const winnerUserId = users.find( + (user) => + tournament.teams.find((team) => team.id === firstMatch!.winnerTeamId) + ?.memberUserIds[0] === user.id, + )!.id; + + const games = await ScannerIngestRepository.gamesInTournamentMatch( + firstMatch!.id, + ); + + expect(games.length).toBeGreaterThan(0); + expect( + games.every( + (game) => + game.target.type === "tournament" && + game.target.tournamentMatchId === firstMatch!.id, + ), + ).toBe(true); + + // the tournament-wide list is what the walk would otherwise see + const allGames = + await ScannerIngestRepository.gamesPlayedByUserInTournament({ + userId: winnerUserId, + tournamentId: tournament.id, + }); + expect(allGames.length).toBeGreaterThan(games.length); + expect( + allGames.some( + (game) => + game.target.type === "tournament" && + laterMatches.some( + (match) => + game.target.type === "tournament" && + game.target.tournamentMatchId === match.id, + ), + ), + ).toBe(true); + }); +}); + +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 new file mode 100644 index 000000000..dd6ad735d --- /dev/null +++ b/app/features/scanner-ingest/ScannerIngestRepository.server.ts @@ -0,0 +1,967 @@ +import { createHash } from "node:crypto"; +import { subDays } from "date-fns"; +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, + IngestableGameWithContext, + IngestContext, +} from "./core/Scoreboards"; +import * as Scoreboards from "./core/Scoreboards"; + +const opponentOneId = sql`"TournamentMatch"."opponentOne" ->> '$.id'`; +const opponentTwoId = sql`"TournamentMatch"."opponentTwo" ->> '$.id'`; + +/** + * How far a stored match's playedAt may sit from an incoming one and still + * be loaded as a merge candidate (content contradictions are checked by + * Matches.isSameMatch; this only bounds the query). + */ +const MERGE_CANDIDATE_PLAYED_AT_WINDOW_DAYS = 1; +/** How recently a playedAt-less stored match must have been created to be a candidate. */ +const MERGE_CANDIDATE_CREATED_AT_WINDOW_DAYS = 7; +const MERGE_CANDIDATE_LIMIT = 50; + +/** 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; + +/** 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: { + userId: number; + tournamentId: number; +}) { + return tournamentGames(params); +} + +/** + * Returns the games a user played in any tournament since the given + * database timestamp, in chronological order — tournament candidates for + * content-based context resolution (Scoreboards.resolveContext). + */ +export function gamesPlayedByUserSince(params: { + userId: number; + /** database timestamp (seconds) */ + since: number; +}) { + return tournamentGames(params); +} + +/** + * Returns the games of a tournament's casted sets (currently streamed ones + * plus the cast history), in chronological order — the candidate set for + * cast footage, whose submitter is staff rather than a player of the games. + */ +export async function castedGamesInTournament(tournamentId: number) { + const tournament = await db + .selectFrom("Tournament") + .select("castedMatchesInfo") + .where("Tournament.id", "=", tournamentId) + .executeTakeFirst(); + const castedMatchesInfo = tournament?.castedMatchesInfo; + + const tournamentMatchIds = [ + ...new Set([ + ...(castedMatchesInfo?.castedMatches ?? []).map( + (casted) => casted.matchId, + ), + ...(castedMatchesInfo?.castedMatchHistory ?? []).map( + (casted) => casted.matchId, + ), + ]), + ]; + if (tournamentMatchIds.length === 0) return []; + + return tournamentGames({ tournamentId, tournamentMatchIds }); +} + +/** + * Returns the reported games of one tournament match, in chronological + * order — the candidate set for a live send, which carries no sequence of + * its own to anchor on and so must not see the rest of the tournament. + */ +export function gamesInTournamentMatch(tournamentMatchId: number) { + return tournamentGames({ tournamentMatchIds: [tournamentMatchId] }); +} + +/** 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 match the user was (probably) playing 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. + */ +export async function tournamentActivityAt({ + 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", "TournamentMatch.id as matchId"]) + .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: row.tournamentId, tournamentMatchId: row.matchId } + : 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, + tournamentMatchIds, + since, +}: { + userId?: number; + tournamentId?: number; + tournamentMatchIds?: number[]; + since?: number; +}): Promise { + const rows = await db + .selectFrom("TournamentMatchGameResult") + .innerJoin( + "TournamentMatch", + "TournamentMatch.id", + "TournamentMatchGameResult.matchId", + ) + .innerJoin( + "TournamentStage", + "TournamentStage.id", + "TournamentMatch.stageId", + ) + .select([ + "TournamentMatchGameResult.id as matchGameResultId", + "TournamentMatchGameResult.matchId as tournamentMatchId", + "TournamentMatchGameResult.number", + "TournamentMatchGameResult.mode", + "TournamentMatchGameResult.stageId", + "TournamentMatchGameResult.winnerTeamId", + "TournamentMatchGameResult.createdAt as playedAt", + "TournamentStage.tournamentId", + opponentOneId.as("opponentOneId"), + opponentTwoId.as("opponentTwoId"), + ]) + // joined (not EXISTS) so the planner drives off the user's own + // participation index instead of scanning the whole createdAt window + .$if(userId !== undefined, (qb) => + qb.innerJoin("TournamentMatchGameResultParticipant", (join) => + join + .onRef( + "TournamentMatchGameResultParticipant.matchGameResultId", + "=", + "TournamentMatchGameResult.id", + ) + .on("TournamentMatchGameResultParticipant.userId", "=", userId!), + ), + ) + .$if(tournamentId !== undefined, (qb) => + qb.where("TournamentStage.tournamentId", "=", tournamentId!), + ) + .$if(tournamentMatchIds !== undefined, (qb) => + qb.where("TournamentMatchGameResult.matchId", "in", tournamentMatchIds!), + ) + .$if(since !== undefined, (qb) => + qb.where("TournamentMatchGameResult.createdAt", ">=", since!), + ) + .orderBy("TournamentMatchGameResult.createdAt", "asc") + .orderBy("TournamentMatchGameResult.number", "asc") + .execute(); + + const inGameNamesByTeamId = await teamInGameNames( + rows.flatMap((row) => [row.opponentOneId, row.opponentTwoId]), + ); + const linkedNames = await linkedPlayerNamesByTarget( + "tournamentMatchGameResultId", + rows.map((row) => row.matchGameResultId), + ); + + return rows.map((row) => { + const loserTeamId = + row.winnerTeamId === row.opponentOneId + ? row.opponentTwoId + : row.winnerTeamId === row.opponentTwoId + ? row.opponentOneId + : null; + + return { + target: { + type: "tournament", + matchGameResultId: row.matchGameResultId, + tournamentMatchId: row.tournamentMatchId, + }, + context: { type: "tournament", tournamentId: row.tournamentId }, + mapIndex: row.number - 1, + mode: row.mode, + stageId: row.stageId, + winnerInGameNames: inGameNamesByTeamId.get(row.winnerTeamId) ?? [], + loserInGameNames: + (loserTeamId !== null + ? inGameNamesByTeamId.get(loserTeamId) + : undefined) ?? [], + playedAt: row.playedAt, + linkedPlayerNames: linkedNames.get(row.matchGameResultId) ?? null, + }; + }); +} + +async function teamInGameNames(teamIds: Array) { + const uniqueTeamIds = [ + ...new Set(teamIds.filter((id): id is number => id !== null)), + ]; + if (uniqueTeamIds.length === 0) return new Map(); + + const members = await db + .selectFrom("TournamentTeamMember") + .innerJoin("User", "User.id", "TournamentTeamMember.userId") + .select((eb) => [ + "TournamentTeamMember.tournamentTeamId", + eb.fn + .coalesce("TournamentTeamMember.inGameName", "User.inGameName") + .as("inGameName"), + ]) + .where("TournamentTeamMember.tournamentTeamId", "in", uniqueTeamIds) + .execute(); + + const result = new Map(); + for (const member of members) { + if (!member.inGameName) continue; + const names = result.get(member.tournamentTeamId) ?? []; + names.push(member.inGameName); + result.set(member.tournamentTeamId, names); + } + + return result; +} + +async function sendouqGames({ + groupMatchId, + userId, + since, +}: { + groupMatchId?: number; + userId?: number; + since?: number; +}): Promise { + const rows = await db + .selectFrom("GroupMatchMap") + .innerJoin("GroupMatch", "GroupMatch.id", "GroupMatchMap.matchId") + .select([ + "GroupMatchMap.id as groupMatchMapId", + "GroupMatchMap.matchId as groupMatchId", + "GroupMatchMap.index as mapIndex", + "GroupMatchMap.mode", + "GroupMatchMap.stageId", + "GroupMatchMap.winnerGroupId", + "GroupMatch.alphaGroupId", + "GroupMatch.bravoGroupId", + "GroupMatch.createdAt as playedAt", + ]) + .$if(groupMatchId !== undefined, (qb) => + qb.where("GroupMatchMap.matchId", "=", groupMatchId!), + ) + // joined (not EXISTS) so the planner drives off the user's own + // membership index instead of scanning the whole createdAt window + .$if(userId !== undefined, (qb) => + qb.innerJoin("GroupMember", (join) => + join + .on("GroupMember.userId", "=", userId!) + .on((eb) => + eb.or([ + eb("GroupMember.groupId", "=", eb.ref("GroupMatch.alphaGroupId")), + eb("GroupMember.groupId", "=", eb.ref("GroupMatch.bravoGroupId")), + ]), + ), + ), + ) + // content resolution walks played games only; a current match's + // pre-generated unplayed maps would flood the candidate sequence + .$if(since !== undefined, (qb) => + qb + .where("GroupMatch.createdAt", ">=", since!) + .where("GroupMatchMap.winnerGroupId", "is not", null), + ) + .orderBy("GroupMatch.createdAt", "asc") + .orderBy("GroupMatchMap.index", "asc") + .execute(); + + const inGameNamesByGroupId = await groupInGameNames( + rows.flatMap((row) => [row.alphaGroupId, row.bravoGroupId]), + ); + const linkedNames = await linkedPlayerNamesByTarget( + "groupMatchMapId", + rows.map((row) => row.groupMatchMapId), + ); + + return rows.map((row) => { + const loserGroupId = + row.winnerGroupId === row.alphaGroupId + ? row.bravoGroupId + : row.winnerGroupId === row.bravoGroupId + ? row.alphaGroupId + : null; + + return { + target: { + type: "sendouq", + groupMatchMapId: row.groupMatchMapId, + groupMatchId: row.groupMatchId, + }, + context: { type: "sendouq", groupMatchId: row.groupMatchId }, + mapIndex: row.mapIndex, + mode: row.mode, + stageId: row.stageId, + winnerInGameNames: + (row.winnerGroupId !== null + ? inGameNamesByGroupId.get(row.winnerGroupId) + : undefined) ?? [], + loserInGameNames: + (loserGroupId !== null + ? inGameNamesByGroupId.get(loserGroupId) + : undefined) ?? [], + playedAt: row.playedAt, + linkedPlayerNames: linkedNames.get(row.groupMatchMapId) ?? null, + }; + }); +} + +async function groupInGameNames(groupIds: number[]) { + const uniqueGroupIds = [...new Set(groupIds)]; + if (uniqueGroupIds.length === 0) return new Map(); + + const members = await db + .selectFrom("GroupMember") + .innerJoin("User", "User.id", "GroupMember.userId") + .select(["GroupMember.groupId", "User.inGameName"]) + .where("GroupMember.groupId", "in", uniqueGroupIds) + .execute(); + + const result = new Map(); + for (const member of members) { + if (!member.inGameName) continue; + const names = result.get(member.groupId) ?? []; + names.push(member.inGameName); + result.set(member.groupId, names); + } + + return result; +} + +/** + * The winner-first player names of each game's earliest linked ingested + * match, keyed by the given link target column's value. + */ +async function linkedPlayerNamesByTarget( + column: "tournamentMatchGameResultId" | "groupMatchMapId", + targetIds: number[], +) { + const result = new Map(); + if (targetIds.length === 0) return result; + + const rows = await db + .selectFrom("IngestedMatchLink") + .innerJoin( + "IngestedMatch", + "IngestedMatch.id", + "IngestedMatchLink.ingestedMatchId", + ) + .select([`IngestedMatchLink.${column} as targetId`, "IngestedMatch.data"]) + .where(`IngestedMatchLink.${column}`, "in", targetIds) + .orderBy("IngestedMatchLink.createdAt", "asc") + .orderBy("IngestedMatchLink.id", "asc") + .execute(); + + for (const row of rows) { + if (row.targetId === null || result.has(row.targetId)) continue; + const names = Scoreboards.winnerFirstPlayerNames(row.data); + if (names) result.set(row.targetId, names); + } + + return result; +} + +async function reportPovWeapon( + trx: Transaction, + { match, game }: { match: ScannerMatch; game: IngestableGame }, + povUserId: number | null, +) { + if (povUserId === null || match.pov === null) return; + const weaponSplId = + match.teams[match.pov.team]?.players[match.pov.index]?.weaponId ?? null; + if (weaponSplId === null) return; + + await trx + .insertInto("ReportedWeapon") + .values({ + tournamentMatchId: + game.target.type === "tournament" + ? game.target.tournamentMatchId + : null, + groupMatchId: + game.target.type === "sendouq" ? game.target.groupMatchId : null, + mapIndex: game.mapIndex, + userId: povUserId, + weaponSplId, + }) + .onConflict((oc) => + oc + .columns( + game.target.type === "tournament" + ? ["tournamentMatchId", "mapIndex", "userId"] + : ["groupMatchId", "mapIndex", "userId"], + ) + .doNothing(), + ) + .execute(); +} diff --git a/app/features/scanner-ingest/actions/scanner-ingest.server.ts b/app/features/scanner-ingest/actions/scanner-ingest.server.ts new file mode 100644 index 000000000..ba371fd0f --- /dev/null +++ b/app/features/scanner-ingest/actions/scanner-ingest.server.ts @@ -0,0 +1,296 @@ +import { subDays } from "date-fns"; +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 { isAdmin, isDev } from "~/modules/permissions/utils"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { logger } from "~/utils/logger"; +import { forbidden, parseBody } from "~/utils/remix.server"; +import * as Scoreboards from "../core/Scoreboards"; +import * as ScannerIngestRepository from "../ScannerIngestRepository.server"; +import { + type IngestedMatchLink, + type IngestResponse, + ingestBodySchema, +} from "../scanner-ingest-schemas"; + +/** + * How far back the POV user's reported games are considered as content- + * resolution candidates + */ +const CONTENT_RESOLUTION_WINDOW_DAYS = 365; + +export const action: ActionFunction = async ({ request }) => { + const user = requireUser(); + + if (!Config.scannerEnabled && !isAdmin(user) && !isDev(user)) { + forbidden(); + } + + const data = await parseBody({ request, schema: ingestBodySchema }); + + const povUserId = user.id; + + const indexedMatches = data.matches + .map((match, requestIndex) => ({ match, requestIndex })) + .filter(({ match }) => match.lobby === null || match.lobby === "PRIVATE"); + const matches = indexedMatches.map(({ match }) => match); + if (matches.length === 0) { + return { + storedMatchesCount: 0, + mergedMatchesCount: 0, + linkedGamesCount: 0, + linkedMatches: [], + contextResolved: false, + } satisfies IngestResponse; + } + + const resolved = await resolveIngestContext({ + matches, + povUserId, + casterUserId: user.id, + }); + + const { insertedCount, mergedCount, effectiveMatches } = + await ScannerIngestRepository.addOrMergeMatches({ + povUserId, + submitterUserId: user.id, + matches, + context: resolved?.context ?? null, + }); + + let linkedGamesCount = 0; + let linkedMatches: IngestResponse["linkedMatches"] = []; + if (resolved) { + const matched = Scoreboards.matchedGames({ + matches: effectiveMatches.map((effective) => effective.data), + games: resolved.games, + }); + + linkedGamesCount = await ScannerIngestRepository.addLinks({ + links: matched.map(({ matchIndex, game }) => ({ + ingestedMatchId: effectiveMatches[matchIndex]!.id, + match: effectiveMatches[matchIndex]!.data, + game, + })), + povUserId, + }); + + linkedMatches = matched.map(({ matchIndex, game }) => ({ + matchIndex: indexedMatches[matchIndex]!.requestIndex, + link: ingestedMatchLink(resolved.context, game.target), + })); + + logger.debug( + `ingest: ${Scoreboards.contextKey(resolved.context)} matched ${matched.length} games, ` + + `${linkedGamesCount} newly linked (stored ${insertedCount}, merged ${mergedCount})`, + ); + } else { + logger.debug( + `ingest: stored ${insertedCount} matches (${mergedCount} merged) without a resolved context ` + + `(povUserId=${povUserId})`, + ); + } + + return { + storedMatchesCount: insertedCount, + mergedMatchesCount: mergedCount, + linkedGamesCount, + linkedMatches, + contextResolved: resolved !== null, + } satisfies IngestResponse; +}; + +function ingestedMatchLink( + context: Scoreboards.IngestContext, + target: Scoreboards.IngestableGameTarget, +): IngestedMatchLink { + if (target.type === "tournament" && context.type === "tournament") { + return { + type: "tournament", + tournamentId: context.tournamentId, + matchId: target.tournamentMatchId, + }; + } + if (target.type === "sendouq") { + return { type: "sendouq", groupMatchId: target.groupMatchId }; + } + throw new Error("ingest link target does not match its resolved context"); +} + +interface ResolvedIngestContext { + context: Scoreboards.IngestContext; + games: Scoreboards.IngestableGameWithContext[]; +} + +interface IngestContextCandidate { + context: Scoreboards.IngestContext; + loadGames: () => Promise; +} + +/** + * Resolves the context (tournament or SendouQ match) a request's matches + * belong to. + * + * The user's activity around the time the matches were played is the strong + * signal: the SendouQ match resp. tournament match of theirs running then + * (for cast footage, the casted sets of tournaments the submitter helps + * run as author/organizer/streamer). Candidates are scored by how many + * matches would link to their games; a candidate is kept even when nothing links + * yet (a live minimap-only match still gets its hint). With no activity, + * the matches' content decides: the mode+stage sequence plus roster sides + * is near-unique in a user's reported-game history. + */ +async function resolveIngestContext({ + matches, + povUserId, + casterUserId, +}: { + matches: ScannerMatch[]; + povUserId: number | null; + casterUserId: number | null; +}): Promise { + const at = anchorTime(matches); + const hasPovMatches = matches.some((match) => !match.cast); + const hasCastMatches = matches.some((match) => match.cast); + + const candidates: IngestContextCandidate[] = []; + const seenContexts = new Set(); + const addCandidate = (candidate: IngestContextCandidate) => { + const key = Scoreboards.contextKey(candidate.context); + if (seenContexts.has(key)) return; + seenContexts.add(key); + candidates.push(candidate); + }; + + if (povUserId && hasPovMatches) { + const groupMatchId = await ScannerIngestRepository.groupMatchIdAt({ + userId: povUserId, + at, + }); + if (groupMatchId) { + addCandidate({ + context: { type: "sendouq", groupMatchId }, + loadGames: () => + ScannerIngestRepository.gamesInGroupMatch(groupMatchId), + }); + } + + const tournamentActivity = + await ScannerIngestRepository.tournamentActivityAt({ + userId: povUserId, + at, + }); + if (tournamentActivity) { + const { tournamentId, tournamentMatchId } = tournamentActivity; + addCandidate({ + context: { type: "tournament", tournamentId }, + loadGames: () => + // a live send carries a single match, so the mode+stage order + // that anchors a whole scan is absent and the walk would take + // the first free game on that map anywhere in the tournament — + // some earlier round's. Only the set being played can be meant. + matches.length === 1 + ? ScannerIngestRepository.gamesInTournamentMatch(tournamentMatchId) + : ScannerIngestRepository.gamesPlayedByUserInTournament({ + userId: povUserId, + tournamentId, + }), + }); + } + } + + if (casterUserId && hasCastMatches) { + const staffTournamentIds = + await ScannerIngestRepository.staffTournamentIdsAt({ + userId: casterUserId, + at, + }); + for (const tournamentId of staffTournamentIds) { + addCandidate({ + context: { type: "tournament", tournamentId }, + loadGames: () => + ScannerIngestRepository.castedGamesInTournament(tournamentId), + }); + } + } + + let best: { + candidate: IngestContextCandidate; + games: Scoreboards.IngestableGameWithContext[]; + matched: number; + } | null = null; + for (const candidate of candidates) { + const games = await candidate.loadGames(); + const matched = Scoreboards.matchedGames({ matches, games }).length; + if (!best || matched > best.matched) { + best = { candidate, games, matched }; + } + } + if (best) { + logger.debug( + `ingest: resolved ${Scoreboards.contextKey(best.candidate.context)} for user ${povUserId} ` + + `from activity at ${new Date(at).toISOString()} (${best.matched} matches aligned, ${candidates.length} candidates)`, + ); + return { + context: best.candidate.context, + games: best.games, + }; + } + + if (povUserId && hasPovMatches && countAttachableMatches(matches) >= 2) { + const since = dateToDatabaseTimestamp( + subDays(new Date(), CONTENT_RESOLUTION_WINDOW_DAYS), + ); + const games = ( + await Promise.all([ + ScannerIngestRepository.gamesPlayedByUserSince({ + userId: povUserId, + since, + }), + ScannerIngestRepository.sendouqGamesPlayedByUserSince({ + userId: povUserId, + since, + }), + ]) + ).flat(); + const context = Scoreboards.resolveContext({ matches, games }); + if (context) { + const key = Scoreboards.contextKey(context); + logger.debug( + `ingest: resolved ${key} for user ${povUserId} from match contents ` + + `(${games.length} candidate games)`, + ); + return { + context, + games: games.filter( + (game) => Scoreboards.contextKey(game.context) === key, + ), + }; + } + } + + logger.debug( + `ingest: no context for user ${povUserId} at ${new Date(at).toISOString()}`, + ); + return null; +} + +/** Matches that could link to a reported game: their winner is known. */ +function countAttachableMatches(matches: ScannerMatch[]): number { + return matches.filter((match) => match.winner !== null).length; +} + +/** + * The wall-clock time the request's matches were (probably) played: the + * latest match's playedAt, falling back to "now". + */ +function anchorTime(matches: ScannerMatch[]): number { + const playedAts = matches + .map((match) => match.playedAt) + .filter((playedAt): playedAt is number => playedAt !== null); + if (playedAts.length > 0) return Math.max(...playedAts); + + return Date.now(); +} diff --git a/app/features/scanner-ingest/core/Matches.test.ts b/app/features/scanner-ingest/core/Matches.test.ts new file mode 100644 index 000000000..3138bbb75 --- /dev/null +++ b/app/features/scanner-ingest/core/Matches.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from "vitest"; +import type { + ScannerMatch, + ScannerMatchPlayer, +} from "~/features/scanner/core/scanner-match"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import * as Matches from "./Matches"; + +const NAMES = ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"]; +const WEAPONS: MainWeaponId[] = [10, 20, 30, 40, 50, 60, 70, 80]; + +function player( + name: string | null, + weaponId: MainWeaponId | null, + partial: Partial = {}, +): ScannerMatchPlayer { + return { + name, + weaponId, + paint: null, + ka: null, + d: null, + s: null, + ...partial, + }; +} + +function testMatch(partial: Partial = {}): ScannerMatch { + return { + startsAt: 100, + endsAt: 400, + playedAt: null, + 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, + }; +} + +/** The same rosters seen from the other side (e.g. a minimap alpha/bravo view). */ +function sideSwapped(match: ScannerMatch): ScannerMatch { + return { + ...match, + teams: [match.teams[1], match.teams[0]], + winner: match.winner === null ? null : match.winner === 0 ? 1 : 0, + matchScores: + match.matchScores === null + ? null + : [match.matchScores[1], match.matchScores[0]], + }; +} + +describe("canonicalMatch", () => { + it("serializes identically regardless of input key order", () => { + const match = testMatch({ + objective: { + mode: "SZ", + samples: [ + { + t: 120, + time: 215, + score: [95, 53], + penalty: [4, null], + control: [true, false], + }, + ], + }, + }); + const reordered = JSON.parse( + JSON.stringify({ + winner: match.winner, + objective: match.objective, + teams: match.teams, + cast: match.cast, + replayCode: match.replayCode, + matchScores: match.matchScores, + stage: match.stage, + mode: match.mode, + lobby: match.lobby, + playedAt: match.playedAt, + endsAt: match.endsAt, + startsAt: match.startsAt, + pov: match.pov, + }), + ) as ScannerMatch; + + expect(JSON.stringify(Matches.canonicalMatch(reordered))).toBe( + JSON.stringify(Matches.canonicalMatch(match)), + ); + }); +}); + +describe("isSameMatch", () => { + it("recognizes an identical match", () => { + expect(Matches.isSameMatch(testMatch(), testMatch())).toBe(true); + }); + + it("matching replay codes are a strong key", () => { + const a = testMatch({ + replayCode: "RABC-DEFG-HIJK-LMNO", + teams: testMatch().teams, + }); + const b = testMatch({ + replayCode: "RABC-DEFG-HIJK-LMNO", + matchScores: null, + teams: [{ players: [] }, { players: [] }], + winner: null, + }); + expect(Matches.isSameMatch(a, b)).toBe(true); + }); + + it("tolerates OCR jitter in the replay code", () => { + const a = testMatch({ replayCode: "RABC-DEFG-HIJK-LMNO" }); + const b = testMatch({ replayCode: "RA8C-DEFG-HIJK-LMN0" }); + expect(Matches.isSameMatch(a, b)).toBe(true); + }); + + it("clearly different replay codes contradict identity", () => { + const a = testMatch({ replayCode: "RABC-DEFG-HIJK-LMNO" }); + const b = testMatch({ replayCode: "RZYX-WVUT-SRQP-ONML" }); + expect(Matches.isSameMatch(a, b)).toBe(false); + }); + + it("close play times identify a match", () => { + const a = testMatch({ playedAt: 1_700_000_000_000 }); + const b = testMatch({ + playedAt: 1_700_000_000_000 + 5 * 60 * 1000, + matchScores: null, + teams: [{ players: [] }, { players: [] }], + winner: null, + }); + expect(Matches.isSameMatch(a, b)).toBe(true); + }); + + it("far-apart play times contradict identity even with equal rosters", () => { + const a = testMatch({ playedAt: 1_700_000_000_000 }); + const b = testMatch({ playedAt: 1_700_000_000_000 + 60 * 60 * 1000 }); + expect(Matches.isSameMatch(a, b)).toBe(false); + }); + + it("differing modes or stages contradict identity", () => { + expect( + Matches.isSameMatch(testMatch({ mode: "SZ" }), testMatch({ mode: "TC" })), + ).toBe(false); + expect( + Matches.isSameMatch(testMatch({ stage: 0 }), testMatch({ stage: 1 })), + ).toBe(false); + }); + + it("a null mode does not contradict a read one", () => { + expect( + Matches.isSameMatch(testMatch({ mode: null }), testMatch({ mode: "TC" })), + ).toBe(true); + }); + + it("roster overlap identifies a match even side-swapped", () => { + expect(Matches.isSameMatch(testMatch(), sideSwapped(testMatch()))).toBe( + true, + ); + }); + + it("roster overlap survives a couple of misread names", () => { + const b = testMatch(); + b.teams[0].players[0] = player("misread", WEAPONS[0]!); + b.teams[1].players[3] = player(null, WEAPONS[7]!); + expect(Matches.isSameMatch(testMatch(), b)).toBe(true); + }); + + it("weapons alone identify a match when names are unread (minimap vs scoreboard)", () => { + const minimap = testMatch({ + winner: null, + lobby: null, + matchScores: null, + teams: [ + { players: WEAPONS.slice(0, 4).map((w) => player(null, w)) }, + { players: WEAPONS.slice(4).map((w) => player(null, w)) }, + ], + }); + expect(Matches.isSameMatch(testMatch(), minimap)).toBe(true); + }); + + it("unrelated matches are not the same", () => { + const other = testMatch({ + matchScores: [88, 12], + teams: [ + { + players: ["a", "b", "c", "d"].map((n, i) => + player(n, (100 + 10 * i) as MainWeaponId), + ), + }, + { + players: ["e", "f", "g", "h"].map((n, i) => + player(n, (200 + 10 * i) as MainWeaponId), + ), + }, + ], + }); + expect(Matches.isSameMatch(testMatch(), other)).toBe(false); + }); +}); + +describe("mergeMatches", () => { + it("fills stored nulls and reports no change when nothing was added", () => { + const existing = testMatch({ mode: null, playedAt: null }); + const incoming = testMatch({ mode: "SZ", playedAt: 1_700_000_000_000 }); + + const first = Matches.mergeMatches(existing, incoming); + expect(first.changed).toBe(true); + expect(first.merged.mode).toBe("SZ"); + expect(first.merged.playedAt).toBe(1_700_000_000_000); + + const second = Matches.mergeMatches(first.merged, incoming); + expect(second.changed).toBe(false); + }); + + it("stored values win on conflict", () => { + const existing = testMatch({ stage: 0 }); + const incoming = testMatch({ stage: null }); + incoming.teams[0].players[0] = player("other", 999 as MainWeaponId); + + const { merged } = Matches.mergeMatches(existing, incoming); + expect(merged.stage).toBe(0); + expect(merged.teams[0].players[0]!.name).toBe("w1"); + }); + + it("aligns a side-swapped incoming match before merging", () => { + const existing = testMatch({ winner: null, matchScores: null }); + const incoming = sideSwapped( + testMatch({ matchScores: [84, 71], playedAt: 1_700_000_000_000 }), + ); + + const { merged } = Matches.mergeMatches(existing, incoming); + expect(merged.winner).toBe(0); + expect(merged.matchScores).toEqual([84, 71]); + expect(merged.teams[0].players.map((p) => p.name)).toEqual( + NAMES.slice(0, 4), + ); + }); + + it("merges player rows by name, keeping stored stats and adding missing ones", () => { + const existing = testMatch(); + existing.teams[1].players[1] = player("l2", null); + const incoming = testMatch(); + incoming.teams[1].players = [ + player("l2", WEAPONS[5]!, { ka: 12, abilities: [["ISM"]] }), + player("l1", WEAPONS[4]!), + player("l3", WEAPONS[6]!), + player("l4", WEAPONS[7]!), + ]; + + const { merged } = Matches.mergeMatches(existing, incoming); + const l2 = merged.teams[1].players[1]!; + expect(l2.weaponId).toBe(WEAPONS[5]); + expect(l2.ka).toBe(12); + expect(l2.abilities).toEqual([["ISM"]]); + }); + + it("fills empty teams from the incoming match", () => { + const existing = testMatch({ + winner: null, + matchScores: null, + teams: [{ players: [] }, { players: [] }], + replayCode: "RABC-DEFG-HIJK-LMNO", + }); + const incoming = testMatch({ replayCode: "RABC-DEFG-HIJK-LMNO" }); + + const { merged, changed } = Matches.mergeMatches(existing, incoming); + expect(changed).toBe(true); + expect(merged.winner).toBe(0); + expect(merged.teams[0].players.map((p) => p.name)).toEqual( + NAMES.slice(0, 4), + ); + expect(merged.matchScores).toEqual([100, 52]); + }); +}); diff --git a/app/features/scanner-ingest/core/Matches.ts b/app/features/scanner-ingest/core/Matches.ts new file mode 100644 index 000000000..b3e090848 --- /dev/null +++ b/app/features/scanner-ingest/core/Matches.ts @@ -0,0 +1,375 @@ +/** + * Pure logic for stored scanner matches: canonical serialization (hashing), + * deciding whether two partial ScannerMatches describe the same game, and + * merging a newly ingested partial into a stored one. + */ +import type { + ScannerMatch, + ScannerMatchObjective, + ScannerMatchPlayer, + ScannerMatchTeam, +} from "~/features/scanner/core/scanner-match"; +import { inGameNameWithoutDiscriminator } from "~/utils/strings"; + +/** + * Replay codes are random enough that two different games share almost no + * positions; this many differing characters still reads as OCR jitter of + * the same code, at or above it as a different game. + */ +const REPLAY_CODE_MAX_OCR_ERRORS = 3; + +/** Two reads of one game land within this of each other (clock skew, retries). */ +const PLAYED_AT_AFFINITY_MS = 10 * 60 * 1000; +/** Reads further apart than this cannot be the same few-minute game. */ +const PLAYED_AT_CONTRADICTION_MS = 20 * 60 * 1000; + +/** How many of the 8 rosters' readable names must align for identity. */ +const MIN_NAME_OVERLAP = 6; +/** How many of the 8 weapon slots must align (with ≥7 read on both sides). */ +const MIN_WEAPON_OVERLAP = 7; +const MIN_WEAPON_SLOTS_READ = 7; + +const PLAYERS_PER_TEAM = 4; + +/** + * Rebuilds a match with a fixed key order so `JSON.stringify` of the result + * is stable regardless of how the input was constructed — the hashing and + * change-detection representation. + */ +export function canonicalMatch(match: ScannerMatch): ScannerMatch { + return { + startsAt: match.startsAt, + endsAt: match.endsAt, + playedAt: match.playedAt, + lobby: match.lobby, + mode: match.mode, + stage: match.stage, + matchScores: + match.matchScores === null + ? null + : [match.matchScores[0], match.matchScores[1]], + replayCode: match.replayCode, + cast: match.cast, + objective: + match.objective === null ? null : canonicalObjective(match.objective), + teams: [canonicalTeam(match.teams[0]), canonicalTeam(match.teams[1])], + winner: match.winner, + pov: + match.pov === null + ? null + : { team: match.pov.team, index: match.pov.index }, + }; +} + +/** + * Whether two (possibly partial) matches describe the same game. Callers + * pre-scope candidates to the same tournament + POV user; this checks the + * contents: contradicting mode/stage/replay-code/play-time rules identity + * out, then a matching replay code, close play times, or an aligning roster + * (names, or weapons when names are unread) rules it in. + */ +export function isSameMatch(a: ScannerMatch, b: ScannerMatch): boolean { + if (a.mode !== null && b.mode !== null && a.mode !== b.mode) return false; + if (a.stage !== null && b.stage !== null && a.stage !== b.stage) return false; + + const codeDiff = replayCodeDiff(a.replayCode, b.replayCode); + if (codeDiff !== null && codeDiff > REPLAY_CODE_MAX_OCR_ERRORS) return false; + + const playedDiff = + a.playedAt !== null && b.playedAt !== null + ? Math.abs(a.playedAt - b.playedAt) + : null; + if (playedDiff !== null && playedDiff > PLAYED_AT_CONTRADICTION_MS) { + return false; + } + + if (codeDiff !== null) return true; + if (playedDiff !== null && playedDiff <= PLAYED_AT_AFFINITY_MS) return true; + + const aligned = bestAlignment(a, b); + if (aligned.nameOverlap >= MIN_NAME_OVERLAP) return true; + if ( + aligned.weaponOverlap >= MIN_WEAPON_OVERLAP && + weaponSlotsRead(a) >= MIN_WEAPON_SLOTS_READ && + weaponSlotsRead(b) >= MIN_WEAPON_SLOTS_READ + ) { + return true; + } + return false; +} + +/** + * Merges a newly ingested partial into the stored match: the incoming teams + * are first aligned to the stored orientation (a scoreboard match's teams[0] + * is the winner side while a minimap match's is alpha), then every field + * fills stored nulls, stored values winning on conflict (mirroring the + * scoreboard attachment's first-ingest-wins). `changed` is false when the + * merge added nothing, so callers can skip the write. + */ +export function mergeMatches( + existing: ScannerMatch, + incoming: ScannerMatch, +): { merged: ScannerMatch; changed: boolean } { + const oriented = + bestAlignment(existing, incoming).orientation === "swapped" + ? swapSides(incoming) + : incoming; + + const merged: ScannerMatch = { + startsAt: existing.startsAt ?? oriented.startsAt, + endsAt: existing.endsAt ?? oriented.endsAt, + playedAt: existing.playedAt ?? oriented.playedAt, + lobby: existing.lobby ?? oriented.lobby, + mode: existing.mode ?? oriented.mode, + stage: existing.stage ?? oriented.stage, + matchScores: mergeScorePair(existing.matchScores, oriented.matchScores), + replayCode: existing.replayCode ?? oriented.replayCode, + cast: existing.cast || oriented.cast, + // whole-series first-ingest-wins: interleaving two partial sample + // series from different scans is not attempted + objective: existing.objective ?? oriented.objective, + teams: [ + mergeTeam(existing.teams[0], oriented.teams[0]), + mergeTeam(existing.teams[1], oriented.teams[1]), + ], + winner: existing.winner ?? oriented.winner, + pov: existing.pov ?? oriented.pov, + }; + + return { + merged, + changed: + JSON.stringify(canonicalMatch(merged)) !== + JSON.stringify(canonicalMatch(existing)), + }; +} + +/** Lowercased, width-normalized in-game name without the #discriminator. */ +export function normalizeInGameName(name: string): string { + return inGameNameWithoutDiscriminator(name) + .normalize("NFKC") + .trim() + .toLowerCase(); +} + +function canonicalObjective( + objective: ScannerMatchObjective, +): ScannerMatchObjective { + return { + mode: objective.mode, + samples: objective.samples.map((sample) => ({ + t: sample.t, + time: sample.time, + score: [sample.score[0], sample.score[1]], + penalty: [sample.penalty[0], sample.penalty[1]], + control: [sample.control[0], sample.control[1]], + })), + }; +} + +function canonicalTeam(team: ScannerMatchTeam): ScannerMatchTeam { + return { + players: team.players.map(canonicalPlayer), + }; +} + +function canonicalPlayer(player: ScannerMatchPlayer): ScannerMatchPlayer { + return { + name: player.name, + weaponId: player.weaponId, + paint: player.paint, + ka: player.ka, + d: player.d, + s: player.s, + ...(player.abilities ? { abilities: player.abilities } : null), + }; +} + +/** + * Positions at which two replay codes differ; null when either is unread. + * A length mismatch counts every position of the longer code. + */ +function replayCodeDiff(a: string | null, b: string | null): number | null { + if (a === null || b === null) return null; + const longer = Math.max(a.length, b.length); + let diff = longer - Math.min(a.length, b.length); + for (let i = 0; i < Math.min(a.length, b.length); i++) { + if (a[i] !== b[i]) diff++; + } + return diff; +} + +interface Alignment { + orientation: "straight" | "swapped"; + /** aligned readable-name matches across both team pairs (0-8) */ + nameOverlap: number; + /** aligned weapon multiset overlap across both team pairs (0-8) */ + weaponOverlap: number; +} + +/** + * How `b`'s teams best map onto `a`'s: as-is or sides swapped, scored by + * name and weapon overlap. Ties keep "straight". + */ +function bestAlignment(a: ScannerMatch, b: ScannerMatch): Alignment { + const straight = pairScore(a, b.teams[0], b.teams[1]); + const swapped = pairScore(a, b.teams[1], b.teams[0]); + const straightTotal = straight.nameOverlap + straight.weaponOverlap; + const swappedTotal = swapped.nameOverlap + swapped.weaponOverlap; + return swappedTotal > straightTotal + ? { orientation: "swapped", ...swapped } + : { orientation: "straight", ...straight }; +} + +function pairScore( + a: ScannerMatch, + bFirst: ScannerMatchTeam, + bSecond: ScannerMatchTeam, +): { nameOverlap: number; weaponOverlap: number } { + return { + nameOverlap: + nameOverlap(a.teams[0], bFirst) + nameOverlap(a.teams[1], bSecond), + weaponOverlap: + weaponOverlap(a.teams[0], bFirst) + weaponOverlap(a.teams[1], bSecond), + }; +} + +function nameOverlap(a: ScannerMatchTeam, b: ScannerMatchTeam): number { + const bNames = new Set( + b.players + .map((player) => (player.name ? normalizeInGameName(player.name) : "")) + .filter(Boolean), + ); + return a.players.filter( + (player) => player.name && bNames.has(normalizeInGameName(player.name)), + ).length; +} + +function weaponOverlap(a: ScannerMatchTeam, b: ScannerMatchTeam): number { + const pool = b.players + .map((player) => player.weaponId) + .filter((id) => id !== null); + let overlap = 0; + for (const player of a.players) { + if (player.weaponId === null) continue; + const i = pool.indexOf(player.weaponId); + if (i === -1) continue; + pool.splice(i, 1); + overlap++; + } + return overlap; +} + +function weaponSlotsRead(match: ScannerMatch): number { + return match.teams.flatMap((team) => + team.players.filter((player) => player.weaponId !== null), + ).length; +} + +function swapSides(match: ScannerMatch): ScannerMatch { + return { + ...match, + teams: [match.teams[1], match.teams[0]], + winner: match.winner === null ? null : match.winner === 0 ? 1 : 0, + pov: + match.pov === null + ? null + : { ...match.pov, team: match.pov.team === 0 ? 1 : 0 }, + matchScores: + match.matchScores === null + ? null + : [match.matchScores[1], match.matchScores[0]], + objective: + match.objective === null + ? null + : { + mode: match.objective.mode, + samples: match.objective.samples.map((sample) => ({ + ...sample, + score: [sample.score[1], sample.score[0]], + penalty: [sample.penalty[1], sample.penalty[0]], + control: [sample.control[1], sample.control[0]], + })), + }, + }; +} + +function mergeScorePair( + existing: [number | null, number | null] | null, + incoming: [number | null, number | null] | null, +): [number | null, number | null] | null { + if (existing === null) return incoming; + if (incoming === null) return existing; + return [existing[0] ?? incoming[0], existing[1] ?? incoming[1]]; +} + +/** + * Merge one team's rows: each stored row takes its incoming counterpart — + * matched by readable name, then by a weapon unique among the unmatched, + * then by position — field-wise with stored values winning. Incoming rows + * no stored row claimed append while the team stays ≤4. + */ +function mergeTeam( + existing: ScannerMatchTeam, + incoming: ScannerMatchTeam, +): ScannerMatchTeam { + const pool = incoming.players.map((player) => ({ player, used: false })); + const counterparts: (ScannerMatchPlayer | null)[] = existing.players.map( + (player) => { + const name = player.name ? normalizeInGameName(player.name) : ""; + if (!name) return null; + const hit = pool.find( + (entry) => + !entry.used && + entry.player.name !== null && + normalizeInGameName(entry.player.name) === name, + ); + if (!hit) return null; + hit.used = true; + return hit.player; + }, + ); + 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) continue; + hits[0]!.used = true; + counterparts[i] = hits[0]!.player; + } + 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) continue; + hit.used = true; + counterparts[i] = hit.player; + } + + const players = existing.players.map((player, i) => { + const counterpart = counterparts[i]; + return counterpart ? mergePlayer(player, counterpart) : player; + }); + for (const entry of pool) { + if (entry.used || players.length >= PLAYERS_PER_TEAM) continue; + players.push(entry.player); + } + + return { players }; +} + +function mergePlayer( + existing: ScannerMatchPlayer, + incoming: ScannerMatchPlayer, +): ScannerMatchPlayer { + const abilities = existing.abilities ?? incoming.abilities; + return { + name: existing.name ?? incoming.name, + weaponId: existing.weaponId ?? incoming.weaponId, + paint: existing.paint ?? incoming.paint, + ka: existing.ka ?? incoming.ka, + d: existing.d ?? incoming.d, + s: existing.s ?? incoming.s, + ...(abilities ? { abilities } : null), + }; +} diff --git a/app/features/scanner-ingest/core/Scoreboards.test.ts b/app/features/scanner-ingest/core/Scoreboards.test.ts new file mode 100644 index 000000000..f75d8041c --- /dev/null +++ b/app/features/scanner-ingest/core/Scoreboards.test.ts @@ -0,0 +1,756 @@ +import { describe, expect, it } from "vitest"; +import type { + ScannerMatch, + ScannerMatchObjective, + ScannerMatchPlayer, +} from "~/features/scanner/core/scanner-match"; +import type { ScannerLobby } from "~/features/scanner/scanner-types"; +import type { + AbilityWithUnknown, + MainWeaponId, + ModeShort, + StageId, +} from "~/modules/in-game-lists/types"; +import * as Scoreboards from "./Scoreboards"; + +const WINNER_TEAM_ID = 100; +const LOSER_TEAM_ID = 200; + +function testGame( + partial: Partial & { + matchGameResultId?: number; + tournamentMatchId?: number; + } = {}, +): Scoreboards.IngestableGame { + const { matchGameResultId = 11, tournamentMatchId = 1, ...rest } = partial; + return { + target: { type: "tournament", matchGameResultId, tournamentMatchId }, + mapIndex: 0, + mode: "SZ", + stageId: 0 as StageId, + winnerInGameNames: [], + loserInGameNames: [], + playedAt: 1000, + linkedPlayerNames: null, + ...rest, + }; +} + +function gameResultId(matched: Scoreboards.MatchedGame): number | null { + return matched.game.target.type === "tournament" + ? matched.game.target.matchGameResultId + : null; +} + +function tournamentMatchIdOf(matched: Scoreboards.MatchedGame): number | null { + return matched.game.target.type === "tournament" + ? matched.game.target.tournamentMatchId + : null; +} + +function testMatch({ + t = 60, + mode = "SZ", + stage = 0, + lobby = "PRIVATE", + names = ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"], + weapons = [10, 10, 10, 10, 20, 20, 20, 20] as (MainWeaponId | null)[], + abilities = {}, + povIndex = null, + objective = null, +}: { + t?: number; + mode?: ModeShort | null; + stage?: StageId | null; + lobby?: ScannerLobby | null; + names?: string[]; + weapons?: (MainWeaponId | null)[]; + abilities?: Record; + povIndex?: number | null; + objective?: ScannerMatchObjective | null; +} = {}): ScannerMatch { + const players = names.map( + (name, i): ScannerMatchPlayer => ({ + name: name || null, + weaponId: weapons[i]!, + paint: 1000, + ka: 10, + d: 5, + s: 2, + ...(abilities[i] ? { abilities: abilities[i] } : null), + }), + ); + return { + startsAt: t, + endsAt: t, + playedAt: null, + lobby, + mode, + stage, + matchScores: [100, 52], + replayCode: null, + cast: false, + objective, + teams: [{ players: players.slice(0, 4) }, { players: players.slice(4) }], + winner: 0, + pov: + povIndex === null + ? null + : { team: povIndex < 4 ? 0 : 1, index: povIndex % 4 }, + }; +} + +function testObjective(): ScannerMatchObjective { + return { + mode: "SZ", + samples: [ + { + t: 600, + time: 300, + score: [100, 100], + penalty: [null, null], + control: [false, false], + }, + { + t: 630, + time: 270, + score: [80, 100], + penalty: [null, 12], + control: [true, false], + }, + ], + }; +} + +/** The same game reported with sides in the other on-screen order. */ +function swapSides(match: ScannerMatch): ScannerMatch { + return { + ...match, + teams: [match.teams[1], match.teams[0]], + objective: + match.objective === null + ? null + : { + ...match.objective, + samples: match.objective.samples.map((sample) => ({ + ...sample, + score: [sample.score[1], sample.score[0]], + penalty: [sample.penalty[1], sample.penalty[0]], + control: [sample.control[1], sample.control[0]], + })), + }, + winner: match.winner === null ? null : match.winner === 0 ? 1 : 0, + matchScores: + match.matchScores === null + ? null + : [match.matchScores[1], match.matchScores[0]], + pov: + match.pov === null + ? null + : { ...match.pov, team: match.pov.team === 0 ? 1 : 0 }, + }; +} + +describe("matchedGames", () => { + it("matches a game's match and reports its index", () => { + const matched = Scoreboards.matchedGames({ + matches: [testMatch()], + games: [testGame()], + }); + + expect(matched).toHaveLength(1); + expect(matched[0]!.matchIndex).toBe(0); + expect(gameResultId(matched[0]!)).toBe(11); + }); + + it("skips matches without a known winner", () => { + const matched = Scoreboards.matchedGames({ + matches: [{ ...testMatch(), winner: null }], + games: [testGame()], + }); + + expect(matched).toHaveLength(0); + }); + + it("skips matches whose teams were not fully seen", () => { + const partial = testMatch(); + partial.teams[1].players.pop(); + const matched = Scoreboards.matchedGames({ + matches: [partial], + games: [testGame()], + }); + + expect(matched).toHaveLength(0); + }); + + it("skips a game whose linked scoreboard has different players", () => { + const matched = Scoreboards.matchedGames({ + matches: [testMatch()], + games: [ + testGame({ + matchGameResultId: 11, + linkedPlayerNames: ["a", "b", "c", "d", "e", "f", "g", "h"], + }), + testGame({ matchGameResultId: 12, playedAt: 2000 }), + ], + }); + + expect(matched.map(gameResultId)).toEqual([12]); + }); + + it("matches a re-detection of a linked scoreboard to the same game despite misread names", () => { + const matched = Scoreboards.matchedGames({ + matches: [testMatch()], + games: [ + testGame({ + matchGameResultId: 11, + linkedPlayerNames: ["w1", "w2", "w3", "wA", "l1", "l2", "l3", "lB"], + }), + testGame({ matchGameResultId: 12, playedAt: 2000 }), + ], + }); + + expect(matched.map(gameResultId)).toEqual([11]); + }); + + it("does not count unreadable names towards linked scoreboard re-detection", () => { + const matched = Scoreboards.matchedGames({ + matches: [testMatch({ names: ["", "", "", "", "l1", "l2", "l3", "l4"] })], + games: [ + testGame({ + matchGameResultId: 11, + linkedPlayerNames: ["", "", "", "", "l1", "l2", "l3", "l4"], + }), + testGame({ matchGameResultId: 12, playedAt: 2000 }), + ], + }); + + expect(matched.map(gameResultId)).toEqual([12]); + }); + + it("matches matches to games by mode and stage", () => { + const matched = Scoreboards.matchedGames({ + matches: [testMatch({ mode: "RM", stage: 1, t: 60 })], + games: [ + testGame({ mapIndex: 0, mode: "SZ", stageId: 0 as StageId }), + testGame({ mapIndex: 1, mode: "RM", stageId: 1 as StageId }), + ], + }); + + expect(matched.map((m) => m.game.mapIndex)).toEqual([1]); + }); + + it("assigns two games on the same mode and stage in chronological order", () => { + const matched = Scoreboards.matchedGames({ + matches: [ + testMatch({ + t: 60, + names: ["a", "b", "c", "d", "e", "f", "g", "h"], + }), + testMatch({ + t: 5000, + names: ["i", "j", "k", "l", "m", "n", "o", "p"], + }), + ], + games: [ + testGame({ tournamentMatchId: 1, playedAt: 1000 }), + testGame({ tournamentMatchId: 2, playedAt: 2000 }), + ], + }); + + expect(matched.map((m) => [m.matchIndex, tournamentMatchIdOf(m)])).toEqual([ + [0, 1], + [1, 2], + ]); + }); + + it("skips duplicate detections of the same game", () => { + const matched = Scoreboards.matchedGames({ + matches: [testMatch({ t: 60 }), testMatch({ t: 65 })], + games: [ + testGame({ tournamentMatchId: 1, playedAt: 1000 }), + testGame({ tournamentMatchId: 2, playedAt: 2000 }), + ], + }); + + expect(matched).toHaveLength(1); + 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" })], + games: [testGame()], + }); + + expect(matched).toHaveLength(0); + }); + + it("skips matches with unreadable mode or stage", () => { + const matched = Scoreboards.matchedGames({ + matches: [testMatch({ mode: null }), testMatch({ stage: null })], + games: [testGame()], + }); + + expect(matched).toHaveLength(0); + }); + + it("skips matches that have no matching game left", () => { + const matched = Scoreboards.matchedGames({ + matches: [ + testMatch({ t: 60 }), + testMatch({ + t: 5000, + names: ["i", "j", "k", "l", "m", "n", "o", "p"], + }), + ], + games: [testGame()], + }); + + expect(matched).toHaveLength(1); + }); + + it("skips a game whose known rosters contradict the match sides", () => { + const matched = Scoreboards.matchedGames({ + matches: [testMatch()], + games: [ + testGame({ + tournamentMatchId: 1, + // match winners are w1-w4 but this game was won by the l* players + winnerInGameNames: ["l1#1234", "l2"], + loserInGameNames: ["w1", "w2"], + playedAt: 1000, + }), + testGame({ + tournamentMatchId: 2, + winnerInGameNames: ["w1", "w2"], + loserInGameNames: ["l1#1234", "l2"], + playedAt: 2000, + }), + ], + }); + + expect(matched.map(tournamentMatchIdOf)).toEqual([2]); + }); + + it("matches known in-game names ignoring discriminator, case and unicode width", () => { + const matched = Scoreboards.matchedGames({ + matches: [ + testMatch({ + names: ["W1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"], + }), + ], + games: [ + testGame({ + winnerInGameNames: ["w1#1234"], + loserInGameNames: ["W3#5678"], + }), + ], + }); + + // "W1" matches winner roster "w1#1234" straight (1) but "w3" on the + // winning side would match the loser roster flipped (1); straight wins ties + expect(matched).toHaveLength(1); + }); + + it("does not assign a game played before the previously assigned one", () => { + const matched = Scoreboards.matchedGames({ + matches: [ + testMatch({ t: 60, mode: "RM", stage: 1 }), + testMatch({ t: 1000, mode: "SZ", stage: 0 }), + ], + games: [ + testGame({ + tournamentMatchId: 1, + mode: "SZ", + stageId: 0 as StageId, + playedAt: 1000, + }), + testGame({ + tournamentMatchId: 2, + mode: "RM" as ModeShort, + stageId: 1 as StageId, + playedAt: 2000, + }), + ], + }); + + expect(matched.map(tournamentMatchIdOf)).toEqual([2]); + }); +}); + +describe("deriveScoreboardData", () => { + function derive( + linked: Array<{ data: ScannerMatch; povUserId: number | null }>, + ) { + return Scoreboards.deriveScoreboardData({ + linked, + winnerTeamId: WINNER_TEAM_ID, + loserTeamId: LOSER_TEAM_ID, + }); + } + + it("projects a match winner-first into scoreboard data", () => { + const data = derive([{ data: testMatch(), povUserId: null }]); + + expect(data).toEqual({ + scores: [100, 52], + players: ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"].map( + (name, i) => ({ + name, + tournamentTeamId: i < 4 ? WINNER_TEAM_ID : LOSER_TEAM_ID, + weaponSplId: i < 4 ? 10 : 20, + ka: 10, + d: 5, + s: 2, + paint: 1000, + }), + ), + }); + }); + + it("a winner-1 match derives identically to its winner-0 mirror", () => { + const straight = derive([{ data: testMatch(), povUserId: null }]); + const swapped = derive([{ data: swapSides(testMatch()), povUserId: null }]); + + expect(swapped).toEqual(straight); + }); + + it("returns null for a match that cannot form a scoreboard", () => { + expect(derive([])).toBe(null); + expect( + derive([{ data: { ...testMatch(), winner: null }, povUserId: null }]), + ).toBe(null); + }); + + it("rebases counter samples to the game's first read", () => { + const data = derive([ + { data: testMatch({ objective: testObjective() }), povUserId: null }, + ]); + + expect(data!.objective).toEqual({ + mode: "SZ", + samples: [ + { + t: 0, + time: 300, + score: [100, 100], + penalty: [null, null], + control: [false, false], + }, + { + t: 30, + time: 270, + score: [80, 100], + penalty: [null, 12], + control: [true, false], + }, + ], + }); + }); + + it("derives counter samples winner-first", () => { + const straight = derive([ + { data: testMatch({ objective: testObjective() }), povUserId: null }, + ]); + const swapped = derive([ + { + data: swapSides(testMatch({ objective: testObjective() })), + povUserId: null, + }, + ]); + + expect(swapped!.objective).toEqual(straight!.objective); + }); + + it("leaves out the objective of a match with no counter reads", () => { + const data = derive([{ data: testMatch(), povUserId: null }]); + + expect(data!.objective).toBeUndefined(); + }); + + it("carries ingested player abilities through", () => { + const build: AbilityWithUnknown[][] = [ + ["ISM", "ISS", "ISS", "ISS"], + ["QR", "QSJ", "QSJ", "QSJ"], + ["SSU", "RSU", "RSU", "RSU"], + ]; + const data = derive([ + { data: testMatch({ abilities: { 5: build } }), povUserId: null }, + ]); + + expect(data!.players[5]!.abilities).toEqual(build); + expect(data!.players[0]!.abilities).toBeUndefined(); + }); + + it("keeps players with unread weapon or empty name", () => { + const data = derive([ + { + data: testMatch({ + names: ["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"], + weapons: [10, 10, null, 10, 20, 20, 20, 20], + }), + povUserId: null, + }, + ]); + + expect(data!.players).toHaveLength(8); + expect(data!.players[1]!.name).toBe(""); + expect(data!.players[1]!.weaponSplId).toBe(10); + expect(data!.players[2]!.weaponSplId).toBe(null); + expect(data!.players[2]!.ka).toBe(10); + }); + + it("keeps players whose name appears twice on the same side", () => { + const data = derive([ + { + data: testMatch({ + names: ["dupe", "dupe", "w3", "w4", "l1", "l2", "l3", "dupe"], + }), + povUserId: null, + }, + ]); + + expect(data!.players.filter((p) => p.name === "dupe")).toHaveLength(3); + }); + + it("attributes the POV seat's row to the POV user", () => { + const data = derive([{ data: testMatch({ povIndex: 2 }), povUserId: 42 }]); + + expect(data!.players[2]!.userId).toBe(42); + expect(data!.players.filter((p) => p.userId !== undefined)).toHaveLength(1); + }); + + it("attributes a losing-side POV of a winner-1 match to the right row", () => { + const data = derive([ + { data: swapSides(testMatch({ povIndex: 6 })), povUserId: 42 }, + ]); + + expect(data!.players[6]!.userId).toBe(42); + }); + + it("attributes each linked POV onto the merged scoreboard", () => { + const data = derive([ + { data: testMatch({ povIndex: 0 }), povUserId: 42 }, + { data: swapSides(testMatch({ povIndex: 5 })), povUserId: 43 }, + ]); + + expect(data!.players[0]!.userId).toBe(42); + expect(data!.players[5]!.userId).toBe(43); + }); + + it("does not attribute the same row twice", () => { + const data = derive([ + { data: testMatch({ povIndex: 2 }), povUserId: 42 }, + { data: testMatch({ povIndex: 2 }), povUserId: 43 }, + ]); + + 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(), + matchScores: null, + }; + const data = derive([ + { data: withoutScores, povUserId: null }, + { data: testMatch(), povUserId: null }, + ]); + + expect(data!.scores).toEqual([100, 52]); + }); +}); + +describe("winnerFirstPlayerNames", () => { + it("returns names winner-first with unread names empty", () => { + const names = Scoreboards.winnerFirstPlayerNames( + swapSides( + testMatch({ names: ["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"] }), + ), + ); + + expect(names).toEqual(["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"]); + }); + + it("returns null for a match without a linkable scoreboard", () => { + expect( + Scoreboards.winnerFirstPlayerNames({ ...testMatch(), winner: null }), + ).toBe(null); + }); +}); + +describe("resolveContext", () => { + /** A tournament's reported games as an ordered (mode, stageId) sequence. */ + function tournamentGames( + tournamentId: number, + sequence: [ModeShort, number][], + partial: Partial = {}, + ): Scoreboards.IngestableGameWithContext[] { + return sequence.map(([mode, stageId], i) => ({ + ...testGame({ + matchGameResultId: tournamentId * 1000 + i, + tournamentMatchId: tournamentId * 100, + mapIndex: i, + mode, + stageId: stageId as StageId, + playedAt: 1000 + i, + ...partial, + }), + context: { type: "tournament", tournamentId }, + })); + } + + /** A SendouQ match's reported games as an ordered (mode, stageId) sequence. */ + function sendouqGames( + groupMatchId: number, + sequence: [ModeShort, number][], + ): Scoreboards.IngestableGameWithContext[] { + return sequence.map(([mode, stageId], i) => ({ + ...testGame({ + mapIndex: i, + mode, + stageId: stageId as StageId, + playedAt: 1000 + i, + }), + target: { + type: "sendouq", + groupMatchMapId: groupMatchId * 1000 + i, + groupMatchId, + }, + context: { type: "sendouq", groupMatchId }, + })); + } + + const seenSequence = [ + testMatch({ t: 60, mode: "SZ", stage: 0 }), + testMatch({ t: 600, mode: "TC", stage: 1 }), + ]; + + it("resolves the tournament whose games match the seen sequence", () => { + const context = Scoreboards.resolveContext({ + matches: seenSequence, + games: [ + ...tournamentGames(1, [ + ["SZ", 0], + ["TC", 1], + ]), + ...tournamentGames(2, [ + ["SZ", 3], + ["TC", 2], + ]), + ], + }); + + expect(context).toEqual({ type: "tournament", tournamentId: 1 }); + }); + + it("resolves a SendouQ match over a tournament when its games match better", () => { + const context = Scoreboards.resolveContext({ + matches: seenSequence, + games: [ + ...tournamentGames(1, [ + ["SZ", 3], + ["TC", 2], + ]), + ...sendouqGames(7, [ + ["SZ", 0], + ["TC", 1], + ]), + ], + }); + + expect(context).toEqual({ type: "sendouq", groupMatchId: 7 }); + }); + + it("does not resolve from a single matching match", () => { + const context = Scoreboards.resolveContext({ + matches: [seenSequence[0]!], + games: tournamentGames(1, [ + ["SZ", 0], + ["TC", 1], + ]), + }); + + expect(context).toBe(null); + }); + + it("lets roster sides break a map-sequence tie", () => { + const sharedMaplist: [ModeShort, number][] = [ + ["SZ", 0], + ["TC", 1], + ]; + const context = Scoreboards.resolveContext({ + matches: seenSequence, + games: [ + ...tournamentGames(1, sharedMaplist, { + winnerInGameNames: ["w1", "w2", "w3", "w4"], + loserInGameNames: ["l1", "l2", "l3", "l4"], + }), + // the other tournament's rosters contradict the match sides + ...tournamentGames(2, sharedMaplist, { + winnerInGameNames: ["l1", "l2", "l3", "l4"], + loserInGameNames: ["w1", "w2", "w3", "w4"], + }), + ], + }); + + expect(context).toEqual({ type: "tournament", tournamentId: 1 }); + }); + + it("skips unreadable matches but resolves from the rest", () => { + const context = Scoreboards.resolveContext({ + matches: [ + seenSequence[0]!, + testMatch({ t: 300, stage: null }), + seenSequence[1]!, + ], + games: [ + ...tournamentGames(1, [ + ["SZ", 0], + ["TC", 1], + ]), + ...tournamentGames(2, [ + ["SZ", 3], + ["TC", 2], + ]), + ], + }); + + expect(context).toEqual({ type: "tournament", tournamentId: 1 }); + }); +}); diff --git a/app/features/scanner-ingest/core/Scoreboards.ts b/app/features/scanner-ingest/core/Scoreboards.ts new file mode 100644 index 000000000..c481fae0c --- /dev/null +++ b/app/features/scanner-ingest/core/Scoreboards.ts @@ -0,0 +1,502 @@ +import type { + ScannerMatch, + ScannerMatchObjective, +} from "~/features/scanner/core/scanner-match"; +import type { ScannerLobby } from "~/features/scanner/scanner-types"; +import type { + AbilityWithUnknown, + MainWeaponId, + ModeShort, + StageId, +} from "~/modules/in-game-lists/types"; +import * as Matches from "./Matches"; + +/** Lobby header value scoreboards of tournament/SendouQ games are expected to have. */ +const TOURNAMENT_LOBBY = "PRIVATE"; + +/** + * How many of the 8 player rows must carry the same readable name in the + * same position for a match to count as a re-detection of a game's + * already linked scoreboard (allows a couple of OCR misreads). + */ +const MIN_LINKED_DUPLICATE_NAME_MATCHES = 6; + +/** How many players on the winning (first) resp. losing side of a scoreboard. */ +const PLAYERS_PER_TEAM = 4; + +/** + * How many matches must align with one context's games for content + * resolution to trust it. A single game's (mode, stage, sides) is common + * across a user's history; two already carry order. + */ +const MIN_RESOLVED_SCOREBOARDS = 2; + +/** The match context an ingest request was resolved to belong to. */ +export type IngestContext = + | { type: "tournament"; tournamentId: number } + | { type: "sendouq"; groupMatchId: number }; + +/** The reported game result an ingested match can link to. */ +export type IngestableGameTarget = + | { + type: "tournament"; + matchGameResultId: number; + tournamentMatchId: number; + } + | { type: "sendouq"; groupMatchMapId: number; groupMatchId: number }; + +/** A game of a tournament or SendouQ match that ingested matches can be linked to. */ +export interface IngestableGame { + target: IngestableGameTarget; + /** 0-based index of the game within its match */ + mapIndex: number; + mode: ModeShort; + stageId: StageId; + /** known in-game names of the winning team's roster, used to validate scoreboard sides */ + winnerInGameNames: string[]; + /** known in-game names of the losing team's roster, used to validate scoreboard sides */ + loserInGameNames: string[]; + /** database timestamp used to order games chronologically across matches */ + playedAt: number; + /** + * player names (winner-first, in scoreboard row order) of an already + * linked ingested match of the game; null when the game has none yet. + * Lets matching skip taken games across requests while recognizing + * re-detections of the same scoreboard. + */ + linkedPlayerNames: string[] | null; +} + +/** A candidate game for content resolution, tagged with its context. */ +export interface IngestableGameWithContext extends IngestableGame { + context: IngestContext; +} + +export interface MatchedGame { + /** index into the input `matches` array */ + matchIndex: number; + game: IngestableGame; +} + +/** Stable grouping/equality key for an {@link IngestContext}. */ +export function contextKey(context: IngestContext): string { + return context.type === "tournament" + ? `tournament:${context.tournamentId}` + : `sendouq:${context.groupMatchId}`; +} + +/** + * Resolves which context (tournament or SendouQ match) a request's matches + * belong to from their content alone: the candidate games (the POV user's + * reported games) are grouped by context and each context is scored by how + * many matches `matchedGames` aligns with its games — the same mode+stage + * sequence walk and roster-side validation that decides what would actually + * be linked. + */ +export function resolveContext({ + matches, + games, +}: { + matches: ScannerMatch[]; + games: IngestableGameWithContext[]; +}): IngestContext | null { + const byContext = new Map(); + for (const game of games) { + const key = contextKey(game.context); + const contextGames = byContext.get(key) ?? []; + contextGames.push(game); + byContext.set(key, contextGames); + } + + let best: { context: IngestContext; matched: number } | null = null; + for (const contextGames of byContext.values()) { + const matched = matchedGames({ + matches, + games: contextGames, + }).length; + if (!best || matched > best.matched) { + best = { context: contextGames[0]!.context, matched }; + } + } + + if (!best || best.matched < MIN_RESOLVED_SCOREBOARDS) return null; + return best.context; +} + +/** + * Matches ingested matches against a context's games, deciding which game + * result each match should link to. + * + * Only matches whose winner is known with two full teams qualify (a + * minimap-only match can never link — its winner and stats are unread). + * Matches and games are both walked in chronological order: each match is + * assigned to the next not-yet-assigned game with the same mode and stage + * whose sides don't contradict the teams' known in-game names (the winning + * rows should overlap the game winner's roster, not the loser's). Matches + * from other lobbies, with unreadable mode/stage or duplicated detections + * of the same game are skipped. + * + * One session's matches may arrive over many requests (one per game), so + * games another ingest already linked to are skipped — unless the incoming + * match is a re-detection of the linked one, which is matched to the same + * game so re-sends stay idempotent and another POV's scan of the same game + * lands on it too. + */ +export function matchedGames({ + matches, + games, +}: { + matches: ScannerMatch[]; + games: IngestableGame[]; +}): MatchedGame[] { + const views = dedupeViews( + matches + .map((match, matchIndex) => { + const view = winnerFirstView(match, matchIndex); + return view ? { ...view, matchIndex } : null; + }) + .filter((view): view is IndexedView => view !== null) + .filter((view) => !view.lobby || view.lobby === TOURNAMENT_LOBBY) + .sort((a, b) => a.order - b.order), + ); + const orderedGames = games.toSorted( + (a, b) => a.playedAt - b.playedAt || a.mapIndex - b.mapIndex, + ); + + const result: MatchedGame[] = []; + + let nextGameIdx = 0; + for (const view of views) { + if (view.mode === null || view.stage === null) continue; + + for (let i = nextGameIdx; i < orderedGames.length; i++) { + const game = orderedGames[i]!; + if (game.mode !== view.mode || game.stageId !== view.stage) continue; + if (game.linkedPlayerNames) { + if (!isLinkedDuplicate(view, game.linkedPlayerNames)) { + continue; + } + } else if (!sidesMatchKnownPlayers(view, game)) { + continue; + } + + result.push({ matchIndex: view.matchIndex, game }); + nextGameIdx = i + 1; + break; + } + } + + return result; +} + +export interface IngestedScoreboardPlayer { + name: string; + tournamentTeamId: number | null; + weaponSplId: MainWeaponId | null; + ka: number | null; + d: number | null; + s: number | null; + paint: number | null; + /** [head, clothes, shoes] ability rows gathered from the match's death screens */ + abilities?: AbilityWithUnknown[][]; + /** set via POV attribution of a linked ingested match */ + userId?: number; +} + +/** + * The scoreboard of a game derived from its linked ingested matches — the + * shape match pages render. Derived at read time, not stored. + */ +export interface IngestedScoreboardData { + /** game scores [winner, loser] (0-100; a knockout's winner is 100) */ + scores: [number | null, number | null]; + /** in scoreboard order: rows 0-3 winning team, rows 4-7 losing team */ + players: IngestedScoreboardPlayer[]; + /** + * objective-counter progress of the game, per-team values [winner, loser] + * and sample `t` in seconds since the game's first read (the source video + * the raw values are offsets into is not stored). Absent when no counter + * was read. + */ + objective?: ScannerMatchObjective; +} + +/** + * Derives a game's scoreboard from its linked ingested matches: the earliest + * link is the base and later ones enrich it (first-ingest-wins field-wise, + * via Matches.mergeMatches), the merged match is projected winner-first, and + * every linked POV seat attributes its player row to the POV user. + * + * `winnerTeamId`/`loserTeamId` are the game result's sides (tournament team + * or SendouQ group ids), stamped onto the rows for the reader. + */ +export function deriveScoreboardData({ + linked, + winnerTeamId, + loserTeamId, +}: { + /** in link order, earliest first */ + linked: Array<{ data: ScannerMatch; povUserId: number | null }>; + winnerTeamId: number; + loserTeamId: number | null; +}): IngestedScoreboardData | null { + const [first, ...rest] = linked; + if (!first) return null; + + let merged = first.data; + for (const other of rest) { + merged = Matches.mergeMatches(merged, other.data).merged; + } + + const view = winnerFirstView(merged, 0); + if (!view) return null; + + const players = view.players.map( + (player, playerIdx): IngestedScoreboardPlayer => ({ + name: player.name.trim(), + tournamentTeamId: + playerIdx < PLAYERS_PER_TEAM ? winnerTeamId : loserTeamId, + weaponSplId: player.weaponId, + ka: player.ka, + d: player.d, + s: player.s, + paint: player.paint, + ...(player.abilities ? { abilities: player.abilities } : null), + }), + ); + + attributePovUsers(players, linked); + + return { + scores: view.scores, + players, + ...(view.objective ? { objective: view.objective } : null), + }; +} + +/** + * A match's players winner-first in scoreboard row order (unread names as + * empty strings), or null when the match has no such view — the + * `linkedPlayerNames` a game's already linked ingest contributes. + */ +export function winnerFirstPlayerNames(match: ScannerMatch): string[] | null { + const view = winnerFirstView(match, 0); + return view ? view.players.map((player) => player.name.trim()) : null; +} + +/** + * A match's players in linked-scoreboard order — winning team's rows first — + * with unread names as empty strings. Null when the match can't link: its + * winner is unknown or either team wasn't fully seen. + */ +interface WinnerFirstView { + lobby: ScannerLobby | null; + mode: ModeShort | null; + stage: StageId | null; + /** game scores [winner, loser] from the match's "Score:" banner */ + scores: [number | null, number | null]; + players: WinnerFirstPlayer[]; + /** counter progress with both the sides and `t` already winner-first */ + objective: ScannerMatchObjective | null; + povIndex: number | null; + /** chronological walk key: wall-clock, else video time, else input order */ + order: number; +} + +interface IndexedView extends WinnerFirstView { + matchIndex: number; +} + +interface WinnerFirstPlayer { + name: string; + weaponId: MainWeaponId | null; + paint: number | null; + ka: number | null; + d: number | null; + s: number | null; + abilities?: AbilityWithUnknown[][]; +} + +function winnerFirstView( + match: ScannerMatch, + index: number, +): WinnerFirstView | null { + if (match.winner === null) return null; + const winners = match.teams[match.winner]; + const losers = match.teams[match.winner === 0 ? 1 : 0]; + if ( + winners.players.length !== PLAYERS_PER_TEAM || + losers.players.length !== PLAYERS_PER_TEAM + ) { + return null; + } + + return { + lobby: match.lobby, + mode: match.mode, + stage: match.stage, + scores: [ + match.matchScores?.[match.winner] ?? null, + match.matchScores?.[match.winner === 0 ? 1 : 0] ?? null, + ], + players: [...winners.players, ...losers.players].map((player) => ({ + ...player, + name: player.name ?? "", + })), + objective: winnerFirstObjective(match.objective, match.winner), + povIndex: + match.pov === null + ? null + : match.pov.team === match.winner + ? match.pov.index + : PLAYERS_PER_TEAM + match.pov.index, + order: match.playedAt ?? match.startsAt ?? index, + }; +} + +/** + * Puts a match's counter samples in derived-scoreboard shape: per-team + * values winner-first like `scores` and `players`, and `t` rebased to the + * game's first read so the samples stay meaningful without the source video. + */ +function winnerFirstObjective( + objective: ScannerMatchObjective | null, + winner: 0 | 1, +): ScannerMatchObjective | null { + if (!objective || objective.samples.length === 0) return null; + + const winnerFirst = (pair: [T, T]): [T, T] => + winner === 0 ? [pair[0], pair[1]] : [pair[1], pair[0]]; + const firstT = Math.min(...objective.samples.map((sample) => sample.t)); + + return { + mode: objective.mode, + samples: objective.samples.map((sample) => ({ + t: sample.t - firstT, + time: sample.time, + score: winnerFirst(sample.score), + penalty: winnerFirst(sample.penalty), + control: winnerFirst(sample.control), + })), + }; +} + +/** + * Attributes each linked match's POV seat to its POV user on the merged + * rows: the seat's read name picks the row (unique name match), falling + * back to the seat's own winner-first position when the names don't + * contradict. A row already attributed, or a user already present, is left + * alone (first link wins). + */ +function attributePovUsers( + players: IngestedScoreboardPlayer[], + linked: Array<{ data: ScannerMatch; povUserId: number | null }>, +) { + for (const { data, povUserId } of linked) { + if (povUserId === null || data.pov === null) continue; + const view = winnerFirstView(data, 0); + if (!view || view.povIndex === null) continue; + if (players.some((player) => player.userId === povUserId)) continue; + + const povName = Matches.normalizeInGameName( + view.players[view.povIndex]!.name, + ); + const index = attributionIndex(players, povName, view.povIndex); + if (index === null || players[index]!.userId !== undefined) continue; + + players[index] = { ...players[index]!, userId: povUserId }; + } +} + +function attributionIndex( + players: IngestedScoreboardPlayer[], + povName: string, + fallbackIndex: number, +): number | null { + if (povName) { + const hits = players.flatMap((player, index) => + Matches.normalizeInGameName(player.name) === povName ? [index] : [], + ); + if (hits.length === 1) return hits[0]!; + } + + const fallback = players[fallbackIndex]; + if (!fallback) return null; + const fallbackName = Matches.normalizeInGameName(fallback.name); + if (povName && fallbackName && fallbackName !== povName) return null; + return fallbackIndex; +} + +/** + * Drops re-detections of the same game within one request: same mode and + * 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[] = []; + + for (const view of sorted) { + const isDuplicate = result.some( + (other) => + other.mode === view.mode && + other.stage === view.stage && + isLinkedDuplicate( + view, + other.players.map((player) => player.name), + ), + ); + if (!isDuplicate) result.push(view); + } + + return result; +} + +/** + * Checks that the view's sides don't contradict the teams' known rosters: + * the winning rows should overlap the game winner's in-game names at least + * as well as the losing team's (and vice versa). A contradiction means the + * match belongs to some other game. No overlap at all (e.g. no in-game + * names set) counts as a pass. + */ +function sidesMatchKnownPlayers(view: WinnerFirstView, game: IngestableGame) { + const winnerSide = view.players + .slice(0, PLAYERS_PER_TEAM) + .map((player) => Matches.normalizeInGameName(player.name)); + const loserSide = view.players + .slice(PLAYERS_PER_TEAM) + .map((player) => Matches.normalizeInGameName(player.name)); + + const knownWinners = game.winnerInGameNames.map(Matches.normalizeInGameName); + const knownLosers = game.loserInGameNames.map(Matches.normalizeInGameName); + + const straight = + nameOverlap(winnerSide, knownWinners) + nameOverlap(loserSide, knownLosers); + const flipped = + nameOverlap(winnerSide, knownLosers) + nameOverlap(loserSide, knownWinners); + + return straight >= flipped; +} + +function nameOverlap(names: string[], knownNames: string[]) { + const known = new Set(knownNames.filter(Boolean)); + return names.filter((name) => name && known.has(name)).length; +} + +/** + * Checks whether a match is a re-detection of a game's already linked + * scoreboard: enough player rows carry the same readable name in the same + * position. Positional comparison keeps two games between the same eight + * players apart — their row orders and sides practically always differ. + */ +function isLinkedDuplicate(view: WinnerFirstView, linkedPlayerNames: string[]) { + const matches = view.players.filter((player, i) => { + const name = Matches.normalizeInGameName(player.name); + const linkedName = linkedPlayerNames[i] + ? Matches.normalizeInGameName(linkedPlayerNames[i]!) + : ""; + return name !== "" && name === linkedName; + }).length; + + return matches >= MIN_LINKED_DUPLICATE_NAME_MATCHES; +} diff --git a/app/features/scanner-ingest/core/VodMatches.test.ts b/app/features/scanner-ingest/core/VodMatches.test.ts new file mode 100644 index 000000000..9ec1e2279 --- /dev/null +++ b/app/features/scanner-ingest/core/VodMatches.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { vodsNewSearchParams } from "~/features/vods/vods-search-params"; +import { + type IngestVodMatchInput, + ingestVodPrefillSchema, +} from "../scanner-ingest-vod-schemas"; +import { prefillVodMatches } from "./VodMatches"; + +// 8 real main weapon ids (4v4): Splattershot etc. +const WEAPONS: IngestVodMatchInput["weapons"] = [ + 40, 40, 40, 40, 20, 20, 20, 20, +]; + +function testMatch( + partial: Partial = {}, +): IngestVodMatchInput { + return { + startsAt: 30, + mode: "SZ", + stage: 0, + weapons: WEAPONS, + ...partial, + }; +} + +describe("prefillVodMatches", () => { + it("maps validated match rows into the form's prefill shape", () => { + const prefilled = prefillVodMatches([testMatch({ povWeapon: 20 })]); + + expect(prefilled).toHaveLength(1); + expect(prefilled[0]).toEqual({ + startsAt: 30, + mode: "SZ", + stageId: 0, + weapons: [40, 40, 40, 40, 20, 20, 20, 20], + povWeapon: 20, + }); + }); + + it("keeps unread (null) fields for the user to fill in the form", () => { + const prefilled = prefillVodMatches([ + testMatch({ + mode: null, + stage: null, + weapons: [...WEAPONS.slice(0, 7), null], + }), + ]); + + expect(prefilled).toHaveLength(1); + expect(prefilled[0]).toEqual({ + startsAt: 30, + mode: null, + stageId: null, + weapons: [40, 40, 40, 40, 20, 20, 20, null], + povWeapon: null, + }); + }); + + it("rejects rows that are not sendou ids", () => { + const parsed = ingestVodPrefillSchema.safeParse({ + matches: [{ ...testMatch(), stage: "Scorch Gorge" }], + }); + expect(parsed.success).toBe(false); + }); + + it("accepts the `ingest` search param the scanner VoD tab sends", () => { + // what the scanner VoD tab's "Add VoD" button puts in the URL + // (~/features/scanner/components/sendou-upload.ts): a { type?, matches } + // payload in the compressed `ingest` param + const href = vodsNewSearchParams.href("/vods/new", { + ingest: { type: "CAST", matches: [testMatch()] }, + }); + + const { ingest } = vodsNewSearchParams.parse( + new URL(href, "https://sendou.ink"), + ); + + expect(ingest).not.toBeNull(); + expect(ingest!.type).toBe("CAST"); + expect(prefillVodMatches(ingest!.matches)).toHaveLength(1); + }); +}); diff --git a/app/features/scanner-ingest/core/VodMatches.ts b/app/features/scanner-ingest/core/VodMatches.ts new file mode 100644 index 000000000..95d01a8dd --- /dev/null +++ b/app/features/scanner-ingest/core/VodMatches.ts @@ -0,0 +1,32 @@ +import type { + MainWeaponId, + ModeShort, + StageId, +} from "~/modules/in-game-lists/types"; +import type { IngestVodMatchInput } from "../scanner-ingest-vod-schemas"; + +export interface PrefillVodMatch { + startsAt: number; + mode: ModeShort | null; + stageId: StageId | null; + weapons: (MainWeaponId | null)[]; + /** the POV player's weapon, when the scan identified their seat */ + povWeapon: MainWeaponId | null; +} + +/** + * Turns the per-match rows a scanner VoD scan sends into prefill data for the + * /vods/new form. The rows already carry sendou ids (validated by + * ingestVodPrefillSchema); this only renames fields into the form's shape. + */ +export function prefillVodMatches( + matches: IngestVodMatchInput[], +): PrefillVodMatch[] { + return matches.map((match) => ({ + startsAt: match.startsAt, + mode: match.mode, + stageId: match.stage, + weapons: match.weapons, + povWeapon: match.povWeapon ?? null, + })); +} diff --git a/app/features/scanner-ingest/routes/scanner-ingest.ts b/app/features/scanner-ingest/routes/scanner-ingest.ts new file mode 100644 index 000000000..e5c0e76e1 --- /dev/null +++ b/app/features/scanner-ingest/routes/scanner-ingest.ts @@ -0,0 +1 @@ +export { action } from "../actions/scanner-ingest.server"; diff --git a/app/features/scanner-ingest/scanner-ingest-schemas.ts b/app/features/scanner-ingest/scanner-ingest-schemas.ts new file mode 100644 index 000000000..fafca3589 --- /dev/null +++ b/app/features/scanner-ingest/scanner-ingest-schemas.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { scannerMatchSchema } from "~/features/scanner/scanner-schemas"; + +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. The + * POV user is always the session user, never client-supplied. + */ +export const ingestBodySchema = z.object({ + matches: z.array(scannerMatchSchema).min(1).max(MAX_MATCHES_PER_REQUEST), +}); + +/** The sendou.ink match an ingested match's scoreboard was linked to. */ +export type IngestedMatchLink = + | { type: "tournament"; tournamentId: number; matchId: number } + | { type: "sendouq"; groupMatchId: number }; + +export interface IngestResponse { + storedMatchesCount: number; + mergedMatchesCount: number; + linkedGamesCount: number; + /** per request match (by its index in the body's `matches`), the match it linked to */ + linkedMatches: Array<{ matchIndex: number; link: IngestedMatchLink }>; + /** + * whether the request's matches were resolved to a tournament or SendouQ + * match. A match that stayed unlinked despite one is waiting for its game + * to be reported, so resending it later can still link it. + */ + contextResolved: boolean; +} diff --git a/app/features/scanner-ingest/scanner-ingest-vod-schemas.ts b/app/features/scanner-ingest/scanner-ingest-vod-schemas.ts new file mode 100644 index 000000000..9e6d653a2 --- /dev/null +++ b/app/features/scanner-ingest/scanner-ingest-vod-schemas.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +import { + mainWeaponIdSchema, + modeShortSchema, + stageIdSchema, +} from "~/features/scanner/scanner-schemas"; +import { videoMatchTypes } from "~/features/vods/vods-constants"; + +/** One detected match of a scanner VoD scan, projected from a ScannerMatch (~/features/scanner/components/sendou-upload.ts). */ +const ingestVodMatchSchema = z.object({ + /** whole seconds into the video the match starts at */ + startsAt: z.number().int().min(0), + /** null when no source read it */ + mode: modeShortSchema.nullable(), + /** + * true when `mode` is the scanner's fabricated PoC default (SZ) rather + * than a real read. Currently informational only — assumed modes are + * still stored, since casted footage never exposes the mode. + */ + modeAssumed: z.boolean().optional(), + /** null when no source read it */ + stage: stageIdSchema.nullable(), + /** sendou main-weapon ids; null for a slot that never read */ + weapons: z.array(mainWeaponIdSchema.nullable()).max(16), + /** + * the POV player's weapon, prefilling a non-CAST VoD's single weapon + * select. Absent when no scoreboard identified the POV seat (or it read + * no weapon) — including on casted footage, which has no POV. + */ + povWeapon: mainWeaponIdSchema.optional(), +}); + +/** + * The scanner VoD tab's "Add VoD" button packs this into /vods/new's + * `ingest` search param (an `SP.json` param, compressed by the search-params + * module) to prefill the form: the detected match rows, minus the submission + * fields (YouTube URL, title, date) the user fills in the form. `type` is + * sent only when the scan auto-detected it (spectator map screens → CAST); + * absent means the form's default. + */ +export const ingestVodPrefillSchema = z.object({ + type: z.enum(videoMatchTypes).optional(), + matches: z.array(ingestVodMatchSchema).min(1).max(100), +}); + +export type IngestVodMatchInput = z.infer; +export type IngestVodPrefill = z.infer; diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md new file mode 100644 index 000000000..6c9b21810 --- /dev/null +++ b/app/features/scanner/README.md @@ -0,0 +1,176 @@ +# Scanner — Splatoon match-event detection + +Browser app (route `/scanner`, dev-only until promoted) that watches OBS +Virtual Camera footage, VoD files, or screenshots, detects Splatoon 3 UI +screens with OpenCV.js in a Web Worker, and parses them into events speaking +sendou.ink ids (`ModeShort`/`StageId`/weapon ids/`Ability`). Events aggregate +client-side into `ScannerMatch` objects (`core/scanner-match.ts`) — one +detected game per object, every field nullable — which feed `/ingest` +(features/scanner-ingest) and the `/vods/new` prefill. Imported from the +emberz repo; see `MIGRATION.md` there. + +Deliberate convention exceptions (dev tool, ported wholesale): the UI is +English-only (no i18next) and styled by one global `components/styles.css` +instead of per-component CSS modules; `tests/node-test-compat.ts` uses a +default export to stay a `node:test` drop-in. + +## Commands + +```sh +pnpm test:scanner # golden-file suite over tests/fixtures/ (Vitest, Node) +pnpm scanner:report # accuracy table + name character error rate across fixtures +pnpm scanner:fixtures [name-substring] # run detectors over matching fixtures, verbose +pnpm scanner:replay # replay ffmpeg-extracted frames through the scheduler+detectors +pnpm scanner:bootstrap-atlas # harvest labeled fixture crops into the glyph atlases +pnpm scanner:build-glyph-atlas # add the font-rendered charset (fonts required, see below) +pnpm scanner:build-localized-entries # regen localized closed sets from ../splat3 +pnpm scanner:build-planner-signatures # regen the minimap stage-ID atlas from the assets repo +``` + +Scanner scripts run through `vite-node -c scripts/scanner/vite-node.config.ts`: +the root vite config pre-bundles `@techstark/opencv-js` for the browser worker +and vite-node must not consume that prebundle. The package is pnpm-patched +(`patches/`) to wrap its thenable CJS export as `{ cvReadyPromise }`, +unwrapped in `core/cv.ts`. + +## Architecture + +```mermaid +sequenceDiagram + participant Cap as capture (sampler / vod-frames) + participant W as analyzer.worker (OpenCV) + participant TL as TimelineBuilder + participant MB as match-builder + participant UI as Live/VoD tab + participant ING as /ingest (scanner-ingest) + participant DB as IngestedMatch / IngestedMatchLink + Cap->>W: frame + t (live/screenshot/seek) — VoD: worker decodes its own slice + W->>W: scheduler dueDetectors() → gate() → parse() + W-->>TL: DetectedEvents + TL-->>UI: deduped timeline (IndexedDB on Live) + UI->>MB: buildScannerMatches(events) + MB-->>UI: ScannerMatch[] + source events + UI->>ING: POST { matches } (Live: on match close / scan end, VoD: whole scan) + ING->>ING: resolve context (current tournament/SendouQ activity, casts via staff roles, else content sequence ≥2) + ING->>DB: merge-store IngestedMatch (matchHash, isSameMatch + merge, context hints) + ING->>DB: link matches to game results → IngestedMatchLink (POV weapon → ReportedWeapon; scoreboards derived at read time) + Note over UI: VoD "Add VoD": ScannerMatch → slim prefill param → /vods/new +``` + +- `core/` is pure (mats in, events/matches out) and runs in the worker, the + Screenshot tab, and Node tests. No DOM/browser APIs; Node-only helpers live + in `node/`. Pure data/type imports from `~/modules` and + `~/features/build-analyzer/data` are fine — zod and the app config graph + are not (schemas live in `scanner-schemas.ts`; core only `import type`s + the shapes). +- `core/match-builder.ts` turns a timeline into `ScannerMatch`es: a MapStart + opens a match, a scoreboard closes one (claiming the last 8 min of deaths + when the intro was missed), minimaps group per map by confirmed stage + change and >5 min gap. An event belongs to at most one match; deaths + reveal enemy builds (`ability-harvest.ts`). Partial matches are fine — + scanner-ingest merges them server-side. Senders filter with + `ingestSkipReasons`: private/unread lobby only, and no games a disconnect + cut short (scoreless + counter left more time than the footage did, or + replayed right after on the same map — the latter is a VoD-scan filter in + practice since it only resolves after the fact). +- The route (`routes/scanner.tsx`) is SSR-guarded: the client tree loads via + `React.lazy` after `useHydrated`; nothing from `core/worker/capture/store` + may be imported at route-module top level. +- Eight detectors: `scoreboard` (results screen), + `scoreboard-battle-log-replay` (replay-browser detail), + `scoreboard-battle-log` (Recent Battles detail — same data sans replay + code, panels stacked), `scoreboard-own` (personal results), `death` + (respawn overlay), `map-start` (match intro), `minimap` (in-match overlay + + casted 8-player spectator variant), `objective` (ranked counter overlay: + counts, penalties, holder, match timer — a mode-discriminated union with + only the SZ member so far). Objective reads land on `ScannerMatch` as + progress samples anchored to the game clock. Reads grouping into a match + whose detected mode is not SZ are lookalike misreads: the builder nulls + that match's `objective` and callers discard the events + (`invalidObjectiveEvents`; Live also stops collecting once a MapStart + reveals a non-SZ mode). Parsing details are in each detector's module + header; accuracy-critical matching internals in `core/glyphs.ts` and + `core/detectors/scoreboard/weapons.ts` — read those before touching + recognition code. +- Scheduling (`core/detectors/scheduler.ts`): the per-session + DetectorScheduler decides which detectors see a frame. Failing gates are + re-checked every `searchIntervalS` (0.25s — produced VoDs cut screens to + ~1s, and gates are ~ms-cheap); a passing gate drops to the dense refine + cadence (`refineIntervalS` overrides for expensive parses). Suppression + ends a refinement streak on parse-count stagnation AND ~3s elapsed (the + floor spans entry animations), or immediately at `sufficientConfidence` + (set just under each detector's measured clean-read floor); death adds + `rearmCooldownS`. Battle-log/replay gates return a content `signature` so + browsing distinct entries re-parses once per battle instead of dropping + the gate. `checkIntervalS` hard-caps both phases; `attachFrame: false` + keeps continuously-firing events from storing a frame PNG each. Frames no + detector is due for skip canvas readback, and everything is counted in + `core/detectors/telemetry.ts` (VoD tab's telemetry panel). A match's + objective reads render as one step-line timeline + (`~/components/ObjectiveTimeline.tsx`, shared with the match page). +- VoD scans (`components/VodPage.tsx`): on the WebCodecs path each worker + demuxes + decodes its own contiguous slice (mediabunny in the worker — no + frames cross the main thread). When the scheduler reports calm (no gate + pass for a quiet period, no open match), the worker skims + keyframe-to-keyframe (hop capped at 2.5s so short screens can't hide), + snapping back to dense decode on any gate pass. The seek fallback drives + one worker and widens its stride over calm footage the same way. +- Recognition is language-agnostic: OCR output snaps against every game + language at once (`core/localized-entries.ts`, generated) and events carry + sendou ids. English display names come from `components/labels.ts`. +- ROI coordinates live in each detector's `rois.ts`, in canonical 1920×1080 + space; every frame is normalized to that size first. +- New event types implement `Detector` (`core/detectors/types.ts`): a cheap + `gate(mat)` at sample rate plus `parse(mat, t)` when the gate fires. + Register in `core/detectors/registry.ts`. + +## Assets (CDN) and fonts + +Weapon/ability/special/sub template sources are the site's shared game icons +in the **sendou-ink/assets repo** under `assets/img/**` (`.avif`; ids from +`~/modules/in-game-lists`, plus the scanner-only `UNKNOWN` ability badge — +`toAbilityWithUnknown` narrows template ids back to sendou ids). +Scanner-specific sets — glyph atlases and the planner signature atlas — live +here under `public/scanner/v1/**` (override with `SCANNER_ASSETS_DIR`; the +version segment bumps on breaking atlas-format changes). xxx: the atlases are +in `public/` only while the feature is in development — move them to the +assets repo (and the worker back to the CDN base) later. + +- Browser/worker: icons from `Config.staticAssetsUrl` at `img/**` (base URL + rides the worker init message; the DO Space needs CORS for GET from + sendou.ink + localhost); atlases same-origin from `/scanner/v1/**`. Local + dev against fresh icon regens: + `npx serve /Users/kalle/Developer/assets/assets -l 9100 --cors` and + `VITE_STATIC_ASSETS_URL=http://localhost:9100` in `.env`. +- Node (tests/scripts): atlases from `public/scanner/v1`, icons from the + `../assets` checkout, never the CDN. AVIF decodes through `sharp` + (`node/image-io.ts`) — `@napi-rs/canvas` mis-decodes AVIF partial-alpha. +- Atlas regens overwrite `public/scanner/v1` in place and ship with the app + build. + +Fonts are proprietary and gitignored: `BlitzMain.otf`, `BlitzBold.otf`, +`FOT-RowdyStd-EB.otf`, `FOT-KurokaneStd-EB.otf` in `assets/fonts/` (repo +root; from the splatoon3-fonts repo). Atlas builders fail loudly without +them. Names and row digits use BlitzMain; team totals BlitzBold; the replay +code line and VICTORY/DEFEAT tags FOT-RowdyStd-EB; the JP death message mixes +condensed Kurokane and Rowdy (`death-weapon-ja`). Regeneration order: +`scanner:bootstrap-atlas` (fixture crops win via tie-break) → +`scanner:build-glyph-atlas`; localized sets via +`scanner:build-localized-entries` (expects a splat3 checkout at `../splat3`) +then the atlas rebuild; planner atlas via `scanner:build-planner-signatures` +(reads the assets repo's `assets/planner-maps/`, MINI variant). + +## Fixtures + +A test case is a directory `tests/fixtures///` with +`frame.png|jpg` (raw capture, never re-encoded) and `expected.json` (partial +expectations, sendou ids; `stageLabel`/`weaponLabel` are informational for +the human corrector — tests compare only ids). Negative cases +(`{ "event": "none" }`) go in the shared `tests/fixtures/negative/`; every +detector's suite sweeps them. Every live misread should become a fixture — +the live app's "Save fixture" button exports the byte-exact analyzed frame +plus a prefilled `expected.json`. **Fixture ground-truth labels are +hand-corrected by the user (the Splatoon domain authority) — treat them as +definitive over any matcher output.** Fixtures are committed as plain blobs +(no LFS for now); keep additions deliberate — fixture IO is isolated in +`node/fixtures.ts` if a retreat to LFS/an external corpus is needed. diff --git a/app/features/scanner/capture/sampler.ts b/app/features/scanner/capture/sampler.ts new file mode 100644 index 000000000..883891ddf --- /dev/null +++ b/app/features/scanner/capture/sampler.ts @@ -0,0 +1,75 @@ +/** + * Capture layer: OBS Virtual Camera in via getUserMedia, frames out as + * ImageBitmaps at a low sample rate. The interface downstream is just + * (bitmap, t) — a WHIP/MediaMTX transport can replace this file later. + */ + +export async function openVirtualCamera( + deviceId?: string, +): Promise { + return navigator.mediaDevices.getUserMedia({ + video: { + deviceId: deviceId ? { exact: deviceId } : undefined, + width: { ideal: 1920 }, + height: { ideal: 1080 }, + }, + audio: false, + }); +} + +export async function listVideoInputs(): Promise { + const devices = await navigator.mediaDevices.enumerateDevices(); + return devices.filter((d) => d.kind === "videoinput"); +} + +export type FrameHandler = (bitmap: ImageBitmap, t: number) => void; + +// xxx: hidden tabs can still be frozen/discarded outright, which suspends the +// workers too. A silent looping