Scanner WIP (#3239)

This commit is contained in:
Kalle
2026-08-07 20:47:19 +03:00
committed by GitHub
parent 2029129f2d
commit 59fa42c513
374 changed files with 84665 additions and 223 deletions

View File

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

3
.gitignore vendored
View File

@@ -34,3 +34,6 @@ dump
.e2e-build-marker
notepad.txt
# proprietary game fonts for the scanner glyph-atlas builders (scripts/scanner)
/assets/fonts/

View File

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

View File

@@ -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(
<fetcher.Form
@@ -99,13 +105,26 @@ export function FormWithConfirm({
<FormMessage type="info">{description}</FormMessage>
) : null}
<div className="stack horizontal md justify-center mt-2">
<SubmitButton
form={id}
variant={submitButtonVariant}
testId={dialogOpen ? "confirm-button" : submitButtonTestId}
>
{submitButtonText ?? t("common:actions.delete")}
</SubmitButton>
{onConfirm ? (
<SendouButton
variant={submitButtonVariant}
testId={dialogOpen ? "confirm-button" : submitButtonTestId}
onPress={() => {
closeDialog();
onConfirm();
}}
>
{submitButtonText ?? t("common:actions.delete")}
</SendouButton>
) : (
<SubmitButton
form={id}
variant={submitButtonVariant}
testId={dialogOpen ? "confirm-button" : submitButtonTestId}
>
{submitButtonText ?? t("common:actions.delete")}
</SubmitButton>
)}
</div>
</div>
</SendouDialog>

View File

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

View File

@@ -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 (
<div className={styles.container}>
<Line
data={{ datasets }}
options={{
animation: false,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
scales: {
x: {
type: "linear",
min: sorted[0]!.t,
max: sorted[sorted.length - 1]!.t,
grid: { color: colors.border },
border: { color: colors.borderHigh },
ticks: {
color: colors.text,
maxRotation: 0,
maxTicksLimit: 8,
callback: (value) => 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(" · ");
},
},
},
},
}}
/>
</div>
);
}
/**
* 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}`;
}

View File

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

View File

@@ -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 (
<div
className={clsx(styles.banner, className)}
style={
{
"--stage-banner": `url(${stageBannerImageUrl(stageId)})`,
} as React.CSSProperties
}
>
{children}
</div>
);
}

View File

@@ -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<string, Map<string, AnyWeapon>>();
function useWeaponFilter() {
const { t } = useTranslation(["weapons"]);
const { t, i18n } = useTranslation(["weapons"]);
const weaponNameToWeaponMap = (() => {
const map = new Map<string, AnyWeapon>();
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<string, AnyWeapon>();
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<MainWeaponId>;
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<typeof buildAllWeaponCategories>
>();
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();
}

View File

@@ -78,6 +78,7 @@ export function SendouSelect<T extends object>({
clearable = false,
className,
filter,
onOpenChange,
...props
}: SendouSelectProps<T>) {
const { t } = useTranslation(["common"]);
@@ -86,6 +87,8 @@ export function SendouSelect<T extends object>({
const isControlled = !!onSearchInputChange;
const handleOpenChange = (isOpen: boolean) => {
onOpenChange?.(isOpen);
if (!isControlled) return;
if (!isOpen) {

View File

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

View File

@@ -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<string> = 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<MainWeaponId | null>;
bravo: Array<MainWeaponId | null>;
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) => (
<TimelineSubstitutionRow key={j} substitution={sub} />
))}
<TimelineMapRow map={map} />
<TimelineMapRow map={map} teams={teams} />
</div>
);
})}
@@ -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 (
<div className={styles.mapEvent}>
@@ -219,6 +270,8 @@ function TimelineMapRow({ map }: { map: TimelineMap }) {
<SideResult
result={map.winner === "ALPHA" ? "WIN" : "LOSS"}
isKo={map.ko && map.winner === "ALPHA"}
scoreboardScore={map.scoreboard?.scores[0]}
objectiveScore={objectiveScores[0]}
weapons={map.weapons?.alpha}
isPicked={map.pickedBy === "ALPHA"}
/>
@@ -239,14 +292,19 @@ function TimelineMapRow({ map }: { map: TimelineMap }) {
<span>{shortStageName(t(`game-misc:STAGE_${map.stageId}`))}</span>
</div>
</div>
<div className={styles.mapSide}>
<div className={clsx(styles.mapSide, styles.mapSideBravo)}>
<SideResult
result={map.winner === "BRAVO" ? "WIN" : "LOSS"}
isKo={map.ko && map.winner === "BRAVO"}
scoreboardScore={map.scoreboard?.scores[1]}
objectiveScore={objectiveScores[1]}
weapons={map.weapons?.bravo}
isPicked={map.pickedBy === "BRAVO"}
/>
</div>
{map.scoreboard ? (
<TimelineScoreboardSection scoreboard={map.scoreboard} teams={teams} />
) : null}
</div>
);
}
@@ -254,49 +312,268 @@ function TimelineMapRow({ map }: { map: TimelineMap }) {
function SideResult({
result,
isKo,
scoreboardScore,
objectiveScore,
weapons,
isPicked,
}: {
result: "WIN" | "LOSS";
isKo?: boolean;
weapons?: Array<MainWeaponId | null>;
/** 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 (
<div className={styles.sideResult}>
<div className={styles.resultHeader}>
{isPicked ? (
<ExplainerIcon
icon={
<MousePointerClick
size={14}
className={result === "WIN" ? "text-success" : "text-error"}
/>
}
description={t("q:match.timeline.explainer.picked")}
/>
) : null}
<span
className={clsx(
styles.resultLabel,
result === "WIN" ? "text-success" : "text-error",
)}
>
{result === "WIN"
? t("q:match.timeline.win")
: t("q:match.timeline.loss")}
</span>
{isKo ? (
<span className={styles.resultPoints}>{t("q:match.action.ko")}</span>
) : null}
<div className={styles.resultHeaderGroup}>
<div className={styles.resultHeader}>
{isPicked ? (
<ExplainerIcon
icon={
<MousePointerClick
size={14}
className={result === "WIN" ? "text-success" : "text-error"}
/>
}
description={t("q:match.timeline.explainer.picked")}
/>
) : null}
<span
className={clsx(
styles.resultLabel,
result === "WIN" ? "text-success" : "text-error",
)}
>
{result === "WIN"
? t("q:match.timeline.win")
: t("q:match.timeline.loss")}
</span>
{isKo && score === null ? (
<span className={styles.resultPoints}>
{t("q:match.action.ko")}
</span>
) : null}
</div>
{score ? <ResultPoints score={score} /> : null}
</div>
{weapons ? <WeaponPool weapons={weapons} /> : null}
</div>
);
}
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 (
<span className={styles.resultPoints}>{t("q:match.action.ko")}</span>
);
}
return (
<span className={styles.resultPoints}>
{score.fromObjective
? `(${score.value})`
: t("q:match.timeline.points", { points: score.value })}
</span>
);
}
function TimelineScoreboardSection({
scoreboard,
teams,
}: {
scoreboard: NonNullable<TimelineMap["scoreboard"]>;
teams: MatchTimelineProps["teams"];
}) {
const { t } = useTranslation(["q"]);
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className={styles.scoreboard}>
<button
type="button"
className={styles.scoreboardToggle}
onClick={() => setIsExpanded(!isExpanded)}
aria-expanded={isExpanded}
>
{t("q:match.timeline.details")}
<ChevronDown
size={14}
className={clsx(styles.scoreboardChevron, {
[styles.scoreboardChevronOpen]: isExpanded,
})}
/>
</button>
{isExpanded ? (
<div className={styles.scoreboardPanel}>
{scoreboard.objective && scoreboard.objective.length > 0 ? (
<ObjectiveTimeline
events={scoreboard.objective}
teamLabels={[teams.alpha.name, teams.bravo.name]}
/>
) : null}
<div className={styles.scoreboardTables}>
<ScoreboardTable
name={teams.alpha.name}
players={scoreboard.alpha}
/>
<ScoreboardTable
name={teams.bravo.name}
players={scoreboard.bravo}
/>
</div>
</div>
) : null}
</div>
);
}
function ScoreboardTable({
name,
players,
}: {
name: string;
players: TimelineScoreboardPlayer[];
}) {
const { t } = useTranslation(["q"]);
return (
<table className={styles.scoreboardTable}>
<thead>
<tr className={styles.scoreboardHeaderRow}>
<th className={styles.scoreboardWeaponColumn} />
<th scope="col" className={styles.scoreboardTeamName}>
{name}
</th>
<th scope="col" className={styles.scoreboardStatHeader}>
{t("q:match.timeline.stats.paint")}
</th>
<th scope="col" className={styles.scoreboardStatHeader}>
{t("q:match.timeline.stats.kills")}
</th>
<th scope="col" className={styles.scoreboardStatHeader}>
{t("q:match.timeline.stats.deaths")}
</th>
<th scope="col" className={styles.scoreboardStatHeader}>
{t("q:match.timeline.stats.specials")}
</th>
<th className={styles.scoreboardBuildColumn} />
</tr>
</thead>
<tbody>
{players.map((player, i) => (
<tr key={i} className={styles.scoreboardPlayerRow}>
<td className={styles.scoreboardWeaponCell}>
{player.weaponSplId !== null ? (
<WeaponImage
weaponSplId={player.weaponSplId}
variant="badge"
size={28}
/>
) : (
<Image
path={abilityImageUrl("UNKNOWN")}
alt="?"
size={28}
className={styles.scoreboardUnknownWeapon}
/>
)}
</td>
<th scope="row" className={styles.scoreboardPlayerName}>
{player.name}
</th>
<td className={styles.scoreboardStat}>
{player.paint !== null
? t("q:match.timeline.points", { points: player.paint })
: "–"}
</td>
<td className={styles.scoreboardStat}>{player.ka ?? "–"}</td>
<td className={styles.scoreboardStat}>{player.d ?? "–"}</td>
<td className={styles.scoreboardStat}>{player.s ?? "–"}</td>
<td className={styles.scoreboardBuildCell}>
{player.abilities && player.abilities.length > 0 ? (
<ScoreboardBuildPopover abilities={player.abilities} />
) : null}
</td>
</tr>
))}
</tbody>
</table>
);
}
function ScoreboardBuildPopover({ abilities }: { abilities: string[][] }) {
const { t } = useTranslation(["common"]);
return (
<SendouPopover
trigger={
<SendouButton shape="circle" size="small" variant="minimal">
<Image
path={navIconUrl("builds")}
alt={t("common:pages.builds")}
size={18}
/>
</SendouButton>
}
>
<div className={styles.scoreboardAbilities}>
{abilities.map((row, i) => (
<div key={i} className={styles.scoreboardAbilityRow}>
{row.map((ability, j) => (
<Ability
key={j}
ability={toAbility(ability)}
size={j === 0 ? "MAIN" : "SUB"}
/>
))}
</div>
))}
</div>
</SendouPopover>
);
}
function toAbility(value: string): AbilityWithUnknown {
return ABILITY_NAMES.has(value) ? (value as AbilityWithUnknown) : "UNKNOWN";
}
function TimelineEventRow({
icon,
alphaContent,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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. */

View File

@@ -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<number>;
}
export interface IngestedMatch {
id: GeneratedAlways<number>;
povUserId: number | null;
submitterUserId: number | null;
/** database timestamp (seconds) the match was played at, when known */
playedAt: number | null;
data: JSONColumnType<ScannerMatch>;
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<number>;
}
/** Links an ingested match to the game result it describes (exactly one target). */
export interface IngestedMatchLink {
id: GeneratedAlways<number>;
ingestedMatchId: number;
tournamentMatchGameResultId: number | null;
groupMatchMapId: number | null;
createdAt: Generated<number>;
}
export interface Skill {
groupMatchId: number | null;
id: GeneratedAlways<number>;
@@ -1286,6 +1311,8 @@ export interface DB {
GroupReadyCheck: GroupReadyCheck;
GroupReadyCheckConfirmation: GroupReadyCheckConfirmation;
GroupSuggestion: GroupSuggestion;
IngestedMatch: IngestedMatch;
IngestedMatchLink: IngestedMatchLink;
PrivateUserNote: PrivateUserNote;
LogInLink: LogInLink;
LFGPost: LFGPost;

View File

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

View File

@@ -149,19 +149,25 @@ export function GraphicTeamRow({
))}
</div>
</div>
<div className={styles.weapons}>
{team.weapons.map((weaponSplId, index) => (
<div key={`${weaponSplId}-${index}`} className={styles.weaponKit}>
<WeaponImage weaponSplId={weaponSplId} variant="badge" size={38} />
<SpecialWeaponImage
specialWeaponId={
weaponParams().weaponKits[weaponSplId].specialWeaponId
}
size={24}
/>
</div>
))}
</div>
{team.weapons.length > 0 ? (
<div className={styles.weapons}>
{team.weapons.map((weaponSplId, index) => (
<div key={`${weaponSplId}-${index}`} className={styles.weaponKit}>
<WeaponImage
weaponSplId={weaponSplId}
variant="badge"
size={38}
/>
<SpecialWeaponImage
specialWeaponId={
weaponParams().weaponKits[weaponSplId].specialWeaponId
}
size={24}
/>
</div>
))}
</div>
) : null}
</Element>
);
}

View File

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

View File

@@ -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({
</div>
) : null}
{bestStage ? (
<div
<StageBannerBox
stageId={bestStage.stageId}
className={clsx(graphicStyles.box, styles.bestStageRow)}
style={
{
"--best-stage-banner": `url(${stageBannerImageUrl(bestStage.stageId)})`,
} as React.CSSProperties
}
>
<div className={graphicStyles.boxLabel}>
{t("user:seasons.summary.bestStage")}
@@ -281,7 +278,7 @@ export function SeasonSummaryGraphic({
{Math.round(bestStage.winratePercentage)}%
</span>
</div>
</div>
</StageBannerBox>
) : null}
<div className={styles.middleGrid}>
<div className={clsx(graphicStyles.box, styles.activityBox)}>

View File

@@ -1,3 +1,9 @@
.tierPillContainer {
display: inline-flex;
vertical-align: middle;
margin-inline-start: var(--s-2);
}
.organizationName {
max-width: 12rem;
overflow: hidden;

View File

@@ -90,7 +90,11 @@ export function TournamentGraphicHeader({
titleRow={
<>
<span className={graphicStyles.headerTitle}>{tournamentName}</span>
{typeof tier === "number" ? <TierPill tier={tier} /> : null}
{typeof tier === "number" ? (
<span className={styles.tierPillContainer}>
<TierPill tier={tier} />
</span>
) : null}
</>
}
subtitle={

View File

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

View File

@@ -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<string, CompObservation[]>();
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<MainWeaponId, number>();
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<MainWeaponId, number>();
const lastPlayedAt = new Map<MainWeaponId, number>();
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
);
}

View File

@@ -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() {
</Main>
);
}
/**
* 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;
}

View File

@@ -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> = {}): 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,
};
}

View File

@@ -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<number>`"TournamentMatch"."opponentOne" ->> '$.id'`;
const opponentTwoId = sql<number>`"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<number[]> {
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<number, typeof rows>();
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<DB>,
{
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<DB>,
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<DB>,
{
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<T extends { createdAt: number }>(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<IngestableGameWithContext[]> {
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<number | null>) {
const uniqueTeamIds = [
...new Set(teamIds.filter((id): id is number => id !== null)),
];
if (uniqueTeamIds.length === 0) return new Map<number, string[]>();
const members = await db
.selectFrom("TournamentTeamMember")
.innerJoin("User", "User.id", "TournamentTeamMember.userId")
.select((eb) => [
"TournamentTeamMember.tournamentTeamId",
eb.fn
.coalesce("TournamentTeamMember.inGameName", "User.inGameName")
.as("inGameName"),
])
.where("TournamentTeamMember.tournamentTeamId", "in", uniqueTeamIds)
.execute();
const result = new Map<number, string[]>();
for (const member of members) {
if (!member.inGameName) continue;
const names = result.get(member.tournamentTeamId) ?? [];
names.push(member.inGameName);
result.set(member.tournamentTeamId, names);
}
return result;
}
async function sendouqGames({
groupMatchId,
userId,
since,
}: {
groupMatchId?: number;
userId?: number;
since?: number;
}): Promise<IngestableGameWithContext[]> {
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<number, string[]>();
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<number, string[]>();
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<number, string[]>();
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<DB>,
{ 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();
}

View File

@@ -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<Scoreboards.IngestableGameWithContext[]>;
}
/**
* 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<ResolvedIngestContext | null> {
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<string>();
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();
}

View File

@@ -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> = {},
): ScannerMatchPlayer {
return {
name,
weaponId,
paint: null,
ka: null,
d: null,
s: null,
...partial,
};
}
function testMatch(partial: Partial<ScannerMatch> = {}): 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]);
});
});

View File

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

View File

@@ -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<Scoreboards.IngestableGame> & {
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<number, AbilityWithUnknown[][]>;
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.IngestableGame> = {},
): 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 });
});
});

View File

@@ -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<string, IngestableGameWithContext[]>();
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 = <T>(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;
}

View File

@@ -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> = {},
): 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);
});
});

View File

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

View File

@@ -0,0 +1 @@
export { action } from "../actions/scanner-ingest.server";

View File

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

View File

@@ -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<typeof ingestVodMatchSchema>;
export type IngestVodPrefill = z.infer<typeof ingestVodPrefillSchema>;

View File

@@ -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 <dir> <startT> <fps> # 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/<detector>/<case-name>/` 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.

View File

@@ -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<MediaStream> {
return navigator.mediaDevices.getUserMedia({
video: {
deviceId: deviceId ? { exact: deviceId } : undefined,
width: { ideal: 1920 },
height: { ideal: 1080 },
},
audio: false,
});
}
export async function listVideoInputs(): Promise<MediaDeviceInfo[]> {
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 <audio> marks the page as playing audio and
// exempts it from both intensive throttling and freezing — measure whether we
// need it before adding it.
/**
* Sample frames from a playing video element at ~fps. The clock is a
* setInterval in a dedicated worker (ticker.worker.ts): rAF and
* requestVideoFrameCallback pause entirely in hidden tabs, but worker timers
* keep firing and the camera stream keeps decoding, so capture continues
* while the user is in another tab or window. Timestamps come from
* performance.now() (monotonic, which the timeline's merge windows rely on)
* rather than mediaTime, which Firefox never advances for MediaStream-backed
* videos anyway. Returns a stop function.
*/
export function startSampler(
video: HTMLVideoElement,
fps: number,
onFrame: FrameHandler,
): () => void {
const ticker = new Worker(new URL("./ticker.worker.ts", import.meta.url), {
type: "module",
});
ticker.postMessage(1000 / fps);
let stopped = false;
let sampling = false;
ticker.onmessage = async () => {
if (stopped || sampling) return;
sampling = true;
try {
const bitmap = await createImageBitmap(video);
if (stopped) {
bitmap.close();
return;
}
onFrame(bitmap, performance.now() / 1000);
} catch {
// video not ready — skip this frame
} finally {
sampling = false;
}
};
return () => {
stopped = true;
ticker.terminate();
};
}

View File

@@ -0,0 +1,9 @@
/**
* Interval clock for the frame sampler. Lives in a worker because worker
* timers keep firing while the tab is hidden, unlike main-thread rAF /
* requestVideoFrameCallback / throttled setInterval. Receives the interval
* in ms, then posts a tick forever.
*/
self.onmessage = (e: MessageEvent<number>) => {
setInterval(() => self.postMessage(0), e.data);
};

View File

@@ -0,0 +1,95 @@
/**
* VoD scan entry points. The primary path probes whether WebCodecs (via
* mediabunny) can decode the file — if so, the analyzer workers each demux
* and decode their own time slice of it (worker/analyzer.worker.ts) and no
* frames cross the main thread at all. When the container/codec can't be
* read that way, the fallback seek-steps a <video> element, which handles
* anything the browser can play at the cost of per-seek latency; its stride
* is supplied per step so the caller can widen it over calm footage.
*/
import { ALL_FORMATS, BlobSource, Input } from "mediabunny";
interface VodFrame {
/** the consumer owns the frame and must close() it */
frame: ImageBitmap;
/** seconds into the video */
t: number;
}
/**
* Whether mediabunny + WebCodecs can decode `file`, and its duration if so.
*/
export async function probeWebCodecs(
file: File,
): Promise<{ duration: number } | null> {
const input = new Input({
formats: ALL_FORMATS,
source: new BlobSource(file),
});
try {
const track = await input.getPrimaryVideoTrack();
if (track && (await track.canDecode())) {
return { duration: await input.computeDuration([track]) };
}
return null;
} catch {
return null;
} finally {
input.dispose();
}
}
/**
* Open a seek-stepping scan over `video` (which must already have the file
* loaded; metadata is awaited here). `nextStrideS` is consulted after every
* yielded frame, so analysis feedback can adjust the step on the fly.
*/
export async function openSeekScan(
video: HTMLVideoElement,
nextStrideS: () => number,
): Promise<{ duration: number; frames: AsyncGenerator<VodFrame> }> {
await loadMetadata(video);
if (!Number.isFinite(video.duration)) {
throw new Error("video has no known duration — cannot scan by seeking");
}
return { duration: video.duration, frames: seekFrames(video, nextStrideS) };
}
async function* seekFrames(
video: HTMLVideoElement,
nextStrideS: () => number,
): AsyncGenerator<VodFrame> {
for (let t = 0; t < video.duration; ) {
await seekTo(video, t);
yield { frame: await createImageBitmap(video), t };
t += Math.max(0.01, nextStrideS());
}
}
function loadMetadata(video: HTMLVideoElement): Promise<void> {
return new Promise((resolve, reject) => {
if (video.readyState >= HTMLMediaElement.HAVE_METADATA) return resolve();
video.addEventListener("loadedmetadata", () => resolve(), { once: true });
video.addEventListener(
"error",
() =>
reject(
new Error(video.error?.message || "cannot decode this file as video"),
),
{ once: true },
);
});
}
function seekTo(video: HTMLVideoElement, t: number): Promise<void> {
return new Promise((resolve) => {
if (
video.currentTime === t &&
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
) {
return resolve();
}
video.addEventListener("seeked", () => resolve(), { once: true });
video.currentTime = t;
});
}

View File

@@ -0,0 +1,61 @@
/**
* Gear-ability grid (3 rows: head/clothes/shoes, each [main, sub, sub, sub])
* shared by the death card and the scoreboard player popover.
*/
import { Button } from "react-aria-components";
import { Ability } from "~/components/Ability";
import { SendouPopover } from "~/components/elements/Popover";
import type { AbilityWithUnknown } from "~/modules/in-game-lists/types";
const ROW_LABELS = ["head", "clothes", "shoes"] as const;
export function AbilityGrid({
abilities,
}: {
abilities: AbilityWithUnknown[][];
}) {
return (
<table className="players">
<tbody>
{abilities.map((row, i) => (
<tr key={i}>
<td>{ROW_LABELS[i]}</td>
{row.map((id, j) => (
<td key={j}>
<Ability ability={id} size={j === 0 ? "SUBTINY" : "TINY"} />
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
/**
* Click-to-toggle popover showing a player's ability grid; the trigger is
* the head-main ability icon.
*/
export function AbilityPopover({
abilities,
}: {
abilities: AbilityWithUnknown[][];
}) {
const trigger = abilities[0]?.[0];
if (!trigger) return null;
return (
<SendouPopover
trigger={
<Button
className="ability-trigger"
aria-label="Show abilities (from death events)"
>
<Ability ability={trigger} size="TINY" />
</Button>
}
>
<AbilityGrid abilities={abilities} />
</SendouPopover>
);
}

View File

@@ -0,0 +1,56 @@
import { Link } from "react-router";
import { useUser } from "~/features/auth/core/user";
import { useSearchParam } from "~/modules/search-params/hooks";
import { SCANNER_PAGE } from "~/utils/urls";
import {
SCANNER_TABS,
type ScannerTab,
scannerSearchParams,
} from "../scanner-search-params";
import { LivePage } from "./LivePage";
import { ScreenshotPage } from "./ScreenshotPage";
import type { SendouUser } from "./sendou-ingest";
import { VodPage } from "./VodPage";
import "./styles.css";
const TAB_LABELS: Record<ScannerTab, string> = {
live: "Live",
screenshot: "Screenshot",
vod: "VoD",
};
export function App() {
const [tab] = useSearchParam(scannerSearchParams, "tab");
const rootUser = useUser();
const sendouUser: SendouUser | null = rootUser
? { id: rootUser.id, username: rootUser.username }
: null;
const page =
tab === "screenshot" ? (
<ScreenshotPage />
) : tab === "vod" ? (
<VodPage sendouUser={sendouUser} />
) : (
<LivePage sendouUser={sendouUser} />
);
return (
<div className="scanner-app">
<header className="topbar">
<nav>
{SCANNER_TABS.map((tabOption) => (
<Link
key={tabOption}
to={scannerSearchParams.href(SCANNER_PAGE, { tab: tabOption })}
className={tab === tabOption ? "active" : ""}
>
{TAB_LABELS[tabOption]}
</Link>
))}
</nav>
</header>
{page}
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { Ability } from "~/components/Ability";
import { WeaponImage } from "~/components/Image";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { weaponLabel } from "./labels";
import { MetaPills } from "./MetaChips";
export function DeathCard(props: {
t: number;
confidence: number;
data: DeathData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const weaponName = weaponLabel(data.weaponType, data.weaponId);
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<MetaPills
t={t}
confidence={confidence}
type={DEATH_EVENT_TYPE}
label="death"
/>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: "Death" }}
/>
</div>
<div className="teams death">
<div className="team">
<div className="death-body">
{data.weaponId !== null && data.weaponType === "MAIN" ? (
<WeaponImage
weaponSplId={data.weaponId as MainWeaponId}
variant="build"
size={28}
className="weapon-icon"
/>
) : null}
<div className="death-info">
<span className="death-name">
splatted by <b>{data.name ?? "?"}</b>
</span>
<span className="death-weapon">{weaponName ?? "?"}</span>
</div>
<div className="death-abilities">
{data.abilities.map((row, i) => (
<div key={i} className="gear">
{row.map((id, j) => (
<Ability
key={j}
ability={id}
size={j === 0 ? "SUBTINY" : "TINY"}
/>
))}
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
/**
* Single dispatch point from a detected event to its card component, shared
* by the live feed and the VoD feed. Frames are loaded lazily through
* `getFrame` (IndexedDB keeps them out of the listed records); the Inspect
* action (open the frame in the screenshot page in a new browser tab, so
* the running scan is left undisturbed) is derived from it here so pages
* don't duplicate the wiring.
*/
import clsx from "clsx";
import { SCANNER_PAGE } from "~/utils/urls";
import type { PlayerAbilityMap } from "../core/ability-harvest";
import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
} from "../core/detectors/minimap/index";
import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
} from "../core/detectors/scoreboard-own/index";
import { scannerSearchParams } from "../scanner-search-params";
import type { SendStatus } from "../store/events";
import { newInspectKey, putInspectFrame } from "../store/inspect";
import { DeathCard } from "./DeathCard";
import type { FixtureData } from "./fixture-export";
import { useEventTimeFormatter } from "./format";
import { MapStartCard } from "./MapStartCard";
import { MinimapCard } from "./MinimapCard";
import { ObjectiveCard } from "./ObjectiveCard";
import { ScoreboardCard } from "./ScoreboardCard";
import { ScoreboardOwnCard } from "./ScoreboardOwnCard";
export type GetFrame = () => Promise<Blob | null | undefined>;
export function EventCard(props: {
type: string;
t: number;
confidence: number;
data: FixtureData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame; enables Inspect + fixture export */
getFrame?: GetFrame;
/** Scoreboard only: abilities harvested from the match's death events */
abilities?: PlayerAbilityMap;
/** sendou.ink /ingest status of this event; absent = never attempted */
send?: SendStatus;
/** when set, shows a Send/Retry button that sends this event's match batch */
onSend?: () => void;
}) {
const { type, t, confidence, data, thumbnail, detectedAt, getFrame } = props;
// window.open must run synchronously in the click gesture (popup blockers);
// the frame write catches up and the new tab polls for it
const onInspect = getFrame
? () => {
const key = newInspectKey();
window.open(
scannerSearchParams.href(SCANNER_PAGE, {
tab: "screenshot",
inspect: key,
}),
"_blank",
);
void getFrame().then((frame) => {
if (frame) void putInspectFrame(key, frame);
});
}
: undefined;
const shared = { t, confidence, thumbnail, detectedAt, getFrame, onInspect };
const card = renderCard(type, data, shared, props.abilities);
if (!props.send && !props.onSend) return card;
return (
<div className="send-wrap">
{card}
<SendStrip send={props.send} onSend={props.onSend} />
</div>
);
}
const SEND_LABELS: Record<SendStatus["state"], string> = {
queued: "queued",
sending: "sending…",
sent: "sent",
unlinked: "waiting for report",
failed: "failed",
};
function SendStrip({
send,
onSend,
}: {
send?: SendStatus;
onSend?: () => void;
}) {
const state = send?.state;
const formatSentAt = useEventTimeFormatter();
return (
<div className={clsx("send-strip", state ?? "unsent")}>
<span>
sendou.ink: {state ? SEND_LABELS[state] : "not sent"}
{state === "sent" && send ? ` ${formatSentAt(send.at)}` : null}
</span>
{send?.error ? <span className="error">{send.error}</span> : null}
{onSend && state !== "sent" && state !== "sending" ? (
<button type="button" onClick={onSend}>
{state === "failed" ? "Retry" : "Send"}
</button>
) : null}
</div>
);
}
function renderCard(
type: string,
data: FixtureData,
shared: {
t: number;
confidence: number;
thumbnail?: string;
detectedAt?: number;
getFrame?: GetFrame;
onInspect?: () => void;
},
abilities?: PlayerAbilityMap,
) {
return type === DEATH_EVENT_TYPE ? (
<DeathCard {...shared} data={data as DeathData} />
) : type === MAP_START_EVENT_TYPE ? (
<MapStartCard {...shared} data={data as MapStartData} />
) : type === SCOREBOARD_OWN_EVENT_TYPE ? (
<ScoreboardOwnCard {...shared} data={data as ScoreboardOwnData} />
) : type === MINIMAP_EVENT_TYPE ? (
<MinimapCard {...shared} data={data as MinimapData} />
) : type === OBJECTIVE_EVENT_TYPE ? (
<ObjectiveCard {...shared} data={data as ObjectiveData} />
) : (
<ScoreboardCard
{...shared}
eventType={type}
data={data as ScoreboardData}
abilities={abilities}
/>
);
}

View File

@@ -0,0 +1,47 @@
/**
* Shared lucide glyph per detected-event type, so the events summary line
* and the event cards mark each type with the same icon.
*/
import {
CircleHelp,
History,
type LucideIcon,
Map as MapIcon,
Play,
RotateCcw,
Skull,
Target,
Trophy,
User,
} from "lucide-react";
import { DEATH_EVENT_TYPE } from "../core/detectors/death/index";
import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start/index";
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
import { SCOREBOARD_EVENT_TYPE } from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log-replay/index";
import { SCOREBOARD_OWN_EVENT_TYPE } from "../core/detectors/scoreboard-own/index";
const EVENT_TYPE_ICONS: Record<string, LucideIcon> = {
[MAP_START_EVENT_TYPE]: Play,
[DEATH_EVENT_TYPE]: Skull,
[MINIMAP_EVENT_TYPE]: MapIcon,
[OBJECTIVE_EVENT_TYPE]: Target,
[SCOREBOARD_EVENT_TYPE]: Trophy,
[SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE]: RotateCcw,
[SCOREBOARD_BATTLE_LOG_EVENT_TYPE]: History,
[SCOREBOARD_OWN_EVENT_TYPE]: User,
};
export function EventTypeIcon({
type,
size = 12,
}: {
type: string;
size?: number;
}) {
const Icon = EVENT_TYPE_ICONS[type] ?? CircleHelp;
return <Icon size={size} aria-hidden className="event-type-icon" />;
}

View File

@@ -0,0 +1,64 @@
/**
* One light line summarizing raw detections as per-type counts, with a
* toggle for their event card feed. Callers pass only events not covered
* by a match card — a fallback for detections not yet grouped, hidden
* entirely once everything lives in a match.
*/
import * as R from "remeda";
import { DEATH_EVENT_TYPE } from "../core/detectors/death/index";
import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start/index";
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
import { SCOREBOARD_EVENT_TYPE } from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log-replay/index";
import { SCOREBOARD_OWN_EVENT_TYPE } from "../core/detectors/scoreboard-own/index";
import { EventTypeIcon } from "./EventTypeIcon";
const EVENT_TYPE_LABELS: Record<string, string> = {
[MAP_START_EVENT_TYPE]: "map start",
[DEATH_EVENT_TYPE]: "death",
[MINIMAP_EVENT_TYPE]: "minimap",
[OBJECTIVE_EVENT_TYPE]: "objective",
[SCOREBOARD_EVENT_TYPE]: "scoreboard",
[SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE]: "replay scoreboard",
[SCOREBOARD_BATTLE_LOG_EVENT_TYPE]: "battle log",
[SCOREBOARD_OWN_EVENT_TYPE]: "own result",
};
export function EventsSummary({
events,
open,
onToggle,
}: {
events: ReadonlyArray<{ type: string }>;
open: boolean;
onToggle: () => void;
}) {
const counts = R.countBy(events, (event) => event.type);
const sorted = Object.entries(counts).toSorted((a, b) => b[1] - a[1]);
return (
<div className="events-summary">
{sorted.map(([type, count]) => {
const label = EVENT_TYPE_LABELS[type] ?? type;
return (
<span
key={type}
className="events-summary-type"
title={`${count} ${label}${count === 1 ? "" : "s"}`}
>
<span className="events-summary-icon">
<EventTypeIcon type={type} />
</span>
×{count}
</span>
);
})}
<button type="button" className="events-toggle" onClick={onToggle}>
{open ? "Hide events" : "Show events"}
</button>
</div>
);
}

View File

@@ -0,0 +1,108 @@
/**
* Analyzed-frame preview in an event card's meta row. Clicking it opens the
* frame big in a dialog — the exact lossless frame once its lazy loader
* resolves, the thumbnail as a stand-in until then. The frame actions
* (Inspect, Save fixture) live under the dialog image.
*/
import { ExternalLink, FlaskConical } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { type FixtureData, saveFixtureFromEvent } from "./fixture-export";
export function FrameThumb({
thumbnail,
getFrame,
onInspect,
fixture,
}: {
thumbnail?: string;
getFrame?: () => Promise<Blob | null | undefined>;
/** opens the frame in the screenshot page in a new browser tab */
onInspect?: () => void;
/** enables Save fixture: the event's payload and fixture type label */
fixture?: { data: FixtureData; type: string };
}) {
const [open, setOpen] = useState(false);
const [frameUrl, setFrameUrl] = useState<string | null>(null);
const frameUrlRef = useRef<string | null>(null);
useEffect(
() => () => {
if (frameUrlRef.current) URL.revokeObjectURL(frameUrlRef.current);
},
[],
);
if (!thumbnail) return null;
const show = () => {
setOpen(true);
if (!getFrame || frameUrlRef.current) return;
void getFrame().then((frame) => {
if (!frame || frameUrlRef.current) return;
frameUrlRef.current = URL.createObjectURL(frame);
setFrameUrl(frameUrlRef.current);
});
};
const onSaveFixture =
getFrame && fixture
? () =>
void getFrame().then(
(frame) =>
frame && saveFixtureFromEvent(frame, fixture.data, fixture.type),
)
: undefined;
return (
<>
<button
type="button"
className="thumb-button"
title="View frame"
onClick={show}
>
<img className="thumb" src={thumbnail} alt="analyzed frame" />
</button>
{open ? (
<SendouDialog
isDismissable
aria-label="Analyzed frame"
className="scanner-frame-dialog"
onClose={() => setOpen(false)}
>
<img
className="frame-full"
src={frameUrl ?? thumbnail}
alt="analyzed frame"
/>
{onInspect || onSaveFixture ? (
<div className="frame-actions">
{onInspect ? (
<SendouButton
size="small"
icon={<ExternalLink />}
onPress={onInspect}
>
Inspect
</SendouButton>
) : null}
{onSaveFixture ? (
<SendouButton
size="small"
variant="outlined"
icon={<FlaskConical />}
onPress={onSaveFixture}
>
Save fixture
</SendouButton>
) : null}
</div>
) : null}
</SendouDialog>
) : null}
</>
);
}

View File

@@ -0,0 +1,558 @@
import clsx from "clsx";
import { Camera, Ellipsis, FileText, Send, Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { SendouButton } from "~/components/elements/Button";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { ObjectiveTimeline } from "~/components/ObjectiveTimeline";
import {
listVideoInputs,
openVirtualCamera,
startSampler,
} from "../capture/sampler";
import { connectAbilities } from "../core/ability-harvest";
import { DEATH_EVENT_TYPE } from "../core/detectors/death/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import { SCOREBOARD_EVENT_TYPES } from "../core/detectors/registry";
import type { DetectedEvent, GateResult } from "../core/detectors/types";
import type { BuiltMatch } from "../core/match-builder";
import {
buildScannerMatches,
ingestSkipReasons,
invalidObjectiveEvents,
} from "../core/match-builder";
import { TimelineBuilder } from "../core/timeline/index";
import {
clearEvents,
deleteEvents,
listEvents,
loadEventFrame,
type StoredEvent,
saveEvent,
updateEventsSend,
} from "../store/events";
import { AnalyzerClient } from "../worker/client";
import { withoutRepeatEvents } from "./dedupe-events";
import { EventCard } from "./EventCard";
import { EventsSummary } from "./EventsSummary";
import { downloadEventsCsv } from "./events-csv";
import { type FixtureData, saveFixture } from "./fixture-export";
import { MatchCard } from "./MatchCard";
import { MatchLobbyTabs } from "./MatchLobbyTabs";
import {
aggregateSendStatus,
matchContaining,
retryableUnlinkedMatches,
type SendouUser,
sendMatches,
unsentMatches,
} from "./sendou-ingest";
import { thumbnailFromBlob } from "./thumbnail";
const SAMPLE_FPS = 2;
/**
* How often a running capture rechecks whether a match sendou.ink could not
* link yet is due for another attempt (the backoff itself lives in
* sendou-ingest.ts).
*/
const UNLINKED_RETRY_TICK_MS = 15_000;
/** The scan knows the on-screen sides only, not who is playing. */
const SCANNER_TEAM_LABELS = ["Alpha", "Bravo"] as const;
/** Event types the ingested matches are built from — the only ones with a send status. */
const INGESTABLE_TYPES = [
MAP_START_EVENT_TYPE,
DEATH_EVENT_TYPE,
MINIMAP_EVENT_TYPE,
...SCOREBOARD_EVENT_TYPES,
];
type Status = "idle" | "loading" | "watching" | "detected" | "error";
export function LivePage({
sendouUser,
}: {
/** sendou.ink login shown in the header; undefined = probing, null = not logged in */
sendouUser: SendouUser | null | undefined;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const clientRef = useRef<AnalyzerClient | null>(null);
const timelineRef = useRef(new TimelineBuilder());
const storedIdsRef = useRef(new WeakMap<DetectedEvent, number>());
const latestParseRef = useRef<{ type: string; data: FixtureData } | null>(
null,
);
const gatesRef = useRef(new Map<string, GateResult>());
const stopRef = useRef<(() => void) | null>(null);
const retryTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// the open match is known to be a non-SZ mode, so counter reads are
// misreads of another mode's overlay and are not collected at all
const objectiveBlockedRef = useRef(false);
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [deviceId, setDeviceId] = useState<string>("");
const [status, setStatus] = useState<Status>("idle");
const [gateScore, setGateScore] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [feed, setFeed] = useState<StoredEvent[]>([]);
const [running, setRunning] = useState(false);
const [sendouError, setSendouError] = useState<string | null>(null);
const [eventsOpen, setEventsOpen] = useState(false);
const [liveSend, setLiveSend] = useState(false);
const liveSendRef = useRef(false);
const sendingRef = useRef(false);
const refreshFeed = useCallback(() => {
void (async () => {
const events = await listEvents();
// objective reads grouped into a known non-SZ match slipped past the
// live block (e.g. the mode read arrived after them) — delete them
const invalid = new Set(
invalidObjectiveEvents(buildScannerMatches(events)),
);
if (invalid.size > 0) {
await deleteEvents(
[...invalid]
.map((event) => event.id)
.filter((id): id is number => id !== undefined),
);
}
setFeed(
events
.filter((event) => !invalid.has(event))
.sort(
(a, b) => b.detectedAt - a.detectedAt || (b.id ?? 0) - (a.id ?? 0),
),
);
})();
}, []);
useEffect(() => {
refreshFeed();
return () => {
stopRef.current?.();
if (retryTimerRef.current) clearInterval(retryTimerRef.current);
clientRef.current?.dispose();
};
}, [refreshFeed]);
/** Sends the matches `include` selects; serialized so sends never overlap. */
const send = async (
include: (built: BuiltMatch<StoredEvent>) => boolean,
{ manual = false } = {},
) => {
if (sendingRef.current) return;
sendingRef.current = true;
if (manual) setSendouError(null);
try {
const events = await listEvents();
const { sentMatches, failedMatches } = await sendMatches({
events,
include,
onStatus: refreshFeed,
});
if (manual && sentMatches + failedMatches === 0) {
setSendouError("nothing to send — no complete match selected");
}
} finally {
sendingRef.current = false;
refreshFeed();
}
};
/** `withLiveSend`: send each match to sendou.ink as it closes. */
const start = async (withLiveSend: boolean) => {
setError(null);
setSendouError(null);
setStatus("loading");
liveSendRef.current = withLiveSend;
setLiveSend(withLiveSend);
// a restart may land mid-another-match; collect until its mode is known
objectiveBlockedRef.current = false;
try {
const video = videoRef.current!;
const stream = await openVirtualCamera(deviceId || undefined);
video.srcObject = stream;
await video.play();
setDevices(await listVideoInputs());
clientRef.current ??= new AnalyzerClient(
(result) => {
// one result arrives per detector per frame; status reflects
// whether any of them fired
gatesRef.current.set(result.detector, result.gate);
const gates = [...gatesRef.current.values()];
setGateScore(Math.max(...gates.map((g) => g.score)));
if (!result.gate.pass) {
if (!gates.some((g) => g.pass)) setStatus("watching");
return;
}
setStatus("detected");
for (const event of result.events as DetectedEvent<FixtureData>[]) {
latestParseRef.current = { type: event.type, data: event.data };
if (
event.type === OBJECTIVE_EVENT_TYPE &&
objectiveBlockedRef.current
) {
continue;
}
const action = timelineRef.current.push(event);
if (action.action === "added" || action.action === "replaced") {
if (event.type === MAP_START_EVENT_TYPE) {
const mode = (event.data as MapStartData).mode;
objectiveBlockedRef.current = mode !== null && mode !== "SZ";
} else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) {
objectiveBlockedRef.current = false;
}
const stale =
action.action === "replaced"
? storedIdsRef.current.get(action.replaced)
: undefined;
void (async () => {
const thumbnail = result.frame
? await thumbnailFromBlob(result.frame)
: undefined;
// reusing the replaced event's id keeps match card keys
// stable, so repeat detections don't remount the cards
const id = await saveEvent(
event,
thumbnail,
result.frame,
stale,
);
storedIdsRef.current.set(event, id);
if (
liveSendRef.current &&
INGESTABLE_TYPES.includes(event.type)
) {
if (SCOREBOARD_EVENT_TYPES.includes(event.type)) {
// a scoreboard closes its match — send it
refreshFeed();
await send(
(built) =>
matchContaining(id)(built) && unsentMatches(built),
);
} else {
await updateEventsSend([id], {
state: "queued",
at: Date.now(),
});
}
}
refreshFeed();
})();
}
}
},
(message) => {
setError(message);
setStatus("error");
},
);
await clientRef.current.whenReady();
stopRef.current = startSampler(video, SAMPLE_FPS, (bitmap, t) => {
clientRef.current?.analyze(bitmap, t);
});
// a match sent the moment its scoreboard closed usually beats the
// players to reporting the game, so sendou.ink had nothing to link
// it to; give those another go while the capture runs
retryTimerRef.current ??= setInterval(() => {
if (liveSendRef.current) void send(retryableUnlinkedMatches);
}, UNLINKED_RETRY_TICK_MS);
setStatus("watching");
setRunning(true);
} catch (e) {
setError(String(e));
setStatus("error");
}
};
const builtMatches = buildScannerMatches(feed);
const skipReasons = ingestSkipReasons(builtMatches);
const groupedEvents = new Set(builtMatches.flatMap((b) => b.sources));
const ungroupedFeed = feed.filter((e) => !groupedEvents.has(e));
const abilityMap = connectAbilities(feed);
const stop = () => {
stopRef.current?.();
stopRef.current = null;
if (retryTimerRef.current) clearInterval(retryTimerRef.current);
retryTimerRef.current = null;
const video = videoRef.current;
if (video?.srcObject instanceof MediaStream) {
for (const track of video.srcObject.getTracks()) track.stop();
video.srcObject = null;
}
setRunning(false);
setStatus("idle");
// the scan ending is the last match boundary — flush what's unsent
// (partials are safe: the server merges them into fuller resends)
if (liveSendRef.current) void send(unsentMatches);
liveSendRef.current = false;
setLiveSend(false);
};
return (
<div>
<div className="controls">
{!running ? (
<>
<button
type="button"
disabled={!sendouUser}
title={sendouUser ? undefined : "Log in on sendou.ink first"}
onClick={() => void start(true)}
>
<Send aria-hidden />
Start capture
</button>
<button
type="button"
className="outlined"
onClick={() => void start(false)}
>
Start capture (no sending)
</button>
</>
) : (
<>
<button type="button" onClick={stop}>
Stop
</button>
<button
type="button"
className="outlined"
disabled={!sendouUser}
title={sendouUser ? undefined : "Log in on sendou.ink first"}
onClick={() => {
liveSendRef.current = !liveSend;
setLiveSend(!liveSend);
}}
>
<Send aria-hidden />
{liveSend ? "Stop sending" : "Start sending"}
</button>
</>
)}
<select value={deviceId} onChange={(e) => setDeviceId(e.target.value)}>
<option value="">Default camera (OBS Virtual Camera)</option>
{devices.map((d) => (
<option key={d.deviceId} value={d.deviceId}>
{d.label || d.deviceId.slice(0, 8)}
</option>
))}
</select>
<span
className={clsx("status", {
detected: status === "detected",
watching: status === "watching",
idle: status !== "detected" && status !== "watching",
})}
>
{status}
{gateScore !== null ? ` · gate ${gateScore.toFixed(2)}` : null}
</span>
{liveSend ? (
<span className="status watching">sending matches live</span>
) : null}
<LiveMenu
canSaveFixture={running}
canSend={Boolean(sendouUser) && feed.length > 0}
hasEvents={feed.length > 0}
onSaveFixture={() =>
void saveFixture(videoRef.current!, latestParseRef.current)
}
onDownloadCsv={() =>
downloadEventsCsv(
`live-events-${new Date().toISOString().slice(0, 19).replaceAll(":", "-")}.csv`,
// feed is newest-first for display; export in chronological order
feed.toSorted(
(a, b) =>
a.detectedAt - b.detectedAt || (a.id ?? 0) - (b.id ?? 0),
),
)
}
onSendUnsent={() => void send(unsentMatches, { manual: true })}
onClearFeed={() => {
if (!window.confirm("Clear all detected events?")) return;
void clearEvents().then(refreshFeed);
}}
/>
</div>
{error ? <p className="error">{error}</p> : null}
{sendouError ? <p className="error">{sendouError}</p> : null}
<div className="live-layout">
<video ref={videoRef} className="preview" muted playsInline />
<div className="feed">
{feed.length === 0 ? (
<p className="score">No detections yet.</p>
) : null}
<MatchLobbyTabs
matches={builtMatches}
keyOf={(built) => built.sources[0]!.id!}
renderMatch={(built, justFormed) => {
const id = built.sources[0]!.id!;
const skipReason = skipReasons.get(built);
// counter reads render as one timeline chart, not a card each;
// a non-SZ match's reads (objective null) are never shown
const objectiveEvents = built.match.objective
? built.sources
.filter((e) => e.type === OBJECTIVE_EVENT_TYPE)
.map((e) => ({ t: e.t, data: e.data as ObjectiveData }))
: [];
const cardEvents = withoutRepeatEvents(built.sources).filter(
(e) => e.type !== OBJECTIVE_EVENT_TYPE,
);
const newest = built === builtMatches.at(-1);
return (
<MatchCard
match={built.match}
live={running && newest && built.match.winner === null}
inProgress={newest && built.match.winner === null}
skipReason={skipReason}
justFormed={justFormed}
send={aggregateSendStatus(built.sources)}
onSend={
sendouUser && !skipReason
? () => void send(matchContaining(id), { manual: true })
: undefined
}
>
{objectiveEvents.length > 0 ? (
<ObjectiveTimeline
events={objectiveEvents}
teamLabels={SCANNER_TEAM_LABELS}
/>
) : null}
{cardEvents.map((e) => (
<EventCard
key={e.id}
type={e.type}
t={e.t}
confidence={e.confidence}
data={e.data as FixtureData}
abilities={abilityMap.get(e)}
thumbnail={e.thumbnail}
detectedAt={e.detectedAt}
getFrame={
e.hasFrame && e.id !== undefined
? () => loadEventFrame(e.id!)
: undefined
}
/>
))}
</MatchCard>
);
}}
/>
{ungroupedFeed.length > 0 ? (
<EventsSummary
events={ungroupedFeed}
open={eventsOpen}
onToggle={() => setEventsOpen(!eventsOpen)}
/>
) : null}
{eventsOpen
? ungroupedFeed.map((e) => (
<EventCard
key={e.id}
type={e.type}
t={e.t}
confidence={e.confidence}
data={e.data as FixtureData}
abilities={abilityMap.get(e)}
thumbnail={e.thumbnail}
detectedAt={e.detectedAt}
getFrame={
e.hasFrame && e.id !== undefined
? () => loadEventFrame(e.id!)
: undefined
}
send={e.send}
onSend={
sendouUser &&
e.id !== undefined &&
INGESTABLE_TYPES.includes(e.type)
? () =>
void send(matchContaining(e.id!), { manual: true })
: undefined
}
/>
))
: null}
</div>
</div>
</div>
);
}
/** The capture's occasional actions, behind one icon-only menu. */
function LiveMenu({
canSaveFixture,
canSend,
hasEvents,
onSaveFixture,
onDownloadCsv,
onSendUnsent,
onClearFeed,
}: {
canSaveFixture: boolean;
canSend: boolean;
hasEvents: boolean;
onSaveFixture: () => void;
onDownloadCsv: () => void;
onSendUnsent: () => void;
onClearFeed: () => void;
}) {
return (
<SendouMenu
trigger={
<SendouButton
icon={<Ellipsis />}
className="icon-menu"
aria-label="More actions"
/>
}
>
<SendouMenuItem
icon={<Camera />}
isDisabled={!canSaveFixture}
onAction={onSaveFixture}
>
Save frame as fixture
</SendouMenuItem>
<SendouMenuItem
icon={<FileText />}
isDisabled={!hasEvents}
onAction={onDownloadCsv}
>
CSV
</SendouMenuItem>
<SendouMenuItem
icon={<Send />}
isDisabled={!canSend}
onAction={onSendUnsent}
>
Send unsent to sendou.ink
</SendouMenuItem>
<SendouMenuItem
icon={<Trash2 />}
isDestructive
isDisabled={!hasEvents}
onAction={onClearFeed}
>
Clear feed
</SendouMenuItem>
</SendouMenu>
);
}

View File

@@ -0,0 +1,45 @@
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { modeLabel, stageLabel } from "./labels";
import { MetaPills } from "./MetaChips";
export function MapStartCard(props: {
t: number;
confidence: number;
data: MapStartData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<MetaPills
t={t}
confidence={confidence}
type={MAP_START_EVENT_TYPE}
label="map start"
/>
<span>
<b>{modeLabel(data.mode) ?? "?"}</b> · {stageLabel(data.stage) ?? "?"}
</span>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: "MapStart" }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,344 @@
/**
* Glanceable card for one ScannerMatch in the live feed: stage banner
* background, mode + stage, score, team weapons, and the match's /ingest
* status. Expanding the card reveals the source event cards below it,
* so the raw per-event view stays one click away.
*/
import clsx from "clsx";
import { ChevronDown } from "lucide-react";
import type * as React from "react";
import { useState } from "react";
import { SendouButton } from "~/components/elements/Button";
import { ModeImage, WeaponImage } from "~/components/Image";
import { matchScoresFromObjective } from "~/components/objective-timeline-utils";
import { StageBannerBox } from "~/components/StageBannerBox";
import type { IngestedMatchLink } from "~/features/scanner-ingest/scanner-ingest-schemas";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { sendouQMatchPage, tournamentMatchPage } from "~/utils/urls";
import type { IngestSkipReason } from "../core/match-builder";
import type { ScannerMatch } from "../core/scanner-match";
import type { SendStatus } from "../store/events";
import { formatTime, useEventTimeFormatter } from "./format";
import { lobbyLabel, modeLabel, stageLabel } from "./labels";
/** the game score a knockout wins at */
const KO_MATCH_SCORE = 100;
const SEND_CHIP_LABELS: Record<Exclude<SendStatus["state"], "sent">, string> = {
queued: "queued",
sending: "sending…",
unlinked: "waiting for report",
failed: "failed",
};
export function MatchCard({
match,
send,
onSend,
live = false,
inProgress = false,
skipReason,
justFormed = false,
children,
}: {
match: ScannerMatch;
/** the match's /ingest status, aggregated from its source events */
send?: SendStatus;
/** when set, shows a Send/Retry button for this match */
onSend?: () => void;
/** still being played: no closing scoreboard yet and the scan is running */
live?: boolean;
/**
* the newest match, still being formulated (no result yet) — shows an
* "in progress" chip in the score slot; at most one card should get this
*/
inProgress?: boolean;
/** set = ingestSkipReasons held the match back from /ingest */
skipReason?: IngestSkipReason;
/** the scan just formed this match — play the enter animation */
justFormed?: boolean;
/** expandable detail content, typically the source event cards */
children?: React.ReactNode;
}) {
const [expanded, setExpanded] = useState(false);
// fixed at mount: re-rendering must not cut the animation short, and a card
// remounting for another reason (switching lobby tabs) must not replay it
const [enter] = useState(justFormed);
// one-shot flash animations only on a state *change*, so already-sent
// matches don't replay the glow on every mount
const [prevSendState, setPrevSendState] = useState(send?.state);
const [flash, setFlash] = useState<"sent" | "failed" | null>(null);
if (prevSendState !== send?.state) {
setPrevSendState(send?.state);
setFlash(
send?.state === "sent" || send?.state === "failed" ? send.state : null,
);
}
const meta = [
modeLabel(match.mode),
skipReason === "lobby" ? lobbyLabel(match.lobby) : null,
match.startsAt !== null ? timeRangeLabel(match) : null,
match.replayCode,
match.cast ? "cast" : null,
]
.filter(Boolean)
.join(" · ");
const inner = (
<>
<div className="match-card-main">
{match.mode !== null ? (
<ModeImage mode={match.mode} size={30} className="match-mode" />
) : null}
<div className="match-headline">
<div className="match-title">
<div className="match-stage">
{stageLabel(match.stage) ?? "Unknown stage"}
</div>
<StatusChip send={send} skipReason={skipReason} live={live} />
</div>
{meta ? <div className="match-meta">{meta}</div> : null}
<TeamWeapons match={match} />
</div>
<div className="match-side">
{live ? (
<span className="match-chip live">
<span className="dot" />
live
</span>
) : (
<Score match={match} inProgress={inProgress} />
)}
{onSend && send?.state !== "sent" && send?.state !== "sending" ? (
<button type="button" onClick={onSend}>
{send?.state === "failed" || send?.state === "unlinked"
? "Retry"
: "Send"}
</button>
) : null}
{children ? (
<SendouButton
variant="minimal"
size="small"
shape="circle"
icon={<ChevronDown />}
className={clsx("match-expand", { expanded })}
aria-expanded={expanded}
aria-label={expanded ? "Hide events" : "Show events"}
onPress={() => setExpanded(!expanded)}
/>
) : null}
</div>
</div>
{send?.state === "failed" && send.error ? (
<div className="match-error">{send.error}</div>
) : null}
</>
);
const className = clsx("match-card", send?.state, {
enter,
live,
"flash-sent": flash === "sent",
"flash-failed": flash === "failed",
});
const card =
match.stage !== null ? (
<StageBannerBox stageId={match.stage} className={className}>
{inner}
</StageBannerBox>
) : (
<div className={className}>{inner}</div>
);
if (!children) return card;
return (
<div className="match-card-group">
{card}
{expanded ? <div className="match-events">{children}</div> : null}
</div>
);
}
/** Labeled rule above the newest card of each set in the feed. */
export function SetDivider({ number }: { number: number }) {
return <div className="set-divider">Set {number}</div>;
}
function timeRangeLabel(match: ScannerMatch): string {
const start = formatTime(match.startsAt!);
return match.endsAt !== null && match.endsAt !== match.startsAt
? `${start}–${formatTime(match.endsAt)}`
: start;
}
function Score({
match,
inProgress,
}: {
match: ScannerMatch;
inProgress: boolean;
}) {
if (match.matchScores === null) {
if (!inProgress) return null;
return (
<span className="match-chip in-progress">
<span className="dot" />
in progress
</span>
);
}
const [alpha, bravo] = match.matchScores;
const objectiveScores = matchScoresFromObjective(
match.objective?.samples ?? [],
);
// scoreboard-sourced matches list the winners first
const winnerKnown = match.winner !== null;
return (
<div className="match-score">
<span className={winnerKnown ? "win" : undefined}>
{scoreLabel(alpha, objectiveScores[0])}
</span>
<span className="sep"> – </span>
<span className={winnerKnown ? "lose" : undefined}>
{scoreLabel(bravo, objectiveScores[1])}
</span>
</div>
);
}
/**
* A 100 only happens on a knockout, shown the way players say it. A knockout's
* loser gets no score of its own, so the objective counter's last read stands
* in — parenthesized, since the scan read it off the video and may have lost
* sight of the counter before the game ended.
*/
function scoreLabel(
score: number | null,
objectiveScore: number | null,
): string {
if (score !== null && score > 0) {
return score === KO_MATCH_SCORE ? "KO" : String(score);
}
if (objectiveScore !== null) {
return objectiveScore === KO_MATCH_SCORE ? "(KO)" : `(${objectiveScore})`;
}
return score === null ? "?" : String(score);
}
interface TeamWeapon {
weaponId: MainWeaponId;
/** the scan's own player — highlighted among the eight */
pov: boolean;
}
function TeamWeapons({ match }: { match: ScannerMatch }) {
const weaponsOf = (team: 0 | 1): TeamWeapon[] =>
match.teams[team].players
.map((player, index) => ({
weaponId: player.weaponId,
pov: match.pov?.team === team && match.pov.index === index,
}))
.filter((weapon): weapon is TeamWeapon => weapon.weaponId !== null);
const alpha = weaponsOf(0);
const bravo = weaponsOf(1);
if (alpha.length + bravo.length === 0) return null;
return (
<div className="match-weapons">
{alpha.length > 0 ? <WeaponRow weapons={alpha} /> : null}
{alpha.length > 0 && bravo.length > 0 ? (
<span className="vs">vs</span>
) : null}
{bravo.length > 0 ? <WeaponRow weapons={bravo} /> : null}
</div>
);
}
/** One team's weapons, kept together when the card is too narrow for both. */
function WeaponRow({ weapons }: { weapons: TeamWeapon[] }) {
return (
<div className="weapon-row">
{weapons.map((weapon, i) => (
<WeaponImage
key={i}
weaponSplId={weapon.weaponId}
variant="build"
size={22}
className={clsx("weapon-icon", { pov: weapon.pov })}
/>
))}
</div>
);
}
function StatusChip({
send,
skipReason,
live,
}: {
send?: SendStatus;
skipReason?: IngestSkipReason;
live: boolean;
}) {
const formatSentAt = useEventTimeFormatter();
if (skipReason) {
return (
<span className="match-chip">
{skipReason === "disconnect" ? "disconnect" : "not ingested"}
</span>
);
}
if (send?.state === "sent") {
return (
<span
className="match-chip sent"
title={`ingested ${formatSentAt(send.at)}`}
>
✓
{send.link ? (
<a
href={ingestedMatchUrl(send.link)}
target="_blank"
rel="noreferrer"
>
{ingestedMatchLabel(send.link)}
</a>
) : null}
</span>
);
}
if (send) {
return (
<span className={clsx("match-chip", send.state)} title={send.error}>
{send.state === "queued" || send.state === "sending" ? (
<span className="dot" />
) : null}
{SEND_CHIP_LABELS[send.state]}
</span>
);
}
if (live) return null;
return <span className="match-chip">not sent</span>;
}
function ingestedMatchUrl(link: IngestedMatchLink): string {
return link.type === "tournament"
? tournamentMatchPage({
tournamentId: link.tournamentId,
matchId: link.matchId,
})
: sendouQMatchPage(link.groupMatchId);
}
function ingestedMatchLabel(link: IngestedMatchLink): string {
return link.type === "tournament"
? `Match ID #${link.matchId}`
: `SQ Match ID #${link.groupMatchId}`;
}

View File

@@ -0,0 +1,137 @@
/**
* Lobby tabs over the match feed: private battles (the games /ingest cares
* about) split from X Battles and everything else, so a scan that picked up
* ranked play between tournament sets stays readable. Sets are a private
* battle concept — consecutive games between the same two teams — so only
* that tab gets set dividers, and set numbers are assigned within the tab.
*/
import { Fragment, useEffect, useRef } from "react";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import type { DetectedEvent } from "../core/detectors/types";
import type { BuiltMatch } from "../core/match-builder";
import { assignMatchSets } from "../core/match-sets";
import type { ScannerLobby } from "../scanner-types";
import { SetDivider } from "./MatchCard";
type LobbyGroup = "private" | "x" | "other";
const LOBBY_GROUPS: LobbyGroup[] = ["private", "x", "other"];
const LOBBY_GROUP_LABELS: Record<LobbyGroup, string> = {
private: "Private Battle",
x: "X Battle",
other: "Other",
};
const NO_KEYS: ReadonlySet<React.Key> = new Set();
export function MatchLobbyTabs<E extends DetectedEvent>({
matches,
keyOf,
renderMatch,
}: {
/** built matches in chronological order (oldest first) */
matches: readonly BuiltMatch<E>[];
/** stable render key for one match, typically its first source event's id */
keyOf: (built: BuiltMatch<E>) => React.Key;
/** `justFormed`: the match appeared while the page was open (enter animation) */
renderMatch: (built: BuiltMatch<E>, justFormed: boolean) => React.ReactNode;
}) {
const justFormedKeys = useJustFormedKeys(matches.map(keyOf));
const groups = LOBBY_GROUPS.map((group) => ({
group,
matches: matches.filter((built) => lobbyGroup(built.match.lobby) === group),
})).filter(({ matches: groupMatches }) => groupMatches.length > 0);
if (groups.length === 0) return null;
return (
<SendouTabs>
<SendouTabList>
{groups.map(({ group, matches: groupMatches }) => (
<SendouTab key={group} id={group} number={groupMatches.length}>
{LOBBY_GROUP_LABELS[group]}
</SendouTab>
))}
</SendouTabList>
{groups.map(({ group, matches: groupMatches }) => (
<SendouTabPanel key={group} id={group} className="match-list">
<MatchList
matches={groupMatches}
sets={group === "private"}
keyOf={keyOf}
justFormedKeys={justFormedKeys}
renderMatch={renderMatch}
/>
</SendouTabPanel>
))}
</SendouTabs>
);
}
function lobbyGroup(lobby: ScannerLobby | null): LobbyGroup {
if (lobby === "PRIVATE") return "private";
if (lobby === "X") return "x";
return "other";
}
/**
* Keys of the matches that showed up since the previous render — the ones a
* scan just formed. A list arriving whole (the live feed loaded from storage,
* a saved VoD opened) is not "just formed": every card would animate in at
* once for something the user did not watch happen.
*/
function useJustFormedKeys(keys: React.Key[]): ReadonlySet<React.Key> {
const seenRef = useRef<Set<React.Key> | null>(null);
const seen = seenRef.current;
// after commit, not during render: under StrictMode the render runs twice
// and the second pass would find every key already seen
useEffect(() => {
seenRef.current = new Set(keys);
});
if (seen === null) return NO_KEYS;
const justFormed = keys.filter((key) => !seen.has(key));
return justFormed.length === keys.length && keys.length > 1
? NO_KEYS
: new Set(justFormed);
}
function MatchList<E extends DetectedEvent>({
matches,
sets,
keyOf,
justFormedKeys,
renderMatch,
}: {
matches: readonly BuiltMatch<E>[];
sets: boolean;
keyOf: (built: BuiltMatch<E>) => React.Key;
justFormedKeys: ReadonlySet<React.Key>;
renderMatch: (built: BuiltMatch<E>, justFormed: boolean) => React.ReactNode;
}) {
const setNumbers = sets ? assignMatchSets(matches.map((b) => b.match)) : [];
const showSetDividers = (setNumbers.at(-1) ?? 1) > 1;
// newest match on top; the builder keeps ascending time order
return [...matches].reverse().map((built, reverseIndex) => {
const index = matches.length - 1 - reverseIndex;
const key = keyOf(built);
return (
<Fragment key={key}>
{showSetDividers && setNumbers[index + 1] !== setNumbers[index] ? (
<SetDivider number={setNumbers[index]!} />
) : null}
{renderMatch(built, justFormedKeys.has(key))}
</Fragment>
);
});
}

View File

@@ -0,0 +1,38 @@
/**
* The pill group every event card's meta row opens with: video timestamp,
* detection confidence and the event type, kept tight together so the free-form
* detail text that follows reads as a separate group.
*/
import { Clock, Gauge } from "lucide-react";
import { EventTypeIcon } from "./EventTypeIcon";
import { formatTime } from "./format";
export function MetaPills({
t,
confidence,
type,
label,
}: {
t: number;
confidence: number;
type: string;
label: string;
}) {
return (
<div className="meta-pills">
<span className="meta-chip" title="Video timestamp">
<Clock size={12} aria-hidden className="meta-chip-icon" />
{formatTime(t)}
</span>
<span className="meta-chip" title="Detection confidence">
<Gauge size={12} aria-hidden className="meta-chip-icon" />
{(confidence * 100).toFixed(0)}%
</span>
<span className="status detected">
<EventTypeIcon type={type} />
{label}
</span>
</div>
);
}

View File

@@ -0,0 +1,140 @@
import { ChevronUp } from "lucide-react";
import type { ReactNode } from "react";
import { Ability } from "~/components/Ability";
import { WeaponImage } from "~/components/Image";
import type { AbilityWithUnknown } from "~/modules/in-game-lists/types";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
type MinimapEnemy,
type MinimapTeammate,
} from "../core/detectors/minimap/index";
import type { CardSlot } from "../core/detectors/minimap/rois";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { stageLabel } from "./labels";
import { MetaPills } from "./MetaChips";
const ENEMY_SLOT_LETTERS = ["A", "B", "X", "Y"] as const;
function AbilityRow({
abilities,
}: {
abilities: (AbilityWithUnknown | null)[];
}) {
return (
<>
{abilities.map((id, i) =>
id ? (
<Ability key={i} ability={id} size="TINY" />
) : (
<span key={i} title="unreadable badge">
?
</span>
),
)}
</>
);
}
function TeammateMarker({ slot }: { slot: CardSlot }) {
if (slot === "self") {
return <span className="slot-marker">●</span>;
}
return (
<span className={`slot-marker ${slot}`}>
<ChevronUp strokeWidth={3.5} aria-label={slot} role="img" />
</span>
);
}
function PlayerRow({
marker,
player,
}: {
marker: ReactNode;
player: MinimapTeammate | MinimapEnemy;
}) {
return (
<div className="minimap-player">
{marker}
{player.weaponId !== null ? (
<WeaponImage
weaponSplId={player.weaponId}
variant="build"
size={24}
className="weapon-icon"
/>
) : (
<span className="weapon-missing">?</span>
)}
<span className="name">{player.name ?? ""}</span>
<span className="abilities">
<AbilityRow abilities={player.abilities} />
</span>
</div>
);
}
export function MinimapCard(props: {
t: number;
confidence: number;
data: MinimapData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<MetaPills
t={t}
confidence={confidence}
type={MINIMAP_EVENT_TYPE}
label="minimap"
/>
{data.stage !== null ? <span>{stageLabel(data.stage)}</span> : null}
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: "Minimap" }}
/>
</div>
<div className="teams">
<div className="team">
<h3>Team</h3>
{data.teammates.map((p) => (
<PlayerRow
key={p.slot}
marker={<TeammateMarker slot={p.slot} />}
player={p}
/>
))}
</div>
{data.enemies.length > 0 ? (
<div className="team">
<h3>Enemies</h3>
{data.enemies.map((p, i) => (
<PlayerRow
key={i}
marker={
<span className="slot-marker">
{ENEMY_SLOT_LETTERS[i] ?? i + 1}
</span>
}
player={p}
/>
))}
</div>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import { FrameThumb } from "./FrameThumb";
import { formatClock, useEventTimeFormatter } from "./format";
import { MetaPills } from "./MetaChips";
export function ObjectiveCard(props: {
t: number;
confidence: number;
data: ObjectiveData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const side = (index: 0 | 1) => {
const score = data.score[index] ?? "?";
const penalty =
data.penalty[index] !== null ? ` (+${data.penalty[index]})` : "";
return `${score}${penalty}`;
};
const holder = data.control.findIndex(Boolean);
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<MetaPills
t={t}
confidence={confidence}
type={OBJECTIVE_EVENT_TYPE}
label="objective"
/>
<span>
{data.time !== null ? `${formatClock(data.time)} · ` : null}
<b>
{side(0)} – {side(1)}
</b>
{holder >= 0
? ` · ${holder === 0 ? "alpha" : "bravo"} in control`
: null}
</span>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: OBJECTIVE_EVENT_TYPE }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
import { WeaponImage } from "~/components/Image";
import type { PlayerAbilityMap } from "../core/ability-harvest";
import type {
ScoreboardData,
ScoreboardPlayer,
} from "../core/detectors/scoreboard/index";
import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log/index";
import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-battle-log-replay/index";
import { AbilityPopover } from "./AbilityGrid";
import { FrameThumb } from "./FrameThumb";
import type { CardData } from "./fixture-export";
import { useEventTimeFormatter } from "./format";
import { lobbyLabel, modeLabel, stageLabel } from "./labels";
import { MetaPills } from "./MetaChips";
function PlayerRows({
players,
offset,
abilities,
}: {
players: ScoreboardPlayer[];
/** index of the first row within the full 8-player list */
offset: number;
abilities?: PlayerAbilityMap;
}) {
return (
<table className="players">
<tbody>
{players.map((p, i) => (
<tr key={i}>
<td>
<span className="weapon-cell">
{p.weaponId !== null ? (
<WeaponImage
weaponSplId={p.weaponId}
variant="build"
size={28}
className="weapon-icon"
/>
) : null}
{abilities?.has(offset + i) ? (
<AbilityPopover abilities={abilities.get(offset + i)!} />
) : null}
</span>
</td>
<td>{p.name || "?"}</td>
<td className="num">{p.paint ?? "?"}p</td>
<td className="num">
{p.ka ?? "?"}/{p.d ?? "?"}/{p.s ?? "?"}
</td>
</tr>
))}
</tbody>
</table>
);
}
function teamHeading(label: string, data: CardData, side: 0 | 1): string {
const score = data.matchScores[side];
return score !== null ? `${label} — ${score}` : label;
}
export function ScoreboardCard(props: {
t: number;
confidence: number;
data: ScoreboardData;
/** DetectedEvent type; replay cards add timestamp/code details */
eventType?: string;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
/** when set, shows an Inspect button (open this match in the screenshot page) */
onInspect?: () => void;
/** abilities harvested from this match's death events, by player index */
abilities?: PlayerAbilityMap;
}) {
const { t, confidence, thumbnail, detectedAt, getFrame, onInspect } = props;
const eventType = props.eventType ?? "Scoreboard";
const data = props.data as CardData;
const isReplay = eventType === SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE;
const isScoreboardBattleLog = eventType === SCOREBOARD_BATTLE_LOG_EVENT_TYPE;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<MetaPills
t={t}
confidence={confidence}
type={eventType}
label={
isReplay
? "replay scoreboard"
: isScoreboardBattleLog
? "battle log"
: "scoreboard"
}
/>
{data.mode !== null || data.stage !== null ? (
<span>
{[
lobbyLabel(data.lobby),
modeLabel(data.mode),
stageLabel(data.stage),
]
.filter(Boolean)
.join(" · ")}
</span>
) : null}
{data.timestamp ? <span>{data.timestamp}</span> : null}
{data.replayCode ? (
<span className="score">{data.replayCode}</span>
) : null}
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: eventType }}
/>
</div>
<div className="teams">
<div className="team win">
<h3>{teamHeading("Victory", data, 0)}</h3>
<PlayerRows
players={data.players.slice(0, 4)}
offset={0}
abilities={props.abilities}
/>
</div>
<div className="team lose">
<h3>{teamHeading("Defeat", data, 1)}</h3>
<PlayerRows
players={data.players.slice(4, 8)}
offset={4}
abilities={props.abilities}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { WeaponImage } from "~/components/Image";
import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
} from "../core/detectors/scoreboard-own/index";
import { AbilityGrid } from "./AbilityGrid";
import { FrameThumb } from "./FrameThumb";
import { useEventTimeFormatter } from "./format";
import { lobbyLabel, mainWeaponLabel, modeLabel, stageLabel } from "./labels";
import { MetaPills } from "./MetaChips";
export function ScoreboardOwnCard(props: {
t: number;
confidence: number;
data: ScoreboardOwnData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<MetaPills
t={t}
confidence={confidence}
type={SCOREBOARD_OWN_EVENT_TYPE}
label="own results"
/>
<span>
{[
lobbyLabel(data.lobby),
modeLabel(data.mode),
stageLabel(data.stage),
]
.map((v) => v ?? "?")
.join(" · ")}
</span>
<span>
weapon <b>{mainWeaponLabel(data.weaponId) ?? "?"}</b>
</span>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: "ScoreboardOwn" }}
/>
</div>
<div className="teams solo">
<div className="team">
{data.weaponId !== null ? (
<WeaponImage
weaponSplId={data.weaponId}
variant="build"
size={28}
className="weapon-icon"
/>
) : null}
<AbilityGrid abilities={data.abilities} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,729 @@
import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "react";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { useSearchParam } from "~/modules/search-params/hooks";
import { mainWeaponImageUrl } from "~/utils/urls";
import { CANONICAL_HEIGHT, CANONICAL_WIDTH, type Roi } from "../core/canonical";
import type { DeathData } from "../core/detectors/death/index";
import * as death from "../core/detectors/death/rois";
import type { MapStartData } from "../core/detectors/map-start/index";
import * as mapStart from "../core/detectors/map-start/rois";
import type { MinimapData } from "../core/detectors/minimap/index";
import * as minimap from "../core/detectors/minimap/rois";
import type { ScoreboardRowDebug } from "../core/detectors/scoreboard/index";
import * as sb from "../core/detectors/scoreboard/rois";
import * as bl from "../core/detectors/scoreboard-battle-log/rois";
import * as replay from "../core/detectors/scoreboard-battle-log-replay/rois";
import type { ScoreboardOwnData } from "../core/detectors/scoreboard-own/index";
import * as own from "../core/detectors/scoreboard-own/rois";
import type { DetectedEvent } from "../core/detectors/types";
import { scannerSearchParams } from "../scanner-search-params";
import { claimInspectFrame } from "../store/inspect";
import { AnalyzerClient } from "../worker/client";
import type { WorkerResponse } from "../worker/protocol";
import { downloadEventsCsv } from "./events-csv";
import { type CardData, downloadExpectedJson } from "./fixture-export";
import {
lobbyLabel,
mainWeaponLabel,
modeLabel,
stageLabel,
weaponLabel,
} from "./labels";
type Result = Extract<WorkerResponse, { kind: "result" }>;
/** Draw a ROI crop from the normalized frame, scaled up. */
function RoiCrop(props: {
frame: HTMLCanvasElement;
roi: Roi;
scale?: number;
}) {
const { frame, roi, scale = 1.5 } = props;
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current!;
canvas.width = Math.round(roi.w * scale);
canvas.height = Math.round(roi.h * scale);
const ctx = canvas.getContext("2d")!;
ctx.imageSmoothingEnabled = false;
ctx.drawImage(
frame,
roi.x,
roi.y,
roi.w,
roi.h,
0,
0,
canvas.width,
canvas.height,
);
}, [frame, roi.x, roi.y, roi.w, roi.h, scale]);
return <canvas ref={ref} />;
}
/** Per-row parse ROIs, in the same order as the event's players array. */
interface RowRois {
weapon: Roi;
name: Roi;
paint: Roi;
stats: [Roi, Roi, Roi];
}
function scoreboardRows(): RowRois[] {
return sb.ROW_CENTERS.map((cy) => ({
weapon: sb.weaponRoi(cy),
name: sb.nameRoi(cy),
paint: sb.paintRoi(cy),
stats: [sb.statRoi(cy, 0), sb.statRoi(cy, 1), sb.statRoi(cy, 2)],
}));
}
/** winnerSide comes from the event debug: players are ordered winners-first. */
function battleLogRows(winnerSide: string): RowRois[] {
const panels = winnerSide === "bottom" ? [bl.PANEL_DY, 0] : [0, bl.PANEL_DY];
return panels.flatMap((dy) =>
bl.ROW_CENTERS.map((base) => {
const cy = base + dy;
return {
weapon: bl.weaponRoi(cy),
name: bl.nameRoi(cy),
paint: bl.paintRoi(cy),
stats: [bl.statRoi(cy, 0), bl.statRoi(cy, 1), bl.statRoi(cy, 2)] as [
Roi,
Roi,
Roi,
],
};
}),
);
}
/** winnerSide comes from the event debug: players are ordered winners-first. */
function replayRows(winnerSide: string): RowRois[] {
const panels =
winnerSide === "right" ? [replay.PANEL_DX, 0] : [0, replay.PANEL_DX];
return panels.flatMap((dx) =>
replay.ROW_CENTERS.map((cy) => ({
weapon: replay.weaponRoi(cy, dx),
name: replay.nameRoi(cy, dx),
paint: replay.paintRoi(cy, dx),
stats: [
replay.statRoi(cy, dx, 0),
replay.statRoi(cy, dx, 1),
replay.statRoi(cy, dx, 2),
] as [Roi, Roi, Roi],
})),
);
}
function drawOverlay(ctx: CanvasRenderingContext2D, detector: string) {
const rect = (roi: Roi, color: string) => {
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.strokeRect(roi.x, roi.y, roi.w, roi.h);
};
if (detector === "death") {
rect(death.SPLAT_LINE1_ROI, "#34d399");
rect(death.WEAPON_LINE_ROI, "#f87171");
for (let row = 0; row < death.ABILITY_ROWS; row++) {
rect(death.abilityMainRoi(row), "#60a5fa");
for (const slot of [0, 1, 2])
rect(death.abilitySubRoi(row, slot), "#e879f9");
}
rect(death.TAG_NAME_OUTER, "#4ade80");
for (const roi of [
...death.GATE_BURST_PROBES,
...death.GATE_PANEL_PROBES,
]) {
rect(roi, "#facc15");
}
return;
}
if (detector === "map-start") {
rect(mapStart.MODE_LABEL_ROI, "#34d399");
rect(mapStart.MODE_BLOCK_ROI, "#f87171");
rect(mapStart.STAGE_ROI, "#60a5fa");
rect(mapStart.GATE_INK_BAND, "#e879f9");
for (const roi of mapStart.GATE_DARK_PROBES) rect(roi, "#facc15");
return;
}
if (detector === "scoreboard-own") {
rect(own.WEAPON_TITLE_BAND, "#f87171");
for (let row = 0; row < own.GEAR_ROWS; row++) {
rect(own.gearMainRoi(row), "#60a5fa");
for (const slot of [0, 1, 2]) rect(own.gearSubRoi(row, slot), "#e879f9");
rect(own.gateStripProbe(row), "#facc15");
}
for (const roi of [
...own.GATE_PANEL_PROBES,
...own.GATE_TITLE_TEXT_PROBES,
]) {
rect(roi, "#facc15");
}
rect(sb.HEADER_LOBBY_BAND, "#34d399");
rect(sb.HEADER_LINE_BAND, "#34d399");
return;
}
if (detector === "minimap") {
for (const card of minimap.CARD_LAYOUTS) {
rect(card.name, "#4ade80");
rect(card.weapon, "#f87171");
rect(card.subTile, "#22d3ee");
for (const [cx, cy] of card.badges)
rect(minimap.badgeRoi(cx, cy), "#60a5fa");
rect(card.cross, "#e879f9");
}
for (const cy of minimap.ENEMY_ROW_CYS) {
rect(minimap.enemyWeaponRoi(cy), "#f87171");
rect(minimap.enemySubTileRoi(cy), "#22d3ee");
for (const cx of minimap.ENEMY_BADGE_XS)
rect(minimap.badgeRoi(cx, cy), "#60a5fa");
rect(minimap.enemyCrossRoi(cy), "#e879f9");
}
for (const roi of [
...minimap.GATE_CLOSE_X_BRIGHT,
minimap.GATE_SPAWN_BRIGHT,
...minimap.GATE_CLOSE_DARK_PROBES,
...minimap.GATE_CLOSE_X_DARK,
...minimap.GATE_SPAWN_DARK_PROBES,
]) {
rect(roi, "#facc15");
}
return;
}
if (detector === "scoreboard-battle-log") {
for (const dy of bl.PANEL_DYS) {
for (const base of bl.ROW_CENTERS) {
const cy = base + dy;
rect(bl.weaponRoi(cy), "#f87171");
rect(bl.nameRoi(cy), "#4ade80");
rect(bl.paintRoi(cy), "#60a5fa");
for (const i of [0, 1, 2] as const) rect(bl.statRoi(cy, i), "#e879f9");
rect(bl.gateDarkProbe(cy), "#facc15");
}
rect(bl.teamScoreRoi(dy), "#60a5fa");
rect(bl.resultTagRoi(dy), "#fb923c");
}
for (const roi of bl.MATCH_SCORE_ROIS) rect(roi, "#60a5fa");
for (const roi of bl.GATE_COLOR_PROBES) rect(roi, "#facc15");
rect(bl.HEADER_TOP_BAND, "#34d399");
rect(bl.HEADER_BOTTOM_BAND, "#34d399");
return;
}
if (detector === "scoreboard-battle-log-replay") {
for (const dx of replay.PANEL_XS) {
for (const cy of replay.ROW_CENTERS) {
rect(replay.weaponRoi(cy, dx), "#f87171");
rect(replay.nameRoi(cy, dx), "#4ade80");
rect(replay.paintRoi(cy, dx), "#60a5fa");
for (const i of [0, 1, 2] as const)
rect(replay.statRoi(cy, dx, i), "#e879f9");
rect(replay.gateFlatProbe(cy, dx), "#facc15");
}
rect(replay.teamScoreRoi(dx), "#60a5fa");
rect(replay.resultTagRoi(dx), "#fb923c");
}
for (const roi of replay.MATCH_SCORE_ROIS) rect(roi, "#60a5fa");
for (const roi of replay.GATE_GAP_PROBES) rect(roi, "#facc15");
rect(replay.HEADER_TOP_BAND, "#34d399");
rect(replay.HEADER_BOTTOM_BAND, "#34d399");
rect(replay.REPLAY_CODE_ROI, "#34d399");
return;
}
for (const cy of sb.ROW_CENTERS) {
rect(sb.weaponRoi(cy), "#f87171");
rect(sb.nameRoi(cy), "#4ade80");
rect(sb.paintRoi(cy), "#60a5fa");
for (const i of [0, 1, 2] as const) rect(sb.statRoi(cy, i), "#e879f9");
rect(sb.gateDarkProbe(cy), "#facc15");
}
for (const roi of sb.TEAM_SCORE_ROIS) rect(roi, "#60a5fa");
for (const roi of sb.MATCH_SCORE_ROIS) rect(roi, "#fb923c");
for (const roi of sb.GATE_PANEL_PROBES) rect(roi, "#facc15");
rect(sb.HEADER_LOBBY_BAND, "#34d399");
rect(sb.HEADER_LINE_BAND, "#34d399");
}
export function ScreenshotPage() {
const displayRef = useRef<HTMLCanvasElement>(null);
const clientRef = useRef<AnalyzerClient | null>(null);
const resultRef = useRef<(r: Result) => void>(() => {});
const doneRef = useRef<() => void>(() => {});
const [frame, setFrame] = useState<HTMLCanvasElement | null>(null);
const [results, setResults] = useState<Record<string, Result>>({});
const [busy, setBusy] = useState(false);
const busyRef = useRef(false);
const [over, setOver] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
clientRef.current = new AnalyzerClient(
(r) => resultRef.current(r),
(message) => {
setError(message);
setBusy(false);
},
() => doneRef.current(),
// one-shot analyses: re-running the same screenshot must always parse
{ suppressSteadyFrames: false },
);
return () => clientRef.current?.dispose();
}, []);
// the detector whose inspector/overlay is shown: the one that fired
const active = Object.values(results).find((r) => r.events.length > 0);
const activeDetector = active?.detector ?? "scoreboard";
useEffect(() => {
if (!frame) return;
const display = displayRef.current!;
display.width = CANONICAL_WIDTH;
display.height = CANONICAL_HEIGHT;
const ctx = display.getContext("2d")!;
ctx.drawImage(frame, 0, 0);
drawOverlay(ctx, activeDetector);
}, [frame, activeDetector]);
const analyze = useCallback(async (file: File | Blob) => {
// one file at a time: in-flight worker results would land in the next
// file's state through the shared resultRef otherwise
if (busyRef.current) return;
busyRef.current = true;
setError(null);
setBusy(true);
setResults({});
try {
const bitmap = await createImageBitmap(file);
// normalized frame for local crop display, same as the pipeline does
const norm = document.createElement("canvas");
norm.width = CANONICAL_WIDTH;
norm.height = CANONICAL_HEIGHT;
norm
.getContext("2d")!
.drawImage(bitmap, 0, 0, CANONICAL_WIDTH, CANONICAL_HEIGHT);
setFrame(norm);
resultRef.current = (r) => {
setResults((prev) => ({ ...prev, [r.detector]: r }));
};
doneRef.current = () => {
busyRef.current = false;
setBusy(false);
};
const client = clientRef.current!;
await client.whenReady();
if (!client.analyze(bitmap, 0)) {
busyRef.current = false;
setBusy(false);
}
} catch (e) {
setError(String(e));
busyRef.current = false;
setBusy(false);
}
}, []);
// frame handed off from an Inspect click in another browser tab; the
// handoff write races this tab's load, so the claim polls briefly
const [inspectKey, setInspectKey] = useSearchParam(
scannerSearchParams,
"inspect",
);
useEffect(() => {
if (!inspectKey) return;
setInspectKey(null);
claimInspectFrame(inspectKey).then(
(frame) => {
if (frame) void analyze(frame);
else
setError(
"Inspected frame did not arrive — go back to the other tab and press Inspect again",
);
},
(e) => setError(String(e)),
);
}, [inspectKey, setInspectKey, analyze]);
const event = active?.events[0] as DetectedEvent<CardData> | undefined;
const rows = (event?.debug?.rows ?? []) as ScoreboardRowDebug[];
const isReplay = activeDetector === "scoreboard-battle-log-replay";
const isScoreboardBattleLog = activeDetector === "scoreboard-battle-log";
const isDeath = activeDetector === "death";
const isMapStart = activeDetector === "map-start";
const isOwn = activeDetector === "scoreboard-own";
const isMinimap = activeDetector === "minimap";
const winnerSide = String(event?.debug?.winnerSide ?? "left");
const rowRois = isReplay
? replayRows(winnerSide)
: isScoreboardBattleLog
? battleLogRows(winnerSide)
: scoreboardRows();
return (
<div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */}
<div
className={clsx("dropzone", { over })}
onDragOver={(e) => {
e.preventDefault();
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={(e) => {
e.preventDefault();
setOver(false);
const file = e.dataTransfer.files[0];
if (file) void analyze(file);
}}
>
Drop a frame (PNG/JPEG) here, or{" "}
<label>
pick a file
<input
type="file"
accept="image/png,image/jpeg"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = ""; // allow re-picking the same file
if (file) void analyze(file);
}}
/>
</label>
{busy ? " — analyzing…" : null}
</div>
{error ? <p className="error">{error}</p> : null}
<div
className="screenshot-frame"
style={{ display: frame ? "block" : "none" }}
>
<canvas ref={displayRef} />
</div>
{frame && !busy ? (
<p>
<button
type="button"
onClick={() =>
downloadExpectedJson(event?.data ?? null, event?.type)
}
>
Download expected.json
</button>{" "}
<button
type="button"
disabled={!event}
onClick={() =>
downloadEventsCsv(
"screenshot-events.csv",
Object.values(results).flatMap((r) => r.events),
)
}
>
Download CSV
</button>
</p>
) : null}
{Object.values(results).map((result) => (
<p key={result.detector}>
{result.detector} gate:{" "}
<b>{result.gate.pass ? "fired" : "no fire"}</b> (score{" "}
{result.gate.score.toFixed(3)})
{result.events[0] && result.detector === "death" ? (
<>
{" · "}confidence{" "}
{((result.events[0].confidence ?? 0) * 100).toFixed(1)}%{" · "}
{(() => {
const data = result.events[0].data as DeathData;
return `splatted by ${weaponLabel(data.weaponType, data.weaponId) ?? "?"} (${data.name ?? "?"})`;
})()}
</>
) : null}
{result.events[0] && result.detector === "map-start" ? (
<>
{" · "}confidence{" "}
{((result.events[0].confidence ?? 0) * 100).toFixed(1)}%{" · "}
{(() => {
const data = result.events[0].data as MapStartData;
return `${modeLabel(data.mode) ?? "?"} · ${stageLabel(data.stage) ?? "?"}`;
})()}
</>
) : null}
{result.events[0] && result.detector === "scoreboard-own" ? (
<>
{" · "}confidence{" "}
{((result.events[0].confidence ?? 0) * 100).toFixed(1)}%{" · "}
{(() => {
const data = result.events[0].data as ScoreboardOwnData;
return `${[lobbyLabel(data.lobby), modeLabel(data.mode), stageLabel(data.stage)].map((v) => v ?? "?").join(" · ")} · ${mainWeaponLabel(data.weaponId) ?? "?"}`;
})()}
</>
) : null}
{result.events[0] && result.detector === "minimap" ? (
<>
{" · "}confidence{" "}
{((result.events[0].confidence ?? 0) * 100).toFixed(1)}%{" · "}
{(() => {
const data = result.events[0].data as MinimapData;
const players = data.teammates
.map((p) => p.name ?? "?")
.join(", ");
return `${data.stage ?? "?"} · ${players}`;
})()}
</>
) : null}
{result.events[0] &&
result.detector !== "death" &&
result.detector !== "map-start" &&
result.detector !== "scoreboard-own" &&
result.detector !== "minimap" ? (
<>
{" · "}confidence{" "}
{((result.events[0].confidence ?? 0) * 100).toFixed(1)}% · scores{" "}
{JSON.stringify((result.events[0].data as CardData).matchScores)}
{" · "}
{(() => {
const data = result.events[0].data as CardData;
return [
lobbyLabel(data.lobby),
modeLabel(data.mode),
stageLabel(data.stage),
]
.map((v) => v ?? "?")
.join(" · ");
})()}
</>
) : null}
</p>
))}
{frame && event && isReplay ? (
<p>
timestamp <b>{event.data.timestamp ?? "?"}</b>
{" · "}code <b>{event.data.replayCode ?? "?"}</b>{" "}
<span className="score">
(raw: {String(event.debug?.codeRaw ?? "")})
</span>
{" · "}match scores {JSON.stringify(event.data.matchScores)}
{" · "}winner panel <b>{winnerSide}</b>
<br />
<RoiCrop frame={frame} roi={replay.HEADER_TOP_BAND} />{" "}
<RoiCrop frame={frame} roi={replay.REPLAY_CODE_ROI} />
</p>
) : null}
{frame && event && isScoreboardBattleLog ? (
<p>
timestamp <b>{event.data.timestamp ?? "?"}</b>
{" · "}match scores {JSON.stringify(event.data.matchScores)}
{" · "}winner panel <b>{winnerSide}</b>
<br />
<RoiCrop frame={frame} roi={bl.HEADER_TOP_BAND} />{" "}
<RoiCrop frame={frame} roi={bl.HEADER_BOTTOM_BAND} />
</p>
) : null}
{frame && event && isDeath ? (
<p>
{(() => {
const data = event.data as unknown as DeathData;
return (
<>
weapon{" "}
<b>{weaponLabel(data.weaponType, data.weaponId) ?? "?"}</b>{" "}
<span className="score">
(raw: {String(event.debug?.weaponRaw ?? "")})
</span>
{" · "}name <b>{data.name ?? "?"}</b>{" "}
<span className="score">
(raw: {String(event.debug?.nameRaw ?? "")})
</span>
{" · "}abilities{" "}
{data.abilities.map((row) => row.join(" ")).join(" | ")}
<br />
<RoiCrop frame={frame} roi={death.WEAPON_LINE_ROI} />{" "}
<RoiCrop frame={frame} roi={death.TAG_NAME_OUTER} />
</>
);
})()}
</p>
) : null}
{frame && event && isMapStart ? (
<p>
{(() => {
const data = event.data as unknown as MapStartData;
return (
<>
mode <b>{modeLabel(data.mode) ?? "?"}</b>{" "}
<span className="score">
(raw: {String(event.debug?.modeReading ?? "")})
</span>
{" · "}stage <b>{stageLabel(data.stage) ?? "?"}</b>{" "}
<span className="score">
(raw: {String(event.debug?.stageReading ?? "")})
</span>
<br />
<RoiCrop
frame={frame}
roi={mapStart.MODE_BLOCK_ROI}
scale={0.75}
/>{" "}
<RoiCrop frame={frame} roi={mapStart.STAGE_ROI} />
</>
);
})()}
</p>
) : null}
{frame && event && isOwn ? (
<p>
{(() => {
const data = event.data as unknown as ScoreboardOwnData;
return (
<>
weapon <b>{mainWeaponLabel(data.weaponId) ?? "?"}</b>{" "}
<span className="score">
(raw: {String(event.debug?.weaponReading ?? "")})
</span>
{" · "}abilities{" "}
{data.abilities.map((row) => row.join(" ")).join(" | ")}
<br />
<RoiCrop frame={frame} roi={own.WEAPON_TITLE_BAND} />{" "}
{[0, 1, 2].map((row) => (
<RoiCrop
key={row}
frame={frame}
roi={{
x: own.GEAR_MAIN_CXS[row]! - 36,
y: own.GEAR_BADGE_CY - 32,
w: 200,
h: 64,
}}
/>
))}
</>
);
})()}
</p>
) : null}
{frame && event && isMinimap ? (
<p>
{(() => {
const data = event.data as unknown as MinimapData;
return (
<>
stage <b>{stageLabel(data.stage) ?? "?"}</b>
{" · "}
{data.spectator ? "spectator map" : "POV overlay"}
{" · "}team{" "}
<b>
{data.teammates
.map(
(p) =>
`${p.slot}: ${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})`,
)
.join(", ") || "—"}
</b>
{data.enemies.length > 0 ? (
<>
{" · "}enemies{" "}
<b>
{data.enemies
.map(
(p) =>
`${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})`,
)
.join(", ")}
</b>
</>
) : null}
{!data.spectator ? (
<>
<br />
{minimap.CARD_LAYOUTS.map((card) => (
<RoiCrop key={card.slot} frame={frame} roi={card.name} />
))}
</>
) : null}
</>
);
})()}
</p>
) : null}
{frame && event && !isDeath && !isMapStart && !isOwn && !isMinimap ? (
<table className="inspector">
<thead>
<tr>
<th>row</th>
<th>weapon crop</th>
<th>top candidates</th>
<th>name</th>
<th>paint</th>
<th>stats (splats / deaths / specials)</th>
</tr>
</thead>
<tbody>
{rowRois.map((roi, i) => {
const player = event.data.players[i];
const dbg = rows[i];
return (
<tr key={i}>
<td>{i}</td>
<td>
<RoiCrop frame={frame} roi={roi.weapon} />
</td>
<td>
<div className="candidates weapon-candidates">
{dbg?.weapon?.top.map((c) => (
<span className="cand" key={c.id}>
<img
className="weapon-icon"
src={`${mainWeaponImageUrl(Number(c.id) as MainWeaponId)}.avif`}
alt={c.id}
/>
{c.id}
<span className="score">{c.score.toFixed(3)}</span>
</span>
))}
</div>
</td>
<td>
<RoiCrop frame={frame} roi={roi.name} />
<b>{player?.name || "—"}</b>{" "}
<span className="score">{dbg?.nameScore.toFixed(3)}</span>
</td>
<td>
<RoiCrop frame={frame} roi={roi.paint} />
<b>{player?.paint ?? "—"}</b>{" "}
<span className="score">{dbg?.paintScore.toFixed(3)}</span>
</td>
<td>
<div className="candidates">
{([0, 1, 2] as const).map((s) => (
<span className="cand" key={s}>
<RoiCrop frame={frame} roi={roi.stats[s]} scale={2} />
<b>{[player?.ka, player?.d, player?.s][s] ?? "—"}</b>
<span className="score">
{dbg?.statScores[s].toFixed(2)}
</span>
</span>
))}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
) : null}
</div>
);
}

View File

@@ -0,0 +1,911 @@
/**
* VoD tab: load a video file and scan it for scoreboard matches as fast as
* decoding allows — no real-time playback. On the primary (WebCodecs) path
* the file's duration is split into one contiguous slice per analyzer
* worker and each worker demuxes, decodes, schedules and analyzes its slice
* by itself (worker/analyzer.worker.ts): no frames cross the main thread,
* scheduling state is exact per slice, and calm stretches are skimmed by
* keyframe hops instead of decoded frame-by-frame. The seek fallback drives
* a <video> element through a single worker, widening its stride over calm
* footage. Each match can be opened in the screenshot page with the exact
* frame that was analyzed.
*
* Completed scans are persisted to IndexedDB keyed by file name
* (src/store/vods.ts); the default view lists them for reinspection.
*/
import clsx from "clsx";
import { Download, FileText, Send, Trash2, Video } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import * as R from "remeda";
import { SendouButton } from "~/components/elements/Button";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { ObjectiveTimeline } from "~/components/ObjectiveTimeline";
import { openSeekScan, probeWebCodecs } from "../capture/vod-frames";
import { connectAbilities } from "../core/ability-harvest";
import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import {
mergeScanTelemetry,
type ScanTelemetry,
} from "../core/detectors/telemetry";
import type { DetectedEvent } from "../core/detectors/types";
import {
buildScannerMatches,
ingestSkipReasons,
invalidObjectiveEvents,
} from "../core/match-builder";
import { TimelineBuilder } from "../core/timeline/index";
import type { SendStatus } from "../store/events";
import {
deleteVod,
listVods,
loadVodEventFrame,
loadVodEvents,
saveVod,
saveVodResultsSend,
type VodResultsSend,
type VodSummary,
} from "../store/vods";
import {
AnalyzerClient,
type DoneInfo,
defaultScanWorkerCount,
} from "../worker/client";
import { withoutRepeatEvents } from "./dedupe-events";
import { EventCard, type GetFrame } from "./EventCard";
import { EventsSummary } from "./EventsSummary";
import { downloadEventsCsv } from "./events-csv";
import type { FixtureData } from "./fixture-export";
import { formatTime, useEventDateTimeFormatter } from "./format";
import { MatchCard } from "./MatchCard";
import { MatchLobbyTabs } from "./MatchLobbyTabs";
import {
countIngestableMatches,
type SendouUser,
sendVodResults,
} from "./sendou-ingest";
import { sendouUpload } from "./sendou-upload";
import { thumbnailFromBlob } from "./thumbnail";
/** The scan knows the on-screen sides only, not who is playing. */
const SCANNER_TEAM_LABELS = ["Alpha", "Bravo"] as const;
/** seek-fallback stride while the worker reports activity */
const SEEK_ACTIVE_STRIDE_S = 0.25;
/**
* seek-fallback stride over calm footage (nothing detected for a while, no
* match open) — small enough that the screens that can start activity from
* dead air (results ~10s, match intro ~7s) still get sampled
*/
const SEEK_CALM_STRIDE_S = 2.5;
type Status = "idle" | "scanning" | "done" | "error";
interface VodMatch {
event: DetectedEvent<FixtureData>;
/**
* render identity — inherited when a better read replaces the event, so
* match cards keyed on it don't remount (replaying the enter animation)
*/
key: number;
thumbnail?: string;
/** lossless PNG of the exact frame the detector analyzed (live scan) */
frame?: Blob;
/** stored VoD: id to load the frame from the vod-frames store */
frameId?: number;
}
interface Progress {
t: number;
duration: number;
/** scan speed as a multiple of realtime */
rate: number;
}
/** "Send results" progress/outcome shown next to the button. */
type ResultsSend =
| { state: "sending"; sent: number; total: number }
| ({ state: "done" } & VodResultsSend);
export function VodPage({
sendouUser,
}: {
/** sendou.ink login shown in the header; undefined = probing, null = not logged in */
sendouUser: SendouUser | null | undefined;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const previewRef = useRef<HTMLCanvasElement>(null);
const clientsRef = useRef<AnalyzerClient[]>([]);
// cancels the in-flight chunk scans of the previous scan, if any
const abortScanRef = useRef<(() => void) | null>(null);
// seek fallback: latest per-frame done info + the waiter for the next one
const doneInfoRef = useRef<DoneInfo | null>(null);
const frameDoneRef = useRef<(() => void) | null>(null);
const timelineRef = useRef(new TimelineBuilder());
// latest gate score from any worker; flushed to state on the UI throttle
const gateScoreRef = useRef<number | null>(null);
const abortRef = useRef({ aborted: false });
const urlRef = useRef<string | null>(null);
// source of truth for matches (state mirrors it) so the scan loop can
// persist the final list without waiting on React
const matchesRef = useRef<VodMatch[]>([]);
// in-flight thumbnail work; awaited before persisting a finished scan
const sideWorkRef = useRef<Promise<void>[]>([]);
const nextMatchKeyRef = useRef(0);
const [fileName, setFileName] = useState<string | null>(null);
/** live scan vs. reopened saved VoD (no video element for the latter) */
const [source, setSource] = useState<"scan" | "stored">("scan");
const [status, setStatus] = useState<Status>("idle");
const [method, setMethod] = useState<string | null>(null);
const [gateScore, setGateScore] = useState<number | null>(null);
const [progress, setProgress] = useState<Progress | null>(null);
const [matches, setMatches] = useState<VodMatch[]>([]);
const [vods, setVods] = useState<VodSummary[]>([]);
const [error, setError] = useState<string | null>(null);
const [telemetry, setTelemetry] = useState<ScanTelemetry | null>(null);
const [over, setOver] = useState(false);
const [eventsOpen, setEventsOpen] = useState(false);
const [resultsSend, setResultsSend] = useState<ResultsSend | null>(null);
const formatSavedAt = useEventDateTimeFormatter();
const abilityMap = connectAbilities(matches.map((m) => m.event));
const builtMatches = buildScannerMatches(matches.map((m) => m.event));
const skipReasons = ingestSkipReasons(builtMatches);
// mirrors sendVodResults' ingestable-match order, which the send outcome's
// links are keyed by
const ingestableBuilt = builtMatches.filter((b) => !skipReasons.has(b));
const linkByIngestableIndex = new Map(
(resultsSend?.state === "done" ? (resultsSend.links ?? []) : []).map(
(linked) => [linked.matchIndex, linked.link] as const,
),
);
const vodMatchByEvent = new Map(matches.map((m) => [m.event, m] as const));
const groupedEvents = new Set(builtMatches.flatMap((b) => b.sources));
const ungroupedMatches = matches.filter((m) => !groupedEvents.has(m.event));
// "Send results" sends the whole scan in one go, so its outcome maps
// onto every ingestable card; a partial failure (some chunks sent, some
// not) can't be attributed per match — the bulk status text covers it
const bulkSend: SendStatus | undefined =
resultsSend?.state === "sending"
? { state: "sending", at: 0 }
: resultsSend?.state === "done" && resultsSend.error === null
? { state: "sent", at: resultsSend.at }
: resultsSend?.state === "done" && resultsSend.sent === 0
? { state: "failed", at: resultsSend.at }
: undefined;
// only offered once the whole VoD has been processed (a stored VoD is a
// completed scan by construction)
const upload =
status === "done" ? sendouUpload(matches.map((m) => m.event)) : null;
// "Send results" — the /ingest counterpart of live sending: the
// scan's ingestable ScannerMatches POSTed in one go
const resultsMatchCount =
status === "done" ? countIngestableMatches(matches.map((m) => m.event)) : 0;
const refreshVods = useCallback(async () => {
try {
setVods(await listVods());
} catch {
// listing failures are non-fatal; the scan UI still works
}
}, []);
const uploadResults = async () => {
const events = matchesRef.current.map((m) => m.event);
setResultsSend({
state: "sending",
sent: 0,
total: countIngestableMatches(events),
});
const report = await sendVodResults(events, (sent, total) =>
setResultsSend({ state: "sending", sent, total }),
);
const outcome: VodResultsSend = {
sent: report.sentMatches,
total: report.totalMatches,
error: report.error,
at: Date.now(),
links: report.links,
};
setResultsSend({ state: "done", ...outcome });
// the scan is saved under its file name, so its send outcome can be
// restored when the VoD is reopened
if (fileName) {
await saveVodResultsSend(fileName, outcome);
await refreshVods();
}
};
useEffect(() => {
void refreshVods();
return () => {
abortRef.current.aborted = true;
abortScanRef.current?.();
for (const client of clientsRef.current) client.dispose();
clientsRef.current = [];
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
};
}, [refreshVods]);
const scan = async (file: File) => {
abortRef.current.aborted = true;
abortScanRef.current?.();
const abort = { aborted: false };
abortRef.current = abort;
setError(null);
setTelemetry(null);
setMatches([]);
setResultsSend(null);
setEventsOpen(false);
setProgress(null);
setGateScore(null);
setMethod(null);
setFileName(file.name);
setSource("scan");
setStatus("scanning");
try {
// the element is used by the seek fallback and for post-scan review
const video = videoRef.current!;
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
urlRef.current = URL.createObjectURL(file);
video.src = urlRef.current;
if (clientsRef.current.length === 0) {
clientsRef.current = Array.from(
{ length: defaultScanWorkerCount() },
() =>
new AnalyzerClient(
(result) => {
gateScoreRef.current = result.gate.score;
if (!result.gate.pass) return;
for (const event of result.events as DetectedEvent<FixtureData>[]) {
const action = timelineRef.current.push(event);
if (action.action !== "added" && action.action !== "replaced")
continue;
const frame = result.frame;
sideWorkRef.current.push(
(async () => {
const thumbnail = frame
? await thumbnailFromBlob(frame)
: undefined;
const replaced =
action.action === "replaced"
? matchesRef.current.find(
(m) => m.event === action.replaced,
)
: undefined;
const next = matchesRef.current.filter(
(m) => m !== replaced,
);
next.push({
event,
key: replaced?.key ?? nextMatchKeyRef.current++,
thumbnail,
frame,
});
next.sort((a, b) => a.event.t - b.event.t);
matchesRef.current = next;
setMatches(next);
})().catch(() => {}),
);
}
},
(message) => {
frameDoneRef.current?.();
frameDoneRef.current = null;
setError(message);
setStatus("error");
},
(_t, info: DoneInfo) => {
doneInfoRef.current = info;
frameDoneRef.current?.();
frameDoneRef.current = null;
},
),
);
}
const clients = clientsRef.current;
await Promise.all(clients.map((c) => c.whenReady()));
// let work still in flight from an aborted scan finish before the
// timeline resets, so its results can't bleed into this scan
await Promise.all(clients.map((c) => c.whenIdle()));
if (abort.aborted) return;
matchesRef.current = [];
sideWorkRef.current = [];
timelineRef.current = new TimelineBuilder();
const started = performance.now();
const finalize = async (duration: number) => {
await Promise.all(sideWorkRef.current);
if (abort.aborted) return;
matchesRef.current = withoutInvalidObjectives(matchesRef.current);
setMatches(matchesRef.current);
setProgress((p) => (p ? { ...p, t: duration } : p));
setStatus("done");
await saveVod(
{ name: file.name, savedAt: Date.now(), duration },
matchesRef.current.map((m) => ({
type: m.event.type,
t: m.event.t,
confidence: m.event.confidence,
data: m.event.data,
thumbnail: m.thumbnail,
frame: m.frame,
})),
);
await refreshVods();
};
const probe = await probeWebCodecs(file);
if (abort.aborted) return;
if (probe) {
// each worker demuxes, decodes and analyzes its own slice of
// the file; the main thread only aggregates progress
setMethod("webcodecs");
const { duration } = probe;
const chunkSpan = duration / clients.length;
const chunks = clients.map((client, i) => ({
client,
tStart: i * chunkSpan,
tEnd: i === clients.length - 1 ? duration : (i + 1) * chunkSpan,
t: i * chunkSpan,
done: false,
telemetry: null as ScanTelemetry | null,
}));
abortScanRef.current = () => {
for (const client of clients) client.abortChunk();
};
const mergedTelemetry = () =>
mergeScanTelemetry(
chunks.flatMap((c) => (c.telemetry ? [c.telemetry] : [])),
);
let lastUiUpdate = Number.NEGATIVE_INFINITY;
const pushUiUpdate = () => {
const now = performance.now();
if (now - lastUiUpdate < 250) return;
lastUiUpdate = now;
const covered = R.sumBy(
chunks,
(c) => Math.min(c.t, c.tEnd) - c.tStart,
);
const elapsed = (now - started) / 1000;
setGateScore(gateScoreRef.current);
setProgress({
t: covered,
duration,
rate: elapsed > 0 ? covered / elapsed : 0,
});
setTelemetry(mergedTelemetry());
};
await Promise.all(
chunks.map((chunk, chunkIndex) =>
chunk.client
.scanChunk(
{ file, chunkIndex, tStart: chunk.tStart, tEnd: chunk.tEnd },
(progress) => {
chunk.t = progress.t;
chunk.telemetry = progress.telemetry;
if (progress.preview) {
// show one chunk at a time: the earliest still running
if (chunks.find((c) => !c.done) === chunk) {
drawPreview(previewRef.current, progress.preview);
}
progress.preview.close();
}
pushUiUpdate();
},
)
.then((chunkTelemetry) => {
chunk.done = true;
chunk.t = chunk.tEnd;
chunk.telemetry = chunkTelemetry;
}),
),
);
if (abort.aborted) return;
setTelemetry(mergedTelemetry());
await finalize(duration);
return;
}
// seek fallback: one worker, one frame in flight; the worker's calm
// signal widens the stride over dead air
setMethod("seek");
const strideRef = { current: SEEK_ACTIVE_STRIDE_S };
const vod = await openSeekScan(video, () => strideRef.current);
if (abort.aborted) return;
const client = clients[0]!;
let lastUiUpdate = Number.NEGATIVE_INFINITY;
for await (const { frame, t } of vod.frames) {
if (abort.aborted) {
frame.close();
break;
}
const now = performance.now();
if (now - lastUiUpdate >= 250) {
lastUiUpdate = now;
// the preview draw must precede analyze — transferring the
// frame to the worker detaches it
drawPreview(previewRef.current, frame);
setGateScore(gateScoreRef.current);
const elapsed = (now - started) / 1000;
setProgress({
t,
duration: vod.duration,
rate: elapsed > 0 ? t / elapsed : 0,
});
if (doneInfoRef.current) setTelemetry(doneInfoRef.current.telemetry);
}
await new Promise<void>((resolve) => {
frameDoneRef.current = resolve;
if (!client.analyze(frame, t)) resolve();
});
strideRef.current = doneInfoRef.current?.calm
? SEEK_CALM_STRIDE_S
: SEEK_ACTIVE_STRIDE_S;
}
if (doneInfoRef.current) setTelemetry(doneInfoRef.current.telemetry);
await finalize(vod.duration);
} catch (e) {
if (!abort.aborted) {
abortScanRef.current?.();
setError(String(e));
setStatus("error");
}
}
};
const openStored = async (vod: VodSummary) => {
const name = vod.name;
abortRef.current.aborted = true;
abortScanRef.current?.();
setTelemetry(null);
try {
const events = await loadVodEvents(name);
// VoDs saved before objective reads were mode-gated may carry them
const loaded = withoutInvalidObjectives(
events.map((e) => ({
event: {
type: e.type,
t: e.t,
confidence: e.confidence,
data: e.data as FixtureData,
},
key: nextMatchKeyRef.current++,
thumbnail: e.thumbnail,
frameId: e.hasFrame ? e.id : undefined,
})),
);
matchesRef.current = loaded;
setMatches(loaded);
setResultsSend(
vod.resultsSend ? { state: "done", ...vod.resultsSend } : null,
);
setEventsOpen(false);
setFileName(name);
setSource("stored");
setStatus("done");
setError(null);
setProgress(null);
setGateScore(null);
setMethod(null);
} catch (e) {
setError(String(e));
}
};
const removeVod = async (name: string) => {
await deleteVod(name);
await refreshVods();
};
const backToList = () => {
abortRef.current.aborted = true;
abortScanRef.current?.();
setTelemetry(null);
matchesRef.current = [];
setMatches([]);
setResultsSend(null);
setEventsOpen(false);
setFileName(null);
setStatus("idle");
setError(null);
setProgress(null);
setGateScore(null);
setMethod(null);
void refreshVods();
};
const showVodView = fileName !== null;
return (
<div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */}
<div
className={clsx("dropzone", { over })}
onDragOver={(e) => {
e.preventDefault();
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={(e) => {
e.preventDefault();
setOver(false);
const file = e.dataTransfer.files[0];
if (file) void scan(file);
}}
>
Drop a VoD (video file) here, or{" "}
<label>
pick a file
<input
type="file"
accept="video/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = ""; // allow re-picking the same file
if (file) void scan(file);
}}
/>
</label>
</div>
<div className="controls">
{showVodView ? (
<>
<button type="button" onClick={backToList}>
← All VoDs
</button>
<span
className={clsx("status", {
watching: status === "scanning",
detected: status === "done",
idle: status !== "scanning" && status !== "done",
})}
>
{source === "stored" ? "saved" : status}
{fileName ? ` · ${fileName}` : null}
{method ? ` · ${method}` : null}
{gateScore !== null && status === "scanning"
? ` · gate ${gateScore.toFixed(2)}`
: null}
</span>
{progress ? (
<span className="score">
{formatTime(progress.t)} / {formatTime(progress.duration)}
{progress.duration > 0
? ` (${Math.round((progress.t / progress.duration) * 100)}%)`
: null}
{progress.rate > 0
? ` · ${progress.rate.toFixed(0)}× realtime`
: null}
</span>
) : null}
{upload?.url ? (
<Link to={upload.url} className="link-button">
<Video aria-hidden />
Add VoD
</Link>
) : null}
{upload?.problem ? (
<span className="score">
upload unavailable: {upload.problem}
</span>
) : null}
{resultsMatchCount > 0 ? (
<button
type="button"
disabled={!sendouUser || resultsSend?.state === "sending"}
title={sendouUser ? undefined : "Log in on sendou.ink first"}
onClick={() => void uploadResults()}
>
<Send aria-hidden />
Send results
</button>
) : null}
{resultsSend ? (
<span
className={clsx("score", {
error:
resultsSend.state === "done" && Boolean(resultsSend.error),
})}
>
{resultsSend.state === "sending"
? `sending match ${Math.min(resultsSend.sent + 1, resultsSend.total)}/${resultsSend.total}…`
: resultsSend.error
? `sent ${resultsSend.sent}/${resultsSend.total} matches — ${resultsSend.error}`
: `sent ${resultsSend.sent}/${resultsSend.total} matches to sendou.ink`}
</span>
) : null}
{matches.length > 0 ? (
<ExportMenu
fileName={fileName}
events={matches.map((m) => m.event)}
/>
) : null}
</>
) : null}
</div>
{error ? <p className="error">{error}</p> : null}
{showVodView && telemetry ? (
<TelemetryPanel telemetry={telemetry} />
) : null}
{!showVodView ? (
<div className="vod-list">
{vods.length === 0 ? (
<p className="score">
No saved VoDs yet — scan one and it will show up here.
</p>
) : null}
{vods.map((vod) => (
<div key={vod.name} className="vod-item">
<span className="name">{vod.name}</span>
<span className="score">
{vod.eventCount} event{vod.eventCount === 1 ? "" : "s"} ·{" "}
{formatTime(vod.duration)} · {formatSavedAt(vod.savedAt)}
</span>
<button type="button" onClick={() => void openStored(vod)}>
Open
</button>
<FormWithConfirm
dialogHeading={`Delete saved analysis of "${vod.name}"?`}
onConfirm={() => void removeVod(vod.name)}
>
<SendouButton
variant="destructive"
size="small"
shape="square"
className="vod-delete"
icon={<Trash2 />}
aria-label="Delete"
/>
</FormWithConfirm>
</div>
))}
</div>
) : null}
<div
className="live-layout"
style={{
display: showVodView ? undefined : "none",
// a reopened saved VoD has no video to review — give the feed the full width
gridTemplateColumns: source === "stored" ? "1fr" : undefined,
}}
>
<div style={{ display: source === "scan" ? undefined : "none" }}>
<canvas
ref={previewRef}
className="preview"
style={{ display: status === "scanning" ? "block" : "none" }}
/>
<video
ref={videoRef}
className="preview"
muted
playsInline
controls
style={{
display: fileName && status !== "scanning" ? "block" : "none",
}}
/>
</div>
<div className="feed">
{matches.length === 0 ? (
<p className="score">
{status === "scanning"
? "Scanning — matches appear here as scoreboards are detected."
: "No matches found in this VoD."}
</p>
) : null}
<MatchLobbyTabs
matches={builtMatches}
keyOf={(built) => vodMatchByEvent.get(built.sources[0]!)!.key}
renderMatch={(built, justFormed) => {
const skipReason = skipReasons.get(built);
const link = linkByIngestableIndex.get(
ingestableBuilt.indexOf(built),
);
const send = skipReason ? undefined : bulkSend;
// counter reads render as one timeline chart, not a card each;
// a non-SZ match's reads (objective null) are never shown
const objectiveEvents = built.match.objective
? built.sources
.filter((e) => e.type === OBJECTIVE_EVENT_TYPE)
.map((e) => ({ t: e.t, data: e.data as ObjectiveData }))
: [];
const cardEvents = withoutRepeatEvents(built.sources).filter(
(e) => e.type !== OBJECTIVE_EVENT_TYPE,
);
return (
<MatchCard
match={built.match}
inProgress={
status === "scanning" &&
built === builtMatches.at(-1) &&
built.match.winner === null
}
skipReason={skipReason}
justFormed={justFormed}
send={
send?.state === "sent" && link ? { ...send, link } : send
}
>
{objectiveEvents.length > 0 ? (
<ObjectiveTimeline
events={objectiveEvents}
teamLabels={SCANNER_TEAM_LABELS}
/>
) : null}
{cardEvents.map((e) => {
const vodMatch = vodMatchByEvent.get(e);
return (
<EventCard
key={vodMatch?.key ?? `${e.type}-${e.t}`}
type={e.type}
t={e.t}
confidence={e.confidence}
data={e.data}
abilities={abilityMap.get(e)}
thumbnail={vodMatch?.thumbnail}
getFrame={vodMatch ? frameLoader(vodMatch) : undefined}
/>
);
})}
</MatchCard>
);
}}
/>
{ungroupedMatches.length > 0 ? (
<EventsSummary
events={ungroupedMatches.map((m) => m.event)}
open={eventsOpen}
onToggle={() => setEventsOpen(!eventsOpen)}
/>
) : null}
{/* newest detection on top; storage keeps ascending video-time order */}
{eventsOpen
? ungroupedMatches
.toReversed()
.map((m) => (
<EventCard
key={m.key}
type={m.event.type}
t={m.event.t}
confidence={m.event.confidence}
data={m.event.data}
abilities={abilityMap.get(m.event)}
thumbnail={m.thumbnail}
getFrame={frameLoader(m)}
/>
))
: null}
</div>
</div>
</div>
);
}
/** Export formats of the scanned events, behind one icon-only menu. */
function ExportMenu({
fileName,
events,
}: {
fileName: string;
events: DetectedEvent<FixtureData>[];
}) {
return (
<SendouMenu
trigger={
<SendouButton
icon={<Download />}
className="icon-menu"
aria-label="Export"
/>
}
>
<SendouMenuItem
icon={<FileText />}
onAction={() =>
downloadEventsCsv(
`${fileName.replace(/\.[^.]+$/, "")}-events.csv`,
events,
)
}
>
CSV
</SendouMenuItem>
</SendouMenu>
);
}
/** Drops objective reads that grouped into a known non-SZ match (misreads). */
function withoutInvalidObjectives(matches: VodMatch[]): VodMatch[] {
const invalid = new Set(
invalidObjectiveEvents(buildScannerMatches(matches.map((m) => m.event))),
);
return invalid.size > 0
? matches.filter((m) => !invalid.has(m.event))
: matches;
}
function frameLoader(m: VodMatch): GetFrame | undefined {
return m.frame
? () => Promise.resolve(m.frame)
: m.frameId !== undefined
? () => loadVodEventFrame(m.frameId!)
: undefined;
}
function TelemetryPanel({ telemetry }: { telemetry: ScanTelemetry }) {
const detectors = Object.entries(telemetry.detectors).sort(([a], [b]) =>
a.localeCompare(b),
);
const coveredS = telemetry.activeVideoS + telemetry.skimVideoS;
return (
<details className="telemetry">
<summary>
telemetry · analyzed {telemetry.analyzedFrames}/
{telemetry.decodedFrames} decoded frames
{coveredS > 0
? ` · skimmed ${formatTime(telemetry.skimVideoS)} of ${formatTime(coveredS)}`
: null}
{telemetry.wallMs > 0
? ` · ${formatTime(telemetry.wallMs / 1000)} cpu`
: null}
</summary>
<table>
<thead>
<tr>
<th>detector</th>
<th>checks</th>
<th>gate pass</th>
<th>gate ms</th>
<th>parses</th>
<th>parse ms</th>
<th>suppressed</th>
</tr>
</thead>
<tbody>
{detectors.map(([id, d]) => (
<tr key={id}>
<td>{id}</td>
<td>{d.checks}</td>
<td>{d.gatePasses}</td>
<td>{Math.round(d.gateMs)}</td>
<td>{d.parses}</td>
<td>{Math.round(d.parseMs)}</td>
<td>{d.suppressedParses}</td>
</tr>
))}
</tbody>
</table>
</details>
);
}
function drawPreview(
canvas: HTMLCanvasElement | null,
frame: ImageBitmap | VideoFrame,
): void {
if (!canvas) return;
const width = "displayWidth" in frame ? frame.displayWidth : frame.width;
const height = "displayHeight" in frame ? frame.displayHeight : frame.height;
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
canvas.getContext("2d")!.drawImage(frame, 0, 0);
}

View File

@@ -0,0 +1,42 @@
/**
* Display-side cleanup for a match card's source event list: a long map-open
* can fragment into several minimap events (the timeline's merge window
* tracks a drifting `t`, and VoD results arrive out of order), so a minimap
* that shows nothing new versus the previous one is pure noise. Names are
* ignored in the comparison — OCR wobbles on them while the actual content
* (stage, weapons, ability reads) is unchanged. Stored events are untouched;
* this only filters what gets rendered.
*/
import { isDeepEqual, omit } from "remeda";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
} from "../core/detectors/minimap/index";
export function withoutRepeatEvents<T extends { type: string; data: unknown }>(
events: readonly T[],
): T[] {
const result: T[] = [];
let previousMinimap: MinimapData | null = null;
for (const event of events) {
if (event.type === MINIMAP_EVENT_TYPE) {
const data = event.data as MinimapData;
const repeat =
previousMinimap !== null &&
isDeepEqual(comparable(previousMinimap), comparable(data));
previousMinimap = data;
if (repeat) continue;
}
result.push(event);
}
return result;
}
function comparable(data: MinimapData) {
return {
...data,
teammates: data.teammates.map((player) => omit(player, ["name"])),
enemies: data.enemies.map((player) => omit(player, ["name"])),
};
}

View File

@@ -0,0 +1,259 @@
/**
* Flatten detected events into a single CSV for download. One row per event;
* the six event types share columns where they overlap (lobby/mode/stage,
* weapon/name/abilities) and a scoreboard's eight player rows — or the
* minimap's teammates+enemies — are packed into one cell, matching the
* compact per-event view of the live feed.
*/
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
} from "../core/detectors/minimap/index";
import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
import type { ScoreboardBattleLogReplayData } from "../core/detectors/scoreboard-battle-log-replay/index";
import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
} from "../core/detectors/scoreboard-own/index";
import { formatClock, formatTime } from "./format";
import {
lobbyLabel,
mainWeaponLabel,
modeLabel,
stageLabel,
weaponLabel,
} from "./labels";
export interface CsvEvent {
type: string;
/** video/stream time in seconds */
t: number;
/** wall-clock time of detection (live capture only) */
detectedAt?: number;
confidence: number;
data: unknown;
}
const HEADER = [
"type",
"time",
"t_seconds",
"detected_at",
"confidence",
"lobby",
"mode",
"stage",
"winner_score",
"loser_score",
"pov",
"weapon",
"name",
"abilities",
"players",
"replay_code",
"replay_timestamp",
];
type Cell = string | number | null | undefined;
function csvCell(value: Cell): string {
if (value === null || value === undefined) return "";
const s = String(value);
return /[",\n\r]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
}
/** [head, clothes, shoes] rows of [main, sub, sub, sub] ability ids */
function formatAbilities(rows: string[][]): string {
return rows.map((row) => row.join("+")).join(" | ");
}
/** the minimap's flat [head, clothes, shoes] main-ability row */
function formatMinimapAbilities(abilities: (string | null)[]): string {
return abilities.map((a) => a ?? "?").join("+");
}
function formatMinimapPlayers(data: MinimapData): string {
const fmt = (
label: string,
p: {
name: string | null;
weaponId: number | null;
abilities: (string | null)[];
},
) =>
`${label} ${p.name ?? "?"} · ${mainWeaponLabel(p.weaponId as MainWeaponId | null) ?? "?"} · ${formatMinimapAbilities(p.abilities)}`;
return [
...data.teammates.map((p) => fmt(p.slot, p)),
...data.enemies.map((p, i) => fmt(`enemy${i + 1}`, p)),
].join("; ");
}
function formatPlayers(data: ScoreboardData): string {
return data.players
.map(
(p, i) =>
`${i < 4 ? "W" : "L"} ${p.name} · ${p.weaponId ?? "?"} · ${p.paint ?? "?"}p ` +
`${p.ka ?? "?"}/${p.d ?? "?"}/${p.s ?? "?"}`,
)
.join("; ");
}
function eventCells(event: CsvEvent): Cell[] {
const base: Cell[] = [
event.type,
formatTime(event.t),
Math.round(event.t * 1000) / 1000,
event.detectedAt === undefined
? ""
: new Date(event.detectedAt).toISOString(),
Math.round(event.confidence * 1000) / 1000,
];
switch (event.type) {
case DEATH_EVENT_TYPE: {
const d = event.data as DeathData;
return [
...base,
"",
"",
"",
"",
"",
"",
weaponLabel(d.weaponType, d.weaponId),
d.name,
formatAbilities(d.abilities),
"",
"",
"",
];
}
case MAP_START_EVENT_TYPE: {
const d = event.data as MapStartData;
return [
...base,
"",
modeLabel(d.mode),
stageLabel(d.stage),
"",
"",
"",
"",
"",
"",
"",
"",
"",
];
}
case SCOREBOARD_OWN_EVENT_TYPE: {
const d = event.data as ScoreboardOwnData;
return [
...base,
lobbyLabel(d.lobby),
modeLabel(d.mode),
stageLabel(d.stage),
"",
"",
"",
mainWeaponLabel(d.weaponId),
"",
formatAbilities(d.abilities),
"",
"",
"",
];
}
case OBJECTIVE_EVENT_TYPE: {
const d = event.data as ObjectiveData;
const sideText = (side: 0 | 1) =>
`${d.score[side] ?? "?"}${d.penalty[side] !== null ? ` (+${d.penalty[side]})` : ""}${d.control[side] ? " ctrl" : ""}`;
const clock = d.time === null ? "" : `${formatClock(d.time)} · `;
return [
...base,
"",
modeLabel(d.mode),
"",
"",
"",
"",
"",
"",
"",
`${clock}${sideText(0)} vs ${sideText(1)}`,
"",
"",
];
}
case MINIMAP_EVENT_TYPE: {
const d = event.data as MinimapData;
const self = d.teammates.find((p) => p.slot === "self");
return [
...base,
"", // lobby
"", // mode (not readable from the minimap)
stageLabel(d.stage), // stage (planner-signature match)
"", // winner_score
"", // loser_score
self?.name,
self ? mainWeaponLabel(self.weaponId) : "",
"",
self ? formatMinimapAbilities(self.abilities) : "",
formatMinimapPlayers(d),
"",
"",
];
}
default: {
// Scoreboard, ScoreboardBattleLogReplay and ScoreboardBattleLog share
// the base shape
const d = event.data as ScoreboardData &
Partial<ScoreboardBattleLogReplayData>;
return [
...base,
lobbyLabel(d.lobby),
modeLabel(d.mode),
stageLabel(d.stage),
d.matchScores[0],
d.matchScores[1],
d.povIndex === null ? "" : d.players[d.povIndex]?.name,
"",
"",
"",
formatPlayers(d),
d.replayCode ?? "",
d.timestamp ?? "",
];
}
}
}
function eventsToCsv(events: CsvEvent[]): string {
const lines = [HEADER.join(",")];
for (const event of events)
lines.push(eventCells(event).map(csvCell).join(","));
return `${lines.join("\r\n")}\r\n`;
}
export function downloadEventsCsv(filename: string, events: CsvEvent[]): void {
const blob = new Blob([eventsToCsv(events)], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}

View File

@@ -0,0 +1,252 @@
/**
* "Save as fixture": download the raw captured frame as PNG plus an
* expected.json prefilled from the detector's own output, so labeling a new
* fixture is review-and-correct instead of data entry.
*/
import {
DEATH_EVENT_TYPE,
type DeathData,
} from "../core/detectors/death/index";
import {
MAP_START_EVENT_TYPE,
type MapStartData,
} from "../core/detectors/map-start/index";
import {
MINIMAP_EVENT_TYPE,
type MinimapData,
} from "../core/detectors/minimap/index";
import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "../core/detectors/objective/index";
import type { ScoreboardData } from "../core/detectors/scoreboard/index";
import type { ScoreboardBattleLogReplayData } from "../core/detectors/scoreboard-battle-log-replay/index";
import {
SCOREBOARD_OWN_EVENT_TYPE,
type ScoreboardOwnData,
} from "../core/detectors/scoreboard-own/index";
import { mainWeaponLabel, stageLabel, weaponLabel } from "./labels";
/** Scoreboard data with the replay extras present when the event has them. */
export type CardData = ScoreboardData &
Partial<Pick<ScoreboardBattleLogReplayData, "timestamp" | "replayCode">>;
/** Any detector's event payload that can prefill a fixture. */
export type FixtureData =
| CardData
| DeathData
| MapStartData
| ScoreboardOwnData
| MinimapData
| ObjectiveData;
function isDeath(_data: FixtureData, eventType: string): _data is DeathData {
return eventType === DEATH_EVENT_TYPE;
}
function download(name: string, blob: Blob): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
}
function buildExpectedJson(
data: FixtureData | null,
eventType = "Scoreboard",
): string {
if (!data) {
return `${JSON.stringify({ event: "none" }, null, 2)}\n`;
}
if (isDeath(data, eventType)) {
const label = weaponLabel(data.weaponType, data.weaponId);
return `${JSON.stringify(
{
event: eventType,
data: {
...(label !== null && { weaponLabel: label }),
...(data.weaponId !== null && { weaponId: data.weaponId }),
...(data.weaponType !== null && { weaponType: data.weaponType }),
abilities: data.abilities,
...(data.name !== null && { name: data.name }),
},
},
null,
2,
)}\n`;
}
if (eventType === SCOREBOARD_OWN_EVENT_TYPE) {
const own = data as ScoreboardOwnData;
return `${JSON.stringify(
{
event: eventType,
data: {
...(own.lobby !== null && { lobby: own.lobby }),
...(own.mode !== null && { mode: own.mode }),
...(own.stage !== null && {
stage: own.stage,
stageLabel: stageLabel(own.stage),
}),
...(own.weaponId !== null && {
weaponLabel: mainWeaponLabel(own.weaponId),
weaponId: own.weaponId,
}),
abilities: own.abilities,
},
},
null,
2,
)}\n`;
}
if (eventType === MINIMAP_EVENT_TYPE) {
const minimap = data as MinimapData;
return `${JSON.stringify(
{
event: eventType,
data: {
...(minimap.stage !== null && {
stage: minimap.stage,
stageLabel: stageLabel(minimap.stage),
}),
...(minimap.spectator && { spectator: true }),
teammates: minimap.teammates.map((p) => ({
slot: p.slot,
name: p.name,
weaponLabel: mainWeaponLabel(p.weaponId),
weaponId: p.weaponId,
abilities: p.abilities,
})),
enemies: minimap.enemies.map((p) => ({
...(minimap.spectator && { name: p.name }),
weaponLabel: mainWeaponLabel(p.weaponId),
weaponId: p.weaponId,
abilities: p.abilities,
})),
},
},
null,
2,
)}\n`;
}
if (eventType === OBJECTIVE_EVENT_TYPE) {
const objective = data as ObjectiveData;
return `${JSON.stringify(
{
event: eventType,
data: {
mode: objective.mode,
time: objective.time,
score: objective.score,
penalty: objective.penalty,
control: objective.control,
},
},
null,
2,
)}\n`;
}
// NB: not a type-predicate helper — CardData is structurally assignable to
// MapStartData, so a predicate would narrow the fall-through case to never
if (eventType === MAP_START_EVENT_TYPE) {
const mapStart = data as MapStartData;
return `${JSON.stringify(
{
event: eventType,
data: {
...(mapStart.mode !== null && { mode: mapStart.mode }),
...(mapStart.stage !== null && {
stage: mapStart.stage,
stageLabel: stageLabel(mapStart.stage),
}),
},
},
null,
2,
)}\n`;
}
const card = data as CardData;
return `${JSON.stringify(
{
event: eventType,
data: {
...(card.lobby !== null && { lobby: card.lobby }),
...(card.mode !== null && { mode: card.mode }),
...(card.stage !== null && {
stage: card.stage,
stageLabel: stageLabel(card.stage),
}),
...(card.timestamp != null && { timestamp: card.timestamp }),
...(card.replayCode != null && { replayCode: card.replayCode }),
matchScores: card.matchScores,
players: card.players.map((p) => ({
name: p.name,
weaponId: p.weaponId,
paint: p.paint,
ka: p.ka,
d: p.d,
s: p.s,
})),
},
},
null,
2,
)}\n`;
}
/**
* expected.json alone, for a frame the user already has on disk (Screenshot
* page). Null data produces the negative-fixture form.
*/
export function downloadExpectedJson(
data: FixtureData | null,
eventType?: string,
): void {
download(
"expected.json",
new Blob([buildExpectedJson(data, eventType)], {
type: "application/json",
}),
);
}
/**
* Fixture export for a live detection: the stored PNG is the byte-exact
* frame the detector analyzed, paired with that event's own parse output.
*/
export function saveFixtureFromEvent(
frame: Blob,
data: FixtureData,
eventType: string,
): void {
download("frame.png", frame);
download(
"expected.json",
new Blob([buildExpectedJson(data, eventType)], {
type: "application/json",
}),
);
}
export async function saveFixture(
video: HTMLVideoElement,
latest: { type: string; data: FixtureData } | null,
): Promise<void> {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d")!.drawImage(video, 0, 0);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/png"),
);
if (!blob) throw new Error("could not encode frame");
download("frame.png", blob);
download(
"expected.json",
new Blob([buildExpectedJson(latest?.data ?? null, latest?.type)], {
type: "application/json",
}),
);
}

View File

@@ -0,0 +1,37 @@
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
/** hh:mm:ss for feed rows, CSV cells, and scan progress. */
export function formatTime(t: number): string {
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const s = Math.floor(t % 60);
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
/** the match timer's M:SS (215 → "3:35") */
export function formatClock(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
/**
* Locale-aware time-of-day formatter for detection timestamps (epoch ms)
* shown in scanner cards, in place of a raw `toLocaleTimeString()` call.
*/
export function useEventTimeFormatter(): (ms: number) => string {
const { formatter } = useDateTimeFormat({ timeStyle: "medium" });
return (ms: number) => formatter.format(new Date(ms));
}
/**
* Locale-aware date+time formatter for absolute timestamps (epoch ms), e.g.
* a saved VoD's scan time, in place of a raw `toLocaleString()` call.
*/
export function useEventDateTimeFormatter(): (ms: number) => string {
const { formatter } = useDateTimeFormat({
dateStyle: "medium",
timeStyle: "medium",
});
return (ms: number) => formatter.format(new Date(ms));
}

View File

@@ -0,0 +1,59 @@
/**
* English display labels for the ids scanner events carry. UI-only: events and
* detectors speak sendou ids (§ scanner-types.ts); these helpers turn them back
* into human-readable names for cards, CSV export, and the fixture
* exporter's informational *Label fields.
*/
import type {
MainWeaponId,
ModeShort,
SpecialWeaponId,
StageId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import gameMisc from "../../../../locales/en/game-misc.json";
import {
ALL_WEAPON_ENTRIES,
type WeaponType,
} from "../core/detectors/death/weapon-names";
import type { ScannerLobby } from "../scanner-types";
const MISC = gameMisc as Record<string, string>;
const WEAPON_NAME_BY_KIND_AND_ID = new Map(
ALL_WEAPON_ENTRIES.map((e) => [`${e.type}:${e.id}`, e.name]),
);
export function weaponLabel(
type: WeaponType | null,
id: MainWeaponId | SubWeaponId | SpecialWeaponId | null,
): string | null {
if (type === null || id === null) return null;
return WEAPON_NAME_BY_KIND_AND_ID.get(`${type}:${id}`) ?? String(id);
}
export function mainWeaponLabel(id: MainWeaponId | null): string | null {
return id === null ? null : weaponLabel("MAIN", id);
}
export function stageLabel(stageId: StageId | null): string | null {
return stageId === null
? null
: (MISC[`STAGE_${stageId}`] ?? String(stageId));
}
export function modeLabel(mode: ModeShort | null): string | null {
return mode === null ? null : (MISC[`MODE_LONG_${mode}`] ?? mode);
}
const LOBBY_LABELS: Record<ScannerLobby, string> = {
X: "X Battle",
SERIES: "Anarchy Battle (Series)",
OPEN: "Anarchy Battle (Open)",
PRIVATE: "Private Battle",
};
export function lobbyLabel(lobby: ScannerLobby | null): string | null {
return lobby === null ? null : LOBBY_LABELS[lobby];
}

View File

@@ -0,0 +1,281 @@
/**
* Browser client for sendou.ink's /ingest. The scanner pages run inside
* sendou.ink itself, so requests are same-origin: the session cookie rides
* along automatically and the logged-in user comes from the root loader
* (useUser) instead of an identity probe. sendou.ink authenticates the
* session user and resolves the tournament/match server-side.
*
* The send unit is one ScannerMatch (core/match-builder.ts); every source
* event's IndexedDB record tracks the outcome (the `send` status the feed
* cards display). Resends are safe: sendou.ink dedupes matches by content
* hash, merges partials, and scoreboards first-ingest-wins.
*/
import * as R from "remeda";
import type {
IngestedMatchLink,
IngestResponse,
} from "~/features/scanner-ingest/scanner-ingest-schemas";
import type { DetectedEvent } from "../core/detectors/types";
import type { BuiltMatch } from "../core/match-builder";
import { buildScannerMatches, ingestSkipReasons } from "../core/match-builder";
import type { ScannerMatch } from "../core/scanner-match";
import {
type SendStatus,
type StoredEvent,
updateEventsSend,
} from "../store/events";
const INGEST_URL = "/ingest";
/** /ingest accepts at most 50 matches per request (mirrors the server cap) */
const MAX_MATCHES_PER_REQUEST = 50;
/**
* How long after each unlinked send to try again. A live send usually beats
* the players to reporting the game, so the first attempts find nothing to
* link to; these delays cover the reporting lag without hammering. Running
* out of them gives up — the capture ending still makes one last attempt.
*/
const UNLINKED_RETRY_DELAYS_MS = [30_000, 2 * 60_000, 5 * 60_000];
export interface SendouUser {
id: number;
username: string;
}
export interface SendResult {
sentMatches: number;
failedMatches: number;
}
/**
* Builds the stored events into matches, POSTs the ingestable ones `include`
* selects, and records the outcome on every source event's `send` status
* (calling `onStatus` after each store write so the feed can refresh).
*/
export async function sendMatches({
events,
include,
onStatus,
}: {
events: readonly StoredEvent[];
include: (built: BuiltMatch<StoredEvent>) => boolean;
onStatus: () => void;
}): Promise<SendResult> {
const allBuilt = ingestableBuilt(
buildScannerMatches(events.filter((e) => e.id !== undefined)),
);
const selected = allBuilt.filter(include);
await clearOrphanedQueued(events, allBuilt);
const result: SendResult = { sentMatches: 0, failedMatches: 0 };
for (const built of selected) {
const ids = built.sources.map((e) => e.id!);
await updateEventsSend(ids, { state: "sending", at: Date.now() });
onStatus();
try {
const response = await postIngestMatches([built.match]);
const link = response.linkedMatches?.find(
(linked) => linked.matchIndex === 0,
)?.link;
// stored but not linked, and sendou.ink knows which tournament or
// SendouQ match this is: the game is just not reported yet, so a
// later resend can still land it. Without a context there is nothing
// to wait for and the match is as done as it will get.
const unlinked = !link && response.contextResolved;
await updateEventsSend(ids, {
state: unlinked ? "unlinked" : "sent",
at: Date.now(),
...(link ? { link } : null),
...(unlinked
? {
attempts: (aggregateSendStatus(built.sources)?.attempts ?? 0) + 1,
}
: null),
});
result.sentMatches++;
} catch (err) {
await updateEventsSend(ids, {
state: "failed",
at: Date.now(),
error: err instanceof Error ? err.message : String(err),
});
result.failedMatches++;
}
onStatus();
}
return result;
}
export interface VodResultsSendReport {
sentMatches: number;
totalMatches: number;
/** last failure's message; null when every request went through */
error: string | null;
/** links /ingest reported, keyed by index into the scan's ingestable matches */
links: Array<{ matchIndex: number; link: IngestedMatchLink }>;
}
/**
* One-go sender for the VoD tab's "Send results": builds a completed
* scan's events into matches and POSTs as many per request as the server
* cap allows — usually the whole scan in one request, so sendou.ink's
* content-based tournament resolution sees the full match sequence (its
* mode+stage order plus roster sides is near-unique in the user's history).
* No per-event status bookkeeping — VoD events don't live in the live feed
* store. Resending is safe (server-side dedupe/merge), so a partial failure
* can simply be retried whole.
*/
export async function sendVodResults(
events: readonly DetectedEvent[],
onProgress?: (sentMatches: number, totalMatches: number) => void,
): Promise<VodResultsSendReport> {
const matches = ingestableMatches(events);
let sentMatches = 0;
let error: string | null = null;
const links: VodResultsSendReport["links"] = [];
const chunks = R.chunk(matches, MAX_MATCHES_PER_REQUEST);
for (const [chunkIndex, request] of chunks.entries()) {
const offset = chunkIndex * MAX_MATCHES_PER_REQUEST;
try {
const response = await postIngestMatches(request);
for (const linked of response.linkedMatches ?? []) {
links.push({
matchIndex: offset + linked.matchIndex,
link: linked.link,
});
}
sentMatches += request.length;
onProgress?.(sentMatches, matches.length);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
}
}
return { sentMatches, totalMatches: matches.length, error, links };
}
/** The number of matches a set of events would send to /ingest. */
export function countIngestableMatches(
events: readonly DetectedEvent[],
): number {
return ingestableMatches(events).length;
}
/** Match selector: the match built from the given stored event. */
export function matchContaining(
id: number,
): (built: BuiltMatch<StoredEvent>) => boolean {
return (built) => built.sources.some((e) => e.id === id);
}
/**
* The single send status a match displays, folded from its source events:
* an in-flight send wins, then a failure, then success, then queued. Within
* a state the most recent change is shown.
*/
export function aggregateSendStatus(
sources: readonly StoredEvent[],
): SendStatus | undefined {
const statuses = sources
.map((e) => e.send)
.filter((status) => status !== undefined);
for (const state of [
"sending",
"failed",
"unlinked",
"sent",
"queued",
] as const) {
const ofState = statuses.filter((status) => status.state === state);
if (ofState.length > 0) {
return ofState.reduce((a, b) => (a.at >= b.at ? a : b));
}
}
return undefined;
}
/** Match selector: matches not yet sent (nor currently sending). */
export function unsentMatches(built: BuiltMatch<StoredEvent>): boolean {
return !built.sources.some(
(e) => e.send?.state === "sent" || e.send?.state === "sending",
);
}
/**
* Match selector: matches sendou.ink stored without a game to link them to,
* whose next attempt is due. Exhausting the backoff stops the retries.
*/
export function retryableUnlinkedMatches(
built: BuiltMatch<StoredEvent>,
): boolean {
const status = aggregateSendStatus(built.sources);
if (status?.state !== "unlinked") return false;
const delay = UNLINKED_RETRY_DELAYS_MS[(status.attempts ?? 1) - 1];
return delay !== undefined && Date.now() - status.at >= delay;
}
function ingestableMatches(events: readonly DetectedEvent[]): ScannerMatch[] {
return ingestableBuilt(buildScannerMatches(events)).map(
(built) => built.match,
);
}
function ingestableBuilt<E extends DetectedEvent>(
built: BuiltMatch<E>[],
): BuiltMatch<E>[] {
const skipped = ingestSkipReasons(built);
return built.filter((match) => !skipped.has(match));
}
async function postIngestMatches(
matches: ScannerMatch[],
): Promise<IngestResponse> {
const res = await fetch(INGEST_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ matches }),
});
if (!res.ok) {
throw new Error(
res.status === 401 ? "not logged in to sendou.ink" : await errorText(res),
);
}
return res.json();
}
/**
* Live sending marks events "queued" as they arrive; ones the match builder
* later leaves out (non-private match, older than the fallback window) would
* sit "queued" forever. Once a match boundary has passed them they can
* never join a future match, so clear their status back to "not sent".
*/
async function clearOrphanedQueued(
events: readonly StoredEvent[],
allBuilt: BuiltMatch<StoredEvent>[],
): Promise<void> {
const lastBoundaryT = Math.max(
...allBuilt.map((built) => built.sources.at(-1)!.t),
Number.NEGATIVE_INFINITY,
);
const builtIds = new Set(
allBuilt.flatMap((built) => built.sources.map((e) => e.id)),
);
const orphaned = events
.filter(
(e) =>
e.send?.state === "queued" &&
e.id !== undefined &&
!builtIds.has(e.id) &&
e.t <= lastBoundaryT,
)
.map((e) => e.id!);
if (orphaned.length > 0) await updateEventsSend(orphaned, undefined);
}
async function errorText(res: Response): Promise<string> {
const text = await res.text().catch(() => "");
return `POST /ingest -> ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`;
}

View File

@@ -0,0 +1,89 @@
/**
* "Upload to sendou.ink" link for a fully processed VoD: the detected events
* are built into ScannerMatches (core/match-builder.ts), projected onto the
* slim per-match rows the `ingest` search param of sendou.ink's /vods/new
* form carries (an `SP.json` param the search-params module compresses),
* which prefills a new VoD from them (the user adds the YouTube
* URL/title/date and fixes any misreads before submitting).
*
* The VoD type is auto-detected: footage containing the casted 8-player
* spectator map screen is a CAST VoD; anything else leaves the form's default
* type untouched. A match without a mode read prefills the form's SZ default,
* flagged `modeAssumed` — the fabricated default lives here, not on
* ScannerMatch.
*/
import type {
IngestVodMatchInput,
IngestVodPrefill,
} from "~/features/scanner-ingest/scanner-ingest-vod-schemas";
import { vodsNewSearchParams } from "~/features/vods/vods-search-params";
import type { MainWeaponId, ModeShort } from "~/modules/in-game-lists/types";
import { newVodPage } from "~/utils/urls";
import type { DetectedEvent } from "../core/detectors/types";
import { buildScannerMatches } from "../core/match-builder";
import type { ScannerMatch } from "../core/scanner-match";
/**
* GET query params ride the request line, and servers/proxies commonly cap
* that around 8-16 KB. A VoD long enough to blow past this needs a POST
* flow, which /vods/new doesn't offer yet — surface that instead of
* emitting a URL the server would reject with an opaque error.
*/
const MAX_URL_LENGTH = 8000;
/** The prefill default for a match whose mode no source read. */
const DEFAULT_VOD_MODE = "SZ" satisfies ModeShort;
export interface SendouUpload {
/** prefilled /vods/new path (same-origin); null when nothing usable to send */
url: string | null;
/** set when matches exist but no usable URL could be built */
problem: string | null;
}
/** Builds the prefilled /vods/new link for a completed scan's events. */
export function sendouUpload(events: readonly DetectedEvent[]): SendouUpload {
const matches = buildScannerMatches(events)
.map((built) => built.match)
.filter((match) => match.teams.some((team) => team.players.length > 0));
if (matches.length === 0) return { url: null, problem: null };
const isCast = matches.some((match) => match.cast);
const payload: IngestVodPrefill = {
...(isCast ? { type: "CAST" as const } : null),
matches: matches.map(toPrefillMatch),
};
const result = vodsNewSearchParams.href(newVodPage(), { ingest: payload });
if (result.length > MAX_URL_LENGTH) {
return {
url: null,
problem:
`prefill URL is ${result.length} chars (limit ~${MAX_URL_LENGTH}) — ` +
"too many matches for a GET query param.",
};
}
return { url: result, problem: null };
}
function toPrefillMatch(match: ScannerMatch): IngestVodMatchInput {
return {
startsAt: match.startsAt ?? 0,
mode: match.mode ?? DEFAULT_VOD_MODE,
modeAssumed: match.mode === null,
stage: match.stage,
// /vods/new splits this at a fixed 4 slots per team, so pad short rosters
weapons: match.teams.flatMap((team) =>
Array.from({ length: 4 }, (_, i) => team.players[i]?.weaponId ?? null),
),
povWeapon: povWeaponId(match),
};
}
function povWeaponId(match: ScannerMatch): MainWeaponId | undefined {
if (!match.pov) return undefined;
return (
match.teams[match.pov.team].players[match.pov.index]?.weaponId ?? undefined
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
/** Small JPEG data-URL thumbnail of an analyzed frame, for feed cards. */
export async function thumbnailFromBlob(frame: Blob): Promise<string> {
const bitmap = await createImageBitmap(frame);
const canvas = document.createElement("canvas");
canvas.width = 320;
canvas.height = 180;
canvas.getContext("2d")!.drawImage(bitmap, 0, 0, 320, 180);
bitmap.close();
return canvas.toDataURL("image/jpeg", 0.7);
}

View File

@@ -0,0 +1,97 @@
/**
* Connect death events to scoreboard players: a death overlay shows the
* killer's splash-tag name, weapon, and full gear-ability grid, so every
* death in a match reveals one enemy player's build. Deaths are attributed
* to the next scoreboard-type event in the timeline (a match's deaths
* always precede its results screen), and matched to a player row by name
* and weapon id.
*/
import type {
AbilityWithUnknown,
MainWeaponId,
} from "~/modules/in-game-lists/types";
import { DEATH_EVENT_TYPE, type DeathData } from "./detectors/death/index";
import { SCOREBOARD_EVENT_TYPES } from "./detectors/registry";
import type { ScoreboardData } from "./detectors/scoreboard/index";
import type { DetectedEvent } from "./detectors/types";
/** player row index (0-7) → [head, clothes, shoes] ability-id rows */
export type PlayerAbilityMap = Map<number, AbilityWithUnknown[][]>;
/** Any player row a death can be attributed against (scoreboard, minimap). */
interface HarvestablePlayer {
name: string | null;
weaponId: MainWeaponId | null;
}
/**
* Match a death's killer to a player row. Both signals are OCR output, so
* neither is trusted alone unless it is unambiguous: a combined name+weapon
* hit wins, then a unique name hit, then a unique weapon hit (two players on
* the same weapon with a misread name stay unattributed).
*/
function matchPlayer(
players: readonly HarvestablePlayer[],
death: DeathData,
): number | null {
const name = death.name?.trim().toLowerCase() || null;
const indices = players.map((_, i) => i);
const byName = name
? indices.filter(
(i) => (players[i]!.name ?? "").trim().toLowerCase() === name,
)
: [];
// scoreboard rows carry main-weapon ids; a sub/special credit says
// nothing about which main the killer holds
const byWeapon =
death.weaponId !== null && death.weaponType === "MAIN"
? indices.filter((i) => players[i]!.weaponId === death.weaponId)
: [];
const both = byName.filter((i) => byWeapon.includes(i));
if (both.length > 0) return both[0]!;
if (byName.length === 1) return byName[0]!;
if (byWeapon.length === 1) return byWeapon[0]!;
return null;
}
/**
* Harvest the builds one match's death events reveal: each death with a
* readable ability grid is attributed to a scoreboard player row.
*/
export function harvestAbilities(
players: readonly HarvestablePlayer[],
deaths: readonly DeathData[],
): PlayerAbilityMap {
const abilities: PlayerAbilityMap = new Map();
for (const death of deaths) {
if (death.abilities.length === 0) continue;
const index = matchPlayer(players, death);
if (index !== null) abilities.set(index, death.abilities);
}
return abilities;
}
/**
* For each scoreboard/replay event, harvest abilities from the death events
* since the previous scoreboard. Keyed by event object identity; events
* without any attributed death are absent from the result.
*/
export function connectAbilities(
events: readonly DetectedEvent[],
): Map<DetectedEvent, PlayerAbilityMap> {
const sorted = events.toSorted((a, b) => a.t - b.t);
const result = new Map<DetectedEvent, PlayerAbilityMap>();
let pendingDeaths: DeathData[] = [];
for (const event of sorted) {
if (event.type === DEATH_EVENT_TYPE) {
pendingDeaths.push(event.data as DeathData);
} else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) {
const players = (event.data as ScoreboardData).players;
const abilities = harvestAbilities(players, pendingDeaths);
if (abilities.size > 0) result.set(event, abilities);
pendingDeaths = [];
}
}
return result;
}

View File

@@ -0,0 +1,14 @@
/**
* Pure constants/types shared by UI and pipeline. No OpenCV dependency —
* the main-thread bundle must not pull in the WASM module (that lives in
* the worker).
*/
export const CANONICAL_WIDTH = 1920;
export const CANONICAL_HEIGHT = 1080;
export interface Roi {
x: number;
y: number;
w: number;
h: number;
}

View File

@@ -0,0 +1,90 @@
/**
* OpenCV.js singleton loader. Works in Node, browser main thread, and workers —
* the UMD bundle embeds its WASM, so no asset paths are involved.
*
* Everything in core/ obtains the cv namespace through getCV(); callers must
* await loadOpenCV() once at startup (worker bootstrap, test setup, tool entry).
*
* Gotcha of this build (5.0.0-release.1): `.data` and `.clone()` are broken
* on ROI views — `view.copyTo(freshMat)` before pixel access. Views are fine
* as inputs to cv calls.
*/
import cvModule from "@techstark/opencv-js";
export type CV = typeof cvModule;
export type Mat = InstanceType<CV["Mat"]>;
let cvInstance: CV | null = null;
let loading: Promise<CV> | null = null;
export function loadOpenCV(): Promise<CV> {
if (cvInstance) return Promise.resolve(cvInstance);
if (loading) return loading;
const attempt = (async () => {
// The package is patched (patches/@techstark__opencv-js…) to export
// { cvReadyPromise } instead of the bare ready-promise: a thenable (or
// default-wrapped thenable) module.exports leaks `then` through
// vite-node's CJS namespace proxy and crashes every Node-side import.
// Depending on bundler interop we see the wrapper (possibly nested
// under `default`) or, in bundles that took the UMD's non-CJS branch,
// the promise itself.
const raw = cvModule as {
cvReadyPromise?: unknown;
default?: { cvReadyPromise?: unknown };
} | null;
const mod: unknown =
raw?.cvReadyPromise ??
raw?.default?.cvReadyPromise ??
raw?.default ??
raw;
let cv: CV;
if (mod instanceof Promise) {
cv = await mod;
} else if ((mod as CV).Mat) {
cv = mod as CV;
} else {
await new Promise<void>((resolve) => {
(mod as { onRuntimeInitialized?: () => void }).onRuntimeInitialized =
resolve;
});
cv = mod as CV;
}
cvInstance = cv;
return cv;
})();
// a failed load must not poison the singleton — clear it so callers can retry
loading = attempt.catch((error) => {
loading = null;
throw error;
});
return loading;
}
export function getCV(): CV {
if (!cvInstance) {
throw new Error(
"OpenCV not loaded — await loadOpenCV() before using core/",
);
}
return cvInstance;
}
// The bundled type definitions mark the optional mask argument of these as
// required; thin wrappers restore the real (mask-less) signatures.
export interface MinMaxResult {
minVal: number;
maxVal: number;
minLoc: { x: number; y: number };
maxLoc: { x: number; y: number };
}
export function minMaxLoc(mat: Mat): MinMaxResult {
return (getCV() as unknown as { minMaxLoc(m: Mat): MinMaxResult }).minMaxLoc(
mat,
);
}
export function meanOf(mat: Mat): number[] {
return (getCV() as unknown as { mean(m: Mat): number[] }).mean(mat);
}

View File

@@ -0,0 +1,80 @@
/**
* Ability badge templates for the death-screen gear panel.
*
* Committed icon assets (assets/cv/abilities/) are bare icon art with
* alpha; on screen each sits centered on a near-black circular badge
* filling a known fraction of the slot. Templates composite the art onto a
* black square at that fraction, then resize to the candidate badge sizes —
* shaped like weapon templates so matchWeapon's NCC + ink-coverage scoring
* (see weapons.ts) applies directly. Mains (⌀~68) and subs (⌀~48) use
* different art fractions, so each role gets its own template set.
*/
import { getCV } from "../../cv";
import type { FrameData } from "../../image";
import { buildTemplateSizes, type WeaponTemplate } from "../scoreboard/weapons";
import {
ABILITY_INK_THRESHOLD,
ABILITY_MAIN_ART_RATIO,
ABILITY_MAIN_SIZES,
ABILITY_SUB_ART_RATIO,
ABILITY_SUB_SIZES,
} from "./rois";
/** Badge interior brightness (near-black circle on the dark panel). */
const BADGE_BACKGROUND = 10;
export interface AbilityTemplates {
mains: WeaponTemplate[];
subs: WeaponTemplate[];
}
/**
* Exported for the calibration tooling's art-ratio sweeps and for other
* screens' badge sets (scoreboard-own builds its sizes/threshold here).
* inkThreshold must match the one passed to matchWeapon, or the coverage
* ratio compares mismatched ink counts.
*/
export function buildAbilityRole(
icons: { id: string; image: FrameData }[],
sizes: readonly number[],
artRatio: number,
inkThreshold: number = ABILITY_INK_THRESHOLD,
): WeaponTemplate[] {
const cv = getCV();
return icons.map(({ id, image }) => {
// composite the icon art over the badge black, padded so the art
// occupies artRatio of the square (as it does of the badge on screen)
const side = Math.round(image.width / artRatio);
const offset = Math.floor((side - image.width) / 2);
const padded = new cv.Mat(
side,
side,
cv.CV_8UC3,
new cv.Scalar(BADGE_BACKGROUND, BADGE_BACKGROUND, BADGE_BACKGROUND),
);
const dst = padded.data;
const src = image.data;
for (let y = 0; y < image.height; y++) {
for (let x = 0; x < image.width; x++) {
const si = (y * image.width + x) * 4;
const a = src[si + 3]! / 255;
const di = ((y + offset) * side + x + offset) * 3;
dst[di] = Math.round(src[si]! * a + BADGE_BACKGROUND * (1 - a));
dst[di + 1] = Math.round(src[si + 1]! * a + BADGE_BACKGROUND * (1 - a));
dst[di + 2] = Math.round(src[si + 2]! * a + BADGE_BACKGROUND * (1 - a));
}
}
const templateSizes = buildTemplateSizes(padded, sizes, inkThreshold);
padded.delete();
return { id, sizes: templateSizes };
});
}
export function prepareAbilityTemplates(
icons: { id: string; image: FrameData }[],
): AbilityTemplates {
return {
mains: buildAbilityRole(icons, ABILITY_MAIN_SIZES, ABILITY_MAIN_ART_RATIO),
subs: buildAbilityRole(icons, ABILITY_SUB_SIZES, ABILITY_SUB_ART_RATIO),
};
}

View File

@@ -0,0 +1,832 @@
/**
* DeathDetector: parses the death cam overlay — "Splatted by <weapon>!"
* text, the killer's gear abilities (3 rows x [main, sub, sub, sub]), and
* the killer's name from the tilted splash tag.
*
* Weapon reads primarily as OCR text matched against per-language message
* templates (localized-messages.ts); the constant line doubles as a
* parse-time confirmation (else a lookalike gate hit emits nothing). Falls
* back to template-matching the burst weapon icon when the WIPEOUT banner
* covers the text, then to a candidate-lattice re-rank for low-fidelity
* captures, then to icon/text corroboration when neither is decisive alone
* (steps 2c/2d).
*/
import type {
AbilityWithUnknown,
MainWeaponId,
SpecialWeaponId,
SubWeaponId,
} from "~/modules/in-game-lists/types";
import { toAbilityWithUnknown } from "../../../scanner-types";
import { getCV, type Mat, minMaxLoc } from "../../cv";
import {
type GlyphSet,
type RecognizedText,
recognizeText,
scaleGlyphSet,
} from "../../glyphs";
import { copyRoi, cropRoi, meanBrightness, type Roi } from "../../image";
import { closestEntry, matchKey, rankBy, rankByRead } from "../../text";
import type { ScoreboardResources } from "../scoreboard/index";
import { parseName } from "../scoreboard/names";
import { matchWeapon, type WeaponMatch } from "../scoreboard/weapons";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
DEATH_MESSAGE_TEMPLATES,
type DeathMessageTemplate,
LOCALIZED_WEAPON_NAMES,
} from "./localized-messages";
import {
ABILITY_INK_THRESHOLD,
ABILITY_ROWS,
ABILITY_SLOT_MIN_INK,
ABILITY_SUB_XS,
abilityMainRoi,
abilitySubRoi,
BURST_ICON_ROI,
GATE_BURST_PROBES,
GATE_DARK_MAX_MEAN,
GATE_ICON_MIN_MAX,
GATE_PANEL_PROBES,
GATE_TEXT_MAX_FRACTION,
GATE_TEXT_MIN_MAX,
gateAbilityProbe,
JA_CONST_LINE_ROI,
JA_WEAPON_LINE_ROI,
SPLAT_LINE1_ROI,
SPLAT_TEXT_BIN_THRESHOLD,
TAG_NAME_INNER,
TAG_NAME_OUTER,
TAG_NAME_TEXT_HEIGHT,
TAG_TILT_DEG,
WEAPON_LINE_ROI,
WEAPON_TEXT_HEIGHT,
} from "./rois";
import {
ALL_WEAPON_ENTRIES,
type WeaponEntry,
type WeaponType,
} from "./weapon-names";
export interface DeathData {
/**
* the killer's weapon id — a sendou main/sub/special weapon id, unique
* only within its kind (`weaponType` disambiguates); null if unreadable
*/
weaponId: MainWeaponId | SubWeaponId | SpecialWeaponId | null;
/** which kind of weapon got the splat; null when the weapon is unreadable */
weaponType: WeaponType | null;
/**
* killer's gear abilities, [head, clothes, shoes] rows of [main, sub...]
* ability ids; rows carry as many sub entries as the gear has slots (1-3)
*/
abilities: AbilityWithUnknown[][];
/** killer's splash-tag name; null if unreadable */
name: string | null;
}
export const DEATH_EVENT_TYPE = "Death";
/** The constant message line must read back at least this well to emit. */
const LINE1_MIN_SCORE = 0.5;
/**
* A Latin template's constant line reading at least this well settles the
* language and the (expensive) JA line reads are skipped. Fixture-measured:
* Latin-language frames read their template at 0.889+, while JA frames'
* best Latin-template score is 0.222.
*/
const LATIN_DECISIVE_SCORE = 0.85;
/** Snapped weapon reading below this is reported as null (kept in debug). */
const WEAPON_MIN_SCORE = 0.55;
/** Burst-icon fallback match below this is ignored (kept in debug). */
const BURST_ICON_MIN_SCORE = 0.52;
/**
* Candidate-lattice re-rank acceptance (rankByRead — its scores sit well
* below the plain-snap scale; see text.ts). On the 720p-upscaled JP
* frames that motivated it, correct picks score 0.25-0.47 with a margin
* of 0.056+ over the nearest other weapon, while wrong picks margin
* <= 0.03 — the margin, not the score, is the discriminator.
*/
const LATTICE_MIN_SCORE = 0.22;
const LATTICE_MIN_MARGIN = 0.05;
/**
* Burst-icon corroboration: below the decisive threshold the icon alone
* can't be trusted, but when the garbled text *independently* ranks the
* icon's weapon at (or within EPS of) its own top, the two weak signals
* agree out of ~350 candidates and the weapon is accepted. Floor sits at
* the weakest corroborated fixture positive (Splat Dualies at 0.33).
*/
const BURST_ICON_CORROBORATE_MIN_SCORE = 0.3;
const CORROBORATE_EPS = 0.02;
const TAG_NAME_BIN_THRESHOLD = 160;
/**
* The text-color refinement band is absolute closeness (255 - distance) to
* the estimated text color, so its threshold is tight by construction:
* 215 keeps pixels within 40 of the text color — glyph cores — while art
* highlights that leak past the banner-median band sit further away.
*/
const TAG_NAME_REFINE_BIN_THRESHOLD = 215;
/** Don't trust a text-color estimate taken from fewer ink pixels. */
const TAG_NAME_REFINE_MIN_INK = 200;
/**
* Split-banner detection: some banners paint the name band in two flat
* hues (diagonal splits). Any single background estimate turns the other
* half into one huge "ink" blob that border-clearing deletes together
* with the glyphs standing on it, so when a second quantized color bin
* both covers a real share of the band and sits far from the first, a
* third read candidate measures distance from the *nearest* of the two.
* The share floor keeps the text color itself (or sparse art) from being
* mistaken for a second background, which would erase the glyphs.
*/
const TAG_SPLIT_MIN_FRACTION = 0.15;
const TAG_SPLIT_MIN_CHANNEL_DISTANCE = 40;
interface WeaponCandidate {
/** the full weapon line as this template renders it, e.g. "Durch Klecksroller" */
text: string;
entry: WeaponEntry;
}
/** JA templates read through the JA atlas and the swapped-width line ROIs. */
function isJaTemplate(t: DeathMessageTemplate): boolean {
return t.langs.some((lang) => lang.endsWith("ja"));
}
/**
* The weapon-line strings a template can show: that language's localized
* names plus every canonical English name (localized-messages omits names
* identical to English), wrapped in the template's constant pre/post text.
*/
const templateCandidates = new Map<DeathMessageTemplate, WeaponCandidate[]>();
function candidatesFor(template: DeathMessageTemplate): WeaponCandidate[] {
let candidates = templateCandidates.get(template);
if (candidates) return candidates;
const byName = new Map(ALL_WEAPON_ENTRIES.map((e) => [e.name, e]));
const seen = new Set<string>();
candidates = [];
const push = (text: string, entry: WeaponEntry | undefined) => {
const k = matchKey(text);
if (!entry || seen.has(k)) return;
seen.add(k);
candidates!.push({
text: template.weaponPre + text + template.weaponPost,
entry,
});
};
for (const lang of template.langs) {
for (const { text, name } of LOCALIZED_WEAPON_NAMES[lang] ?? [])
push(text, byName.get(name));
}
for (const entry of ALL_WEAPON_ENTRIES) push(entry.name, entry);
templateCandidates.set(template, candidates);
return candidates;
}
export function createDeathDetector(
resources: ScoreboardResources,
): Detector<DeathData> {
const cv = getCV();
const scaled = (
set: GlyphSet | null | undefined,
height: number,
): GlyphSet | null => (set ? scaleGlyphSet(set, height / set.height) : null);
const weaponGlyphs = scaled(resources.deathWeaponGlyphs, WEAPON_TEXT_HEIGHT);
// JA glyphs match at native scale: the atlas mixes fixture crops with
// per-face renders already sized to the on-screen condensed text
const jaGlyphs = resources.deathWeaponJaGlyphs ?? null;
const tagNameGlyphs = scaled(
resources.deathTagNameGlyphs,
TAG_NAME_TEXT_HEIGHT,
);
const abilities = resources.abilities ?? null;
const burstWeapons = resources.deathBurstWeapons ?? null;
const mainById = new Map(
ALL_WEAPON_ENTRIES.filter((e) => e.type === "MAIN").map((e) => [e.id, e]),
);
function gate(frame: Mat): GateResult {
let darkOk = 0;
const darkProbes = [...GATE_BURST_PROBES, ...GATE_PANEL_PROBES];
for (const roi of darkProbes) {
if (meanBrightness(frame, roi) < GATE_DARK_MAX_MEAN) darkOk++;
}
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const line1 = copyRoi(gray, SPLAT_LINE1_ROI);
const { maxVal } = minMaxLoc(line1);
const bin = new cv.Mat();
cv.threshold(line1, bin, GATE_TEXT_MIN_MAX, 255, cv.THRESH_BINARY);
line1.delete();
const whiteFraction = cv.countNonZero(bin) / (bin.rows * bin.cols);
bin.delete();
const textOk =
maxVal > GATE_TEXT_MIN_MAX &&
whiteFraction > 0.01 &&
whiteFraction < GATE_TEXT_MAX_FRACTION;
// max RGB channel, not gray: saturated icon art can be gray-dark (rois.ts)
let iconOk = 0;
for (const row of [0, 1, 2]) {
const probe = copyRoi(frame, gateAbilityProbe(row));
const d = probe.data;
const ch = probe.channels();
const n = probe.rows * probe.cols;
let maxCh = 0;
for (let i = 0; i < n; i++) {
const v = Math.max(d[i * ch]!, d[i * ch + 1]!, d[i * ch + 2]!);
if (v > maxCh) maxCh = v;
}
probe.delete();
if (maxCh > GATE_ICON_MIN_MAX) iconOk++;
}
gray.delete();
const score =
(darkOk / darkProbes.length + (textOk ? 1 : 0) + iconOk / 3) / 3;
return {
pass: darkOk === darkProbes.length && textOk && iconOk === 3,
score,
};
}
/** Crop the tilted tag, rotate it level, and return the name band crop. */
function levelTagInner(rgb: Mat): Mat {
const outer = copyRoi(rgb, TAG_NAME_OUTER);
const center = new cv.Point(outer.cols / 2, outer.rows / 2);
const m = cv.getRotationMatrix2D(center, -TAG_TILT_DEG, 1);
const rotated = new cv.Mat();
cv.warpAffine(
outer,
rotated,
m,
new cv.Size(outer.cols, outer.rows),
cv.INTER_LINEAR,
cv.BORDER_REPLICATE,
new cv.Scalar(),
);
m.delete();
outer.delete();
const inner = copyRoi(rotated, TAG_NAME_INNER);
rotated.delete();
return inner;
}
/** Per-channel median color of `inner`, over pixels where mask(i) holds. */
function medianColor(
inner: Mat,
mask?: (i: number) => boolean,
): [number, number, number] {
const n = inner.rows * inner.cols;
const px = inner.data;
const color: [number, number, number] = [0, 0, 0];
for (let c = 0; c < 3; c++) {
const hist = new Array<number>(256).fill(0);
let total = 0;
for (let i = 0; i < n; i++) {
if (mask && !mask(i)) continue;
hist[px[i * 3 + c]!]!++;
total++;
}
let acc = 0;
let v = 0;
for (; v < 255; v++) {
acc += hist[v]!;
if (acc >= total / 2) break;
}
color[c] = v;
}
return color;
}
/**
* Dominant colors of `inner`: most frequent quantized colors (5 bits/
* channel), clustered by proximity (else a hue straddling a quantization
* boundary under-reports, split across neighbor bins), each refined to
* its per-channel median with its share of the band. A second background
* estimator besides medianColor — neither wins everywhere (whole-image
* medians blend distinct populations into a color nobody has; the bin
* vote loses to a flat art blob out-voting a textured banner's true
* color) — so both are tried and the better read kept. The runner-up
* cluster feeds the split-banner candidate (see TAG_SPLIT_MIN_FRACTION).
*/
function dominantColors(
inner: Mat,
count: number,
): { color: [number, number, number]; fraction: number }[] {
const n = inner.rows * inner.cols;
const px = inner.data;
const bins = new Map<number, number>();
for (let i = 0; i < n; i++) {
const key =
((px[i * 3]! >> 3) << 10) |
((px[i * 3 + 1]! >> 3) << 5) |
(px[i * 3 + 2]! >> 3);
bins.set(key, (bins.get(key) ?? 0) + 1);
}
// greedy cluster of the top bins by quantized-center proximity
const CLUSTER_MAX_CHANNEL_DISTANCE = 24;
const top = [...bins.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
const centerOf = (key: number): [number, number, number] => [
((key >> 10) << 3) + 4,
(((key >> 5) & 31) << 3) + 4,
((key & 31) << 3) + 4,
];
const clusters: {
seed: [number, number, number];
keys: Set<number>;
count: number;
}[] = [];
for (const [key, binCount] of top) {
const c = centerOf(key);
const home = clusters.find((cl) =>
cl.seed.every(
(s, i) => Math.abs(s - c[i]!) <= CLUSTER_MAX_CHANNEL_DISTANCE,
),
);
if (home) {
home.keys.add(key);
home.count += binCount;
} else {
clusters.push({ seed: c, keys: new Set([key]), count: binCount });
}
}
return clusters
.sort((a, b) => b.count - a.count)
.slice(0, count)
.map(({ keys, count: clusterCount }) => {
const inCluster = (i: number) =>
keys.has(
((px[i * 3]! >> 3) << 10) |
((px[i * 3 + 1]! >> 3) << 5) |
(px[i * 3 + 2]! >> 3),
);
return {
color: medianColor(inner, inCluster),
fraction: clusterCount / n,
};
});
}
/**
* Text-ness map: per-pixel max-channel distance from the nearest of
* `colors` (one for solid banners, two hues for a split banner). Uses
* distance from banner color rather than a fixed brightness/polarity
* since text/banner colors vary per player (e.g. pink text on a
* light-blue banner has near-zero luminance contrast). `invert` flips to
* *closeness* for the text-color refinement pass.
*/
function distanceBand(
inner: Mat,
colors: readonly [number, number, number][],
invert: boolean,
): Mat {
const n = inner.rows * inner.cols;
const px = inner.data;
const band = new cv.Mat(inner.rows, inner.cols, cv.CV_8UC1);
const out = band.data;
for (let i = 0; i < n; i++) {
let d = 255;
for (const color of colors) {
const dc = Math.max(
Math.abs(px[i * 3]! - color[0]),
Math.abs(px[i * 3 + 1]! - color[1]),
Math.abs(px[i * 3 + 2]! - color[2]),
);
if (dc < d) d = dc;
}
out[i] = invert ? 255 - d : d;
}
return band;
}
/**
* Zero ink components touching the band border. Busy banner art also
* differs from the median banner color but continues past the name
* band's edges, while the name sits inside it (fixture extremes: dakuten
* at y=3, descender 2px above bottom) — left in place an edge blob
* merges into a glyph and corrupts the read.
*/
function clearBorderBlobs(band: Mat, threshold: number): void {
const bin = new cv.Mat();
cv.threshold(band, bin, threshold, 255, cv.THRESH_BINARY);
const labels = new cv.Mat();
const stats = new cv.Mat();
const centroids = new cv.Mat();
const count = cv.connectedComponentsWithStats(
bin,
labels,
stats,
centroids,
8,
);
bin.delete();
centroids.delete();
const s = stats.data32S;
const touchesBorder = new Uint8Array(count);
for (let i = 1; i < count; i++) {
const left = s[i * 5 + cv.CC_STAT_LEFT]!;
const top = s[i * 5 + cv.CC_STAT_TOP]!;
const right = left + s[i * 5 + cv.CC_STAT_WIDTH]!;
const bottom = top + s[i * 5 + cv.CC_STAT_HEIGHT]!;
touchesBorder[i] =
left === 0 || top === 0 || right === band.cols || bottom === band.rows
? 1
: 0;
}
stats.delete();
const lab = labels.data32S;
const out = band.data;
for (let i = 0; i < out.length; i++) {
if (touchesBorder[lab[i]!]!) out[i] = 0;
}
labels.delete();
}
function parse(frame: Mat, t: number): DetectedEvent<DeathData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const confidences: number[] = [];
// 1. read both burst lines and find the language template whose constant
// line reads back best — a gate hit matching none is a lookalike.
// Latin templates read the standard line boxes with the Latin atlas;
// JA templates read the swapped-width JA boxes (weapon line 1 wide,
// constant line 2 narrow — see rois.ts) with the JA atlas, so the two
// scripts never compete inside one glyph set.
let line1: RecognizedText | null = null;
let line2: RecognizedText | null = null;
let jaWeaponLine: RecognizedText | null = null;
let jaConstLine: RecognizedText | null = null;
let template: DeathMessageTemplate | null = null;
let line1Score = 0;
if (weaponGlyphs) {
const readLine = (roi: Roi, glyphs: GlyphSet) => {
const crop = cropRoi(gray, roi);
const read = recognizeText(crop, glyphs, {
binThreshold: SPLAT_TEXT_BIN_THRESHOLD,
minCharScore: 0.3,
});
crop.delete();
return read;
};
line1 = readLine(SPLAT_LINE1_ROI, weaponGlyphs);
line2 = readLine(WEAPON_LINE_ROI, weaponGlyphs);
for (const t of DEATH_MESSAGE_TEMPLATES) {
if (isJaTemplate(t)) continue;
const constReading = t.weaponLine === 1 ? line2.text : line1.text;
const score = closestEntry(constReading, [t.constText])?.score ?? 0;
if (score > line1Score) {
line1Score = score;
template = t;
}
}
// the JA line reads cost ~2x the Latin ones (condensed-kana atlas),
// so they only run when no Latin template already owns the frame:
// measured constant-line scores separate cleanly (Latin frames read
// their template at 0.889+, JA frames' best Latin score is <= 0.222)
if (jaGlyphs && line1Score < LATIN_DECISIVE_SCORE) {
jaWeaponLine = readLine(JA_WEAPON_LINE_ROI, jaGlyphs);
jaConstLine = readLine(JA_CONST_LINE_ROI, jaGlyphs);
for (const t of DEATH_MESSAGE_TEMPLATES) {
if (!isJaTemplate(t)) continue;
const score =
closestEntry(jaConstLine.text, [t.constText])?.score ?? 0;
if (score > line1Score) {
line1Score = score;
template = t;
}
}
}
if (!template || line1Score < LINE1_MIN_SCORE) {
gray.delete();
rgb.delete();
return [];
}
}
// 2. the other line carries the weapon name; snap it to the template
// language's names (localized + canonical English)
let weapon: string | null = null;
let weaponId: DeathData["weaponId"] = null;
let weaponType: WeaponType | null = null;
let weaponScore = 0;
let weaponRaw: RecognizedText | null = null;
let plainRanked: { entry: WeaponCandidate; score: number }[] = [];
const accept = (entry: WeaponEntry, score: number) => {
weapon = entry.name;
weaponId = Number(entry.id) as NonNullable<DeathData["weaponId"]>;
weaponType = entry.type;
weaponScore = score;
};
if (weaponGlyphs && template) {
weaponRaw = isJaTemplate(template)
? jaWeaponLine
: template.weaponLine === 1
? line1
: line2;
const reading = weaponRaw!.text;
if (reading)
plainRanked = rankBy(reading, candidatesFor(template), (c) => c.text);
const match = plainRanked[0];
if (match) {
weaponScore = match.score;
if (match.score >= WEAPON_MIN_SCORE)
accept(match.entry.entry, match.score);
}
}
// 2b. text can be unreadable while the burst's weapon icon is intact
// (the WIPEOUT banner covers the weapon name line), so fall back to
// matching the icon against the main-weapon set at burst size. Only a
// decisive match is trusted: fixture positives score 0.55+ while the
// best off-target frame (icon displaced by a rainmaker line) hits 0.48.
let burstIcon: WeaponMatch | null = null;
if (weapon === null && burstWeapons) {
const crop = cropRoi(rgb, BURST_ICON_ROI);
burstIcon = matchWeapon(crop, burstWeapons);
crop.delete();
const entry =
burstIcon.score >= BURST_ICON_MIN_SCORE
? mainById.get(burstIcon.id)
: undefined;
if (entry) accept(entry, burstIcon.score);
}
// 2c. low-fidelity captures (720p upscaled to canonical) garble the
// per-segment top-1 read enough that the plain snap stays under
// WEAPON_MIN_SCORE, while the correct glyphs sit at rank 2-3 of the
// segments' candidate lists. Re-rank through those lists (rankByRead)
// and accept the top weapon when it clears the field decisively.
let latticeTop: {
entry: WeaponEntry;
score: number;
margin: number;
} | null = null;
let latticeRanked: { entry: WeaponCandidate; score: number }[] = [];
if (
weapon === null &&
weaponGlyphs &&
template &&
weaponRaw!.chars.length > 0
) {
latticeRanked = rankByRead(
weaponRaw!.chars,
candidatesFor(template),
(c) => c.text,
);
const top = latticeRanked[0]!;
// margin vs the nearest *other* weapon: the same weapon rides both
// its localized and English candidate lines
const runner = latticeRanked.find(
(r) => r.entry.entry !== top.entry.entry,
);
latticeTop = {
entry: top.entry.entry,
score: top.score,
margin: top.score - (runner?.score ?? 0),
};
if (
latticeTop.score >= LATTICE_MIN_SCORE &&
latticeTop.margin >= LATTICE_MIN_MARGIN
) {
accept(latticeTop.entry, latticeTop.score);
}
}
// 2d. neither signal is decisive alone, but if the burst icon's main
// weapon is also the text's best guess (plain or lattice ranking,
// within EPS of that ranking's top), the independent agreement is
// decisive together.
if (
weapon === null &&
burstIcon &&
burstIcon.score >= BURST_ICON_CORROBORATE_MIN_SCORE
) {
const entry = mainById.get(burstIcon.id);
if (entry) {
const bestFor = (ranked: { entry: WeaponCandidate; score: number }[]) =>
ranked.reduce(
(s, r) => (r.entry.entry === entry ? Math.max(s, r.score) : s),
0,
);
const supported =
(plainRanked.length > 0 &&
bestFor(plainRanked) >= plainRanked[0]!.score - CORROBORATE_EPS) ||
(latticeRanked.length > 0 &&
bestFor(latticeRanked) >=
latticeRanked[0]!.score - CORROBORATE_EPS);
if (supported) accept(entry, burstIcon.score);
}
}
if (weaponGlyphs && template) confidences.push(weaponScore);
// 3. ability grid; rows carry 1-3 sub circles (left-aligned, as many
// as the gear has slots), so a sub box without badge ink ends the row
const abilityRows: AbilityWithUnknown[][] = [];
const abilityDebug: (WeaponMatch | null)[][] = [];
if (abilities) {
for (let row = 0; row < ABILITY_ROWS; row++) {
const ids: AbilityWithUnknown[] = [];
const debug: (WeaponMatch | null)[] = [];
const mainCrop = cropRoi(rgb, abilityMainRoi(row));
const main = matchWeapon(mainCrop, abilities.mains, {
inkThreshold: ABILITY_INK_THRESHOLD,
});
mainCrop.delete();
ids.push(toAbilityWithUnknown(main.id) ?? "UNKNOWN");
debug.push(main);
confidences.push(Math.max(0, main.score));
for (let slot = 0; slot < ABILITY_SUB_XS.length; slot++) {
const crop = copyRoi(rgb, abilitySubRoi(row, slot));
const d = crop.data;
const n = crop.rows * crop.cols;
let ink = 0;
for (let i = 0; i < n; i++) {
const v = Math.max(d[i * 3]!, d[i * 3 + 1]!, d[i * 3 + 2]!);
if (v > ABILITY_INK_THRESHOLD) ink++;
}
if (ink < ABILITY_SLOT_MIN_INK) {
crop.delete();
break;
}
const sub = matchWeapon(crop, abilities.subs, {
inkThreshold: ABILITY_INK_THRESHOLD,
});
crop.delete();
ids.push(toAbilityWithUnknown(sub.id) ?? "UNKNOWN");
debug.push(sub);
confidences.push(Math.max(0, sub.score));
}
abilityRows.push(ids);
abilityDebug.push(debug);
}
}
// 4. splash-tag name: read against the estimated banner color, then
// (since busy banner art also differs from that estimate and can
// survive as fake glyphs) against closeness to the text color
// estimated from pass 1's ink; whichever reads back more confidently
// wins. Both background estimators (dominantColors) run the same way.
let name: string | null = null;
let nameConfidence = 0;
let nameRaw = "";
let tagBackground: [number, number, number] | null = null;
let tagTextColor: [number, number, number] | null = null;
if (tagNameGlyphs) {
const spaceGap = Math.max(
7,
Math.round(tagNameGlyphs.medianWidth * 0.55),
);
const inner = levelTagInner(rgb);
const readWithBackground = (
backgrounds: readonly [number, number, number][],
) => {
const band = distanceBand(inner, backgrounds, false);
cv.normalize(band, band, 0, 255, cv.NORM_MINMAX);
clearBorderBlobs(band, TAG_NAME_BIN_THRESHOLD);
let parsed = parseName(band, tagNameGlyphs, {
spaceGap,
binThreshold: TAG_NAME_BIN_THRESHOLD,
});
let textColor: [number, number, number] | null = null;
const ink = band.data;
let inkCount = 0;
for (let i = 0; i < ink.length; i++)
if (ink[i]! > TAG_NAME_BIN_THRESHOLD) inkCount++;
if (inkCount >= TAG_NAME_REFINE_MIN_INK) {
textColor = medianColor(
inner,
(i) => ink[i]! > TAG_NAME_BIN_THRESHOLD,
);
const refined = distanceBand(inner, [textColor], true);
clearBorderBlobs(refined, TAG_NAME_REFINE_BIN_THRESHOLD);
const reparsed = parseName(refined, tagNameGlyphs, {
spaceGap,
binThreshold: TAG_NAME_REFINE_BIN_THRESHOLD,
});
refined.delete();
if (reparsed.confidence > parsed.confidence) parsed = reparsed;
}
band.delete();
return { parsed, background: backgrounds[0]!, textColor };
};
const median = medianColor(inner);
const dominants = dominantColors(inner, 2);
const dominant = dominants[0]!.color;
const candidates: [number, number, number][][] = [[median]];
if (dominant.some((c, i) => Math.abs(c - median[i]!) > 8))
candidates.push([dominant]);
const second = dominants[1];
if (
second &&
second.fraction >= TAG_SPLIT_MIN_FRACTION &&
second.color.some(
(c, i) => Math.abs(c - dominant[i]!) > TAG_SPLIT_MIN_CHANNEL_DISTANCE,
)
) {
candidates.push([dominant, second.color]);
}
// an empty read never beats one with glyphs (an estimate landing on
// the text color blanks the band, and recognizeText scores a
// segment-less band confidence 1); near-tied confidences resolve to
// the longer read, since confidence is the *min* char score and
// erasing most of the name can still read the survivors immaculately
const NEAR_TIE = 0.03;
const beats = (
a: { parsed: { name: string; confidence: number } },
b: typeof a,
) => {
const aRead = a.parsed.name.length > 0 ? 1 : 0;
const bRead = b.parsed.name.length > 0 ? 1 : 0;
if (aRead !== bRead) return aRead - bRead;
if (Math.abs(a.parsed.confidence - b.parsed.confidence) <= NEAR_TIE) {
return a.parsed.name.length - b.parsed.name.length;
}
return a.parsed.confidence - b.parsed.confidence;
};
let best = readWithBackground(candidates[0]!);
for (const backgrounds of candidates.slice(1)) {
const alt = readWithBackground(backgrounds);
if (beats(alt, best) > 0) best = alt;
}
inner.delete();
tagBackground = best.background;
tagTextColor = best.textColor;
nameRaw = best.parsed.raw.text;
if (best.parsed.name.length > 0) name = best.parsed.name;
nameConfidence = best.parsed.confidence;
confidences.push(nameConfidence);
}
gray.delete();
rgb.delete();
const confidence =
confidences.length > 0
? confidences.reduce((a, b) => a + b, 0) / confidences.length
: 0;
return [
{
type: DEATH_EVENT_TYPE,
t,
confidence,
data: { weaponId, weaponType, abilities: abilityRows, name },
debug: {
weaponName: weapon,
line1: line1?.text,
line2: line2?.text,
jaWeaponLine: jaWeaponLine?.text,
jaConstLine: jaConstLine?.text,
line1Score,
messageLangs: template?.langs,
weaponRaw: weaponRaw?.text,
weaponScore,
weaponLattice: latticeTop && {
name: latticeTop.entry.name,
score: latticeTop.score,
margin: latticeTop.margin,
},
burstIcon: burstIcon && {
id: burstIcon.id,
score: burstIcon.score,
top: burstIcon.top,
},
abilityRows: abilityDebug.map((row) =>
row.map((m) => m && { top: m.top, score: m.score }),
),
nameRaw,
nameScore: nameConfidence,
tagBackground,
tagTextColor,
},
},
];
}
// the death cam's animated background flickers the gate; after a
// sufficient read the 4s rearm hold stays inside the timeline's 8s
// Death merge window, so every parse it skips would merge anyway.
// sufficientConfidence sits just under the measured clean-read floor
// (fixtures 0.750-0.825, confirmed scan events 0.751+); the refine and
// stagnation overrides cap what a ~1.4s parse can cost when a dirty
// read never reaches it
return {
id: "death",
refineIntervalS: 0.5,
sufficientConfidence: 0.74,
rearmCooldownS: 4,
maxStagnantParses: 3,
gate,
parse,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,146 @@
/**
* ALL death-screen ROI coordinates, in canonical 1920x1080 space.
* Calibrated against the death/ fixtures via tools/dump-crops.ts,
* HoughCircles measurement, and bright-row profiling.
*
* The death cam overlays three fixed elements on live gameplay: a dark
* camo "splat burst" top-center (two-line "Splatted by" / "<weapon>!"
* message); the killer's gear panel bottom-left (three gear rows, each
* with one main-ability circle ⌀~68 plus three sub circles ⌀~48,
* divided by dashed lines); and the killer's splash tag bottom-right,
* tilted, name in large type. Probes sit in the overlays' opaque-dark
* parts (everything but the tag banner sits over the live scene).
*/
import type { Roi } from "../../canonical";
/** The constant "Splatted by" line (white text centered at x=960). */
export const SPLAT_LINE1_ROI: Roi = { x: 790, y: 362, w: 340, h: 52 };
/** The weapon name line ("Rapid Blaster Deco!"), centered, length varies. */
export const WEAPON_LINE_ROI: Roi = { x: 640, y: 414, w: 640, h: 48 };
/** White message text on the dark burst binarizes cleanly and high. */
export const SPLAT_TEXT_BIN_THRESHOLD = 190;
/** Tight cap height of the message text (atlas nominal height). */
export const WEAPON_TEXT_HEIGHT = 34;
/**
* Non-Latin weaponLine=1 langs (JA: "<weapon> で" / "やられた!") swap line
* widths: variable name gets the full-width box, the short constant line
* gets a narrow one. Both boxes run taller than Latin: kana overshoot the
* cap band both sides (JP line spans y=359..401 vs Latin's y=362 start).
*/
export const JA_WEAPON_LINE_ROI: Roi = { x: 640, y: 354, w: 640, h: 62 };
export const JA_CONST_LINE_ROI: Roi = { x: 790, y: 412, w: 340, h: 54 };
/**
* Killer's weapon icon, upright above the message text (~110px art;
* specials render team-tinted, unmatched). Templates at
* BURST_ICON_TEMPLATE_SIZES. A "Lost the Rainmaker!" style line shifts
* the icon out of this box; the text read handles those.
*/
export const BURST_ICON_ROI: Roi = { x: 785, y: 230, w: 190, h: 140 };
export const BURST_ICON_TEMPLATE_SIZES = [116, 124, 132] as const;
/** Gear panel rows: [head, clothes, shoes]. */
export const ABILITY_ROWS = 3;
/** Main-ability circle centers (⌀~68). */
const ABILITY_MAIN_X = 515;
const ABILITY_MAIN_YS = [696, 795, 887] as const;
/** Sub-ability circle centers (⌀~48), slightly below the main's center. */
export const ABILITY_SUB_XS = [578, 630, 682] as const;
const ABILITY_SUB_YS = [702, 797, 888] as const;
/**
* Search boxes around each circle; heights double as the size filter
* (matchTemplate skips templates taller than the ROI).
*/
export function abilityMainRoi(row: number): Roi {
const cy = ABILITY_MAIN_YS[row]!;
return { x: ABILITY_MAIN_X - 38, y: cy - 38, w: 76, h: 76 };
}
export function abilitySubRoi(row: number, slot: number): Roi {
const cx = ABILITY_SUB_XS[slot]!;
const cy = ABILITY_SUB_YS[row]!;
return { x: cx - 28, y: cy - 28, w: 56, h: 56 };
}
/** Template heights (px at 1080p) per circle role. */
export const ABILITY_MAIN_SIZES = [64, 68, 72] as const;
export const ABILITY_SUB_SIZES = [44, 48, 52] as const;
/**
* Icon art diameter as fraction of circle box: mains peak 1.0, subs 0.92
* — badges draw art nearly edge-to-edge, ring contributes ~nothing.
*/
export const ABILITY_MAIN_ART_RATIO = 1.0;
export const ABILITY_SUB_ART_RATIO = 0.92;
/**
* Ink threshold: badge near-black, icon art saturated-bright; panel is
* slightly translucent so a bright scene can ghost through — kept above that.
*/
export const ABILITY_INK_THRESHOLD = 90;
/**
* A gear row carries only as many sub circles as gear slots (1-3); an
* absent slot shows bare panel. Bright pixels (max channel >
* ABILITY_INK_THRESHOLD) separate cases: absent measures 0, every real
* badge measures 403+ even at the dimmest 720p capture.
*/
export const ABILITY_SLOT_MIN_INK = 200;
/**
* Splash tag name band. Tag renders tilted (baseline rises right by
* TAG_TILT_DEG); crop TAG_NAME_OUTER, rotate level, read TAG_NAME_INNER
* relative to the rotated crop.
*/
export const TAG_TILT_DEG = 3.0;
export const TAG_NAME_OUTER: Roi = { x: 1130, y: 770, w: 650, h: 140 };
/**
* Name band inside the rotated crop. Top edge must clear kana dakuten
* (old y=34 clipped them, misreading こ for ご); y=21 splits the title
* line above (ends outer y=17) with margin.
*/
export const TAG_NAME_INNER: Roi = { x: 20, y: 21, w: 610, h: 87 };
/** Tight cap height of the tag name text (atlas nominal height). */
export const TAG_NAME_TEXT_HEIGHT = 46;
/**
* Gate probes: burst camo is dark left/right of the text line and below
* the weapon line; gear panel is dark right of the sub circles.
*/
export const GATE_BURST_PROBES: readonly Roi[] = [
{ x: 750, y: 376, w: 36, h: 22 },
{ x: 1134, y: 376, w: 36, h: 22 },
{ x: 930, y: 466, w: 60, h: 18 },
];
export const GATE_PANEL_PROBES: readonly Roi[] = [
{ x: 720, y: 686, w: 30, h: 20 },
{ x: 720, y: 785, w: 30, h: 20 },
];
export const GATE_DARK_MAX_MEAN = 95;
/** SPLAT_LINE1_ROI must contain near-white pixels... */
export const GATE_TEXT_MIN_MAX = 210;
/** ...but not too many: it is a short text line, not a white panel. */
export const GATE_TEXT_MAX_FRACTION = 0.35;
/**
* Dark probes + white text also match the scoreboard screens, so the gate
* additionally requires bright icon art at all three main-ability centers
* (max RGB channel, not grayscale — saturated art can be gray-dark yet
* bright in max-channel). Death fixtures measure 244+, closest non-death
* row 184.
*/
export function gateAbilityProbe(row: number): Roi {
return {
x: ABILITY_MAIN_X - 14,
y: ABILITY_MAIN_YS[row]! - 14,
w: 28,
h: 28,
};
}
export const GATE_ICON_MIN_MAX = 200;

View File

@@ -0,0 +1,245 @@
/**
* English weapon names keyed by in-game weapon id, generated from
* sendou.ink locales/en/weapons.json (MAIN_/SUB_/SPECIAL_<id> entries;
* mains filtered to the assets/cv/main-weapons icon manifest).
* The death screen shows the killer's weapon as text ("Splatted by
* <name>!") and can credit a main, sub, or special, so the closed set
* that OCR output snaps to spans all three kinds. Ids are only unique
* within a kind (sub 0 = Splat Bomb, main 0 = Sploosh-o-matic).
*/
export type WeaponType = "MAIN" | "SUB" | "SPECIAL";
export interface WeaponEntry {
id: string;
name: string;
type: WeaponType;
}
const WEAPON_NAMES: ReadonlyMap<string, string> = new Map([
["0", "Sploosh-o-matic"],
["1", "Neo Sploosh-o-matic"],
["10", "Splattershot Jr."],
["11", "Custom Splattershot Jr."],
["20", "Splash-o-matic"],
["21", "Neo Splash-o-matic"],
["22", "Splash-o-matic GCK-O"],
["30", "Aerospray MG"],
["31", "Aerospray RG"],
["32", "Colorz Aerospray"],
["40", "Splattershot"],
["41", "Tentatek Splattershot"],
["42", "Glamorz Splattershot"],
["45", "Hero Shot Replica"],
["46", "Octo Shot Replica"],
["47", "Order Shot Replica"],
["50", ".52 Gal"],
["51", ".52 Gal Deco"],
["60", "N-ZAP '85"],
["61", "N-ZAP '89"],
["70", "Splattershot Pro"],
["71", "Forge Splattershot Pro"],
["72", "Splattershot Pro FRZ-N"],
["80", ".96 Gal"],
["81", ".96 Gal Deco"],
["82", "Clawz .96 Gal"],
["90", "Jet Squelcher"],
["91", "Custom Jet Squelcher"],
["92", "Jet Squelcher COB-R"],
["100", "Splattershot Nova"],
["101", "Annaki Splattershot Nova"],
["200", "Luna Blaster"],
["201", "Luna Blaster Neo"],
["205", "Order Blaster Replica"],
["210", "Blaster"],
["211", "Custom Blaster"],
["212", "Gleamz Blaster"],
["220", "Range Blaster"],
["221", "Custom Range Blaster"],
["230", "Clash Blaster"],
["231", "Clash Blaster Neo"],
["240", "Rapid Blaster"],
["241", "Rapid Blaster Deco"],
["250", "Rapid Blaster Pro"],
["251", "Rapid Blaster Pro Deco"],
["252", "Rapid Blaster Pro WNT-R"],
["260", "S-BLAST '92"],
["261", "S-BLAST '91"],
["300", "L-3 Nozzlenose"],
["301", "L-3 Nozzlenose D"],
["302", "Glitterz L-3 Nozzlenose"],
["310", "H-3 Nozzlenose"],
["311", "H-3 Nozzlenose D"],
["312", "H-3 Nozzlenose VIP-R"],
["400", "Squeezer"],
["401", "Foil Squeezer"],
["1000", "Carbon Roller"],
["1001", "Carbon Roller Deco"],
["1002", "Carbon Roller ANG-L"],
["1010", "Splat Roller"],
["1011", "Krak-On Splat Roller"],
["1015", "Order Roller Replica"],
["1020", "Dynamo Roller"],
["1021", "Gold Dynamo Roller"],
["1022", "Starz Dynamo Roller"],
["1030", "Flingza Roller"],
["1031", "Foil Flingza Roller"],
["1040", "Big Swig Roller"],
["1041", "Big Swig Roller Express"],
["1042", "Planetz Big Swig Roller"],
["1100", "Inkbrush"],
["1101", "Inkbrush Nouveau"],
["1110", "Octobrush"],
["1111", "Octobrush Nouveau"],
["1112", "Cometz Octobrush"],
["1115", "Orderbrush Replica"],
["1120", "Painbrush"],
["1121", "Painbrush Nouveau"],
["1122", "Painbrush BRN-Z"],
["2000", "Classic Squiffer"],
["2001", "New Squiffer"],
["2010", "Splat Charger"],
["2011", "Z+F Splat Charger"],
["2012", "Splat Charger CAM-O"],
["2015", "Order Charger Replica"],
["2020", "Splatterscope"],
["2021", "Z+F Splatterscope"],
["2022", "Splatterscope CAM-O"],
["2030", "E-liter 4K"],
["2031", "Custom E-liter 4K"],
["2040", "E-liter 4K Scope"],
["2041", "Custom E-liter 4K Scope"],
["2050", "Bamboozler 14 Mk I"],
["2051", "Bamboozler 14 Mk II"],
["2060", "Goo Tuber"],
["2061", "Custom Goo Tuber"],
["2070", "Snipewriter 5H"],
["2071", "Snipewriter 5B"],
["3000", "Slosher"],
["3001", "Slosher Deco"],
["3005", "Order Slosher Replica"],
["3010", "Tri-Slosher"],
["3011", "Tri-Slosher Nouveau"],
["3012", "Tri-Slosher ASH-N"],
["3020", "Sloshing Machine"],
["3021", "Sloshing Machine Neo"],
["3030", "Bloblobber"],
["3031", "Bloblobber Deco"],
["3040", "Explosher"],
["3041", "Custom Explosher"],
["3050", "Dread Wringer"],
["3051", "Dread Wringer D"],
["3052", "Hornz Dread Wringer"],
["4000", "Mini Splatling"],
["4001", "Zink Mini Splatling"],
["4002", "Mini Splatling RTL-R"],
["4010", "Heavy Splatling"],
["4011", "Heavy Splatling Deco"],
["4015", "Order Splatling Replica"],
["4020", "Hydra Splatling"],
["4021", "Custom Hydra Splatling"],
["4022", "Torrentz Hydra Splatling"],
["4030", "Ballpoint Splatling"],
["4031", "Ballpoint Splatling Nouveau"],
["4040", "Nautilus 47"],
["4041", "Nautilus 79"],
["4050", "Heavy Edit Splatling"],
["4051", "Heavy Edit Splatling Nouveau"],
["5000", "Dapple Dualies"],
["5001", "Dapple Dualies Nouveau"],
["5002", "Dapple Dualies NOC-T"],
["5010", "Splat Dualies"],
["5011", "Enperry Splat Dualies"],
["5012", "Twinklez Splat Dualies"],
["5015", "Order Dualie Replicas"],
["5020", "Glooga Dualies"],
["5021", "Glooga Dualies Deco"],
["5030", "Dualie Squelchers"],
["5031", "Custom Dualie Squelchers"],
["5032", "Hoofz Dualie Squelchers"],
["5040", "Dark Tetra Dualies"],
["5041", "Light Tetra Dualies"],
["5050", "Douser Dualies FF"],
["5051", "Custom Douser Dualies FF"],
["6000", "Splat Brella"],
["6001", "Sorella Brella"],
["6005", "Order Brella Replica"],
["6010", "Tenta Brella"],
["6011", "Tenta Sorella Brella"],
["6012", "Tenta Brella CRE-M"],
["6020", "Undercover Brella"],
["6021", "Undercover Sorella Brella"],
["6022", "Patternz Undercover Brella"],
["6030", "Recycled Brella 24 Mk I"],
["6031", "Recycled Brella 24 Mk II"],
["7010", "Tri-Stringer"],
["7011", "Inkline Tri-Stringer"],
["7012", "Bulbz Tri-Stringer"],
["7015", "Order Stringer Replica"],
["7020", "REEF-LUX 450"],
["7021", "REEF-LUX 450 Deco"],
["7022", "REEF-LUX 450 MIL-K"],
["7030", "Wellstring V"],
["7031", "Custom Wellstring V"],
["8000", "Splatana Stamper"],
["8001", "Splatana Stamper Nouveau"],
["8002", "Stickerz Splatana Stamper"],
["8005", "Order Splatana Replica"],
["8010", "Splatana Wiper"],
["8011", "Splatana Wiper Deco"],
["8012", "Splatana Wiper RUS-T"],
["8020", "Mint Decavitator"],
["8021", "Charcoal Decavitator"],
]);
const SUB_WEAPON_NAMES: ReadonlyMap<string, string> = new Map([
["0", "Splat Bomb"],
["1", "Suction Bomb"],
["2", "Burst Bomb"],
["3", "Sprinkler"],
["4", "Splash Wall"],
["5", "Fizzy Bomb"],
["6", "Curling Bomb"],
["7", "Autobomb"],
["8", "Squid Beakon"],
["9", "Point Sensor"],
["10", "Ink Mine"],
["11", "Toxic Mist"],
["12", "Angle Shooter"],
["13", "Torpedo"],
]);
const SPECIAL_WEAPON_NAMES: ReadonlyMap<string, string> = new Map([
["1", "Trizooka"],
["2", "Big Bubbler"],
["3", "Zipcaster"],
["4", "Tenta Missiles"],
["5", "Ink Storm"],
["6", "Booyah Bomb"],
["7", "Wave Breaker"],
["8", "Ink Vac"],
["9", "Killer Wail 5.1"],
["10", "Inkjet"],
["11", "Ultra Stamp"],
["12", "Crab Tank"],
["13", "Reefslider"],
["14", "Triple Inkstrike"],
["15", "Tacticooler"],
["16", "Super Chump"],
["17", "Kraken Royale"],
["18", "Triple Splashdown"],
["19", "Splattercolor Screen"],
]);
function entriesOf(
map: ReadonlyMap<string, string>,
type: WeaponType,
): WeaponEntry[] {
return [...map.entries()].map(([id, name]) => ({ id, name, type }));
}
/** Every weapon that can appear in the death message, across all kinds. */
export const ALL_WEAPON_ENTRIES: readonly WeaponEntry[] = [
...entriesOf(WEAPON_NAMES, "MAIN"),
...entriesOf(SUB_WEAPON_NAMES, "SUB"),
...entriesOf(SPECIAL_WEAPON_NAMES, "SPECIAL"),
];

View File

@@ -0,0 +1,365 @@
/**
* MapStartDetector: parses the match-intro splash — the mode title on the
* black center splat and the stage name bottom-right, both snapped to the
* localized closed sets (core/localized.ts) and reported under canonical
* English names.
*
* The mode title wraps to two lines for longer names ("Splat" / "Zones"),
* so parse finds text lines inside MODE_BLOCK_ROI by row projection, OCRs
* each band, and snaps the joined reading. The constant "MODE" label
* doubles as a parse-time confirmation (else a lookalike gate hit emits
* nothing). The splash sits on live gameplay, so bright stages leak
* background past the text edges: the title block is masked to near-dark
* for line finding only (raw crop OCRs better); the stage line reads its
* min channel (drops blue-tinted water gray keeps) under several
* binarizations, keeping the best-snapping one (see rois.ts).
*/
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { getCV, type Mat, minMaxLoc } from "../../cv";
import {
type GlyphSet,
type RecognizedText,
recognizeText,
scaleGlyphSet,
} from "../../glyphs";
import { copyRoi, meanBrightness, minChannel } from "../../image";
import {
ALL_MODE_ENTRIES,
ALL_MODE_LABELS,
ALL_STAGE_ENTRIES,
} from "../../localized";
import { closestBy } from "../../text";
import type { ScoreboardResources } from "../scoreboard/index";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
BLOCK_MASK_RADIUS,
GATE_DARK_MAX_MEAN,
GATE_DARK_PROBES,
GATE_INK_BAND,
GATE_INK_BAND_MAX_BRIGHT,
GATE_INK_BAND_MIN_DARK,
GATE_TEXT_MAX_FRACTION,
GATE_TEXT_MIN_FRACTION,
GATE_TEXT_MIN_MAX,
LINE_GAP_TOLERANCE,
LINE_MIN_HEIGHT,
LINE_MIN_ROW_PIXELS,
LINE_ROW_FRACTION,
MASK_DARK_MAX,
MODE_BLOCK_ROI,
MODE_LABEL_ROI,
MODE_LABEL_TEXT_HEIGHT,
MODE_TEXT_HEIGHT,
STAGE_BIN_THRESHOLD,
STAGE_MASK_RADIUS,
STAGE_RAW_BIN_THRESHOLDS,
STAGE_ROI,
STAGE_TEXT_HEIGHT,
TEXT_BIN_THRESHOLD,
} from "./rois";
export interface MapStartData {
mode: ModeShort | null;
stage: StageId | null;
}
export const MAP_START_EVENT_TYPE = "MapStart";
/** "MODE" must read back at least this well for parse to emit. */
const LABEL_MIN_SCORE = 0.5;
/** Accept a closed-set match only above this score (1 = exact). */
const MIN_MATCH_SCORE = 0.62;
interface LineBand {
y0: number;
y1: number;
}
/** Zero every pixel with no near-black pixel within `radius` of it. */
function maskNearDark(gray: Mat, radius: number): Mat {
const cv = getCV();
const dark = new cv.Mat();
cv.threshold(gray, dark, MASK_DARK_MAX, 255, cv.THRESH_BINARY_INV);
const kernel = cv.getStructuringElement(
cv.MORPH_ELLIPSE,
new cv.Size(2 * radius + 1, 2 * radius + 1),
);
const near = new cv.Mat();
cv.dilate(dark, near, kernel);
kernel.delete();
dark.delete();
const out = new cv.Mat(gray.rows, gray.cols, cv.CV_8UC1, new cv.Scalar(0));
gray.copyTo(out, near);
near.delete();
return out;
}
/** Find text line bands in a binarized block by row projection. */
function findLineBands(binary: Mat): LineBand[] {
const { rows, cols, data } = binary;
const counts = new Array<number>(rows).fill(0);
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
if (data[y * cols + x]! > 0) counts[y]!++;
}
}
// dual threshold: a band needs rows above a cutoff scaled to the
// strongest row (background leaking past the splat's rim puts a
// scene-dependent noise floor under every row), then extends over a
// fixed floor so antialiased glyph tops/bottoms stay inside the band
const core = Math.max(
LINE_MIN_ROW_PIXELS,
LINE_ROW_FRACTION * Math.max(...counts),
);
const bands: LineBand[] = [];
let start = -1;
let gap = 0;
for (let y = 0; y < rows; y++) {
if (counts[y]! >= core) {
if (start < 0) start = y;
gap = 0;
} else if (start >= 0 && ++gap > LINE_GAP_TOLERANCE) {
bands.push({ y0: start, y1: y - gap + 1 });
start = -1;
}
}
if (start >= 0) bands.push({ y0: start, y1: rows - gap });
for (const band of bands) {
while (band.y0 > 0 && counts[band.y0 - 1]! >= LINE_MIN_ROW_PIXELS)
band.y0--;
while (band.y1 < rows && counts[band.y1]! >= LINE_MIN_ROW_PIXELS) band.y1++;
}
const merged: LineBand[] = [];
for (const band of bands) {
const last = merged[merged.length - 1];
if (last && band.y0 <= last.y1) last.y1 = Math.max(last.y1, band.y1);
else merged.push(band);
}
return merged.filter((b) => b.y1 - b.y0 >= LINE_MIN_HEIGHT);
}
/**
* Trim a line band to its text columns (on the masked binary), so the raw
* OCR crop excludes the background the wide block picks up at its edges.
*/
function bandExtent(
binary: Mat,
band: LineBand,
): { x0: number; x1: number } | null {
const { cols, data } = binary;
let x0 = -1;
let x1 = -1;
for (let x = 0; x < cols; x++) {
let count = 0;
for (let y = band.y0; y < band.y1; y++) {
if (data[y * cols + x]! > 0) count++;
}
if (count >= 2) {
if (x0 < 0) x0 = x;
x1 = x;
}
}
return x0 < 0 ? null : { x0, x1 };
}
export function createMapStartDetector(
resources: ScoreboardResources,
): Detector<MapStartData> {
const cv = getCV();
const scaled = (
set: GlyphSet | null | undefined,
height: number,
): GlyphSet | null => (set ? scaleGlyphSet(set, height / set.height) : null);
const modeGlyphs = scaled(resources.mapStartModeGlyphs, MODE_TEXT_HEIGHT);
const stageGlyphs = scaled(resources.mapStartStageGlyphs, STAGE_TEXT_HEIGHT);
const labelGlyphs = scaled(
resources.mapStartStageGlyphs,
MODE_LABEL_TEXT_HEIGHT,
);
function gate(frame: Mat): GateResult {
let darkOk = 0;
for (const roi of GATE_DARK_PROBES) {
if (meanBrightness(frame, roi) < GATE_DARK_MAX_MEAN) darkOk++;
}
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const label = copyRoi(gray, MODE_LABEL_ROI);
const { maxVal } = minMaxLoc(label);
const bin = new cv.Mat();
cv.threshold(label, bin, TEXT_BIN_THRESHOLD, 255, cv.THRESH_BINARY);
label.delete();
const whiteFraction = cv.countNonZero(bin) / (bin.rows * bin.cols);
bin.delete();
const textOk =
maxVal > GATE_TEXT_MIN_MAX &&
whiteFraction > GATE_TEXT_MIN_FRACTION &&
whiteFraction < GATE_TEXT_MAX_FRACTION;
// the label-to-title gap core is solid ink on this screen: it must be
// near-totally dark (the scoreboards' pills aren't) and carry no bright
// pixels (the death burst's "Splatted by" line crosses it)
const band = copyRoi(gray, GATE_INK_BAND);
const bandPixels = band.rows * band.cols;
const bandBright = new cv.Mat();
cv.threshold(band, bandBright, TEXT_BIN_THRESHOLD, 255, cv.THRESH_BINARY);
const brightFraction = cv.countNonZero(bandBright) / bandPixels;
bandBright.delete();
const bandDark = new cv.Mat();
cv.threshold(band, bandDark, MASK_DARK_MAX, 255, cv.THRESH_BINARY_INV);
const darkFraction = cv.countNonZero(bandDark) / bandPixels;
bandDark.delete();
band.delete();
const inkOk =
brightFraction <= GATE_INK_BAND_MAX_BRIGHT &&
darkFraction >= GATE_INK_BAND_MIN_DARK;
gray.delete();
const score =
(darkOk / GATE_DARK_PROBES.length + (textOk ? 1 : 0) + (inkOk ? 1 : 0)) /
3;
return {
pass: darkOk === GATE_DARK_PROBES.length && textOk && inkOk,
score,
};
}
function parse(frame: Mat, t: number): DetectedEvent<MapStartData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
// 1. confirm the constant label — a gate hit without it is a lookalike
let label: RecognizedText | null = null;
let labelScore = 0;
if (labelGlyphs) {
const crop = copyRoi(gray, MODE_LABEL_ROI);
label = recognizeText(crop, labelGlyphs, {
binThreshold: TEXT_BIN_THRESHOLD,
minCharScore: 0.3,
});
crop.delete();
labelScore = closestBy(label.text, ALL_MODE_LABELS, (l) => l)?.score ?? 0;
if (labelScore < LABEL_MIN_SCORE) {
gray.delete();
return [];
}
}
// 2. mode title: find the 1-2 text lines, OCR each, snap the joined text
let mode: ModeShort | null = null;
let modeScore = 0;
let modeReading = "";
if (modeGlyphs) {
const block = copyRoi(gray, MODE_BLOCK_ROI);
// find line bands on the masked block (bright background rows would
// merge/invent bands) but OCR the raw crop (masking clips strokes and
// costs accuracy); each band is trimmed to its text columns (also
// from the mask) since the full-width block picks up background junk
const masked = maskNearDark(block, BLOCK_MASK_RADIUS);
const binary = new cv.Mat();
cv.threshold(masked, binary, TEXT_BIN_THRESHOLD, 255, cv.THRESH_BINARY);
masked.delete();
const bands = findLineBands(binary);
const lines: string[] = [];
for (const band of bands) {
const extent = bandExtent(binary, band);
if (!extent) continue;
const pad = 3;
const y0 = Math.max(0, band.y0 - pad);
const x0 = Math.max(0, extent.x0 - pad);
const line = copyRoi(block, {
x: x0,
y: y0,
w: Math.min(block.cols, extent.x1 + 1 + pad) - x0,
h: Math.min(block.rows, band.y1 + pad) - y0,
});
const read = recognizeText(line, modeGlyphs, {
binThreshold: TEXT_BIN_THRESHOLD,
minCharScore: 0.3,
});
line.delete();
if (read.text.trim()) lines.push(read.text.trim());
}
binary.delete();
block.delete();
modeReading = lines.join(" ");
const match = modeReading
? closestBy(modeReading, ALL_MODE_ENTRIES, (e) => e.text)
: null;
if (match) {
modeScore = match.score;
if (match.score >= MIN_MATCH_SCORE) mode = match.entry.mode;
}
}
// 3. stage name. Backdrop is live gameplay (dark water on one stage,
// a bright floor on another), so no single binarization works
// everywhere: read the near-dark-masked crop plus the raw crop at a
// few rising thresholds, and keep whichever snaps best.
let stage: StageId | null = null;
let stageScore = 0;
let stageReading = "";
if (stageGlyphs) {
const rgbaCrop = copyRoi(frame, STAGE_ROI);
const bright = minChannel(rgbaCrop);
rgbaCrop.delete();
const masked = maskNearDark(bright, STAGE_MASK_RADIUS);
const attempts: [Mat, number][] = [
[masked, STAGE_BIN_THRESHOLD],
...STAGE_RAW_BIN_THRESHOLDS.map((thr): [Mat, number] => [bright, thr]),
];
for (const [input, binThreshold] of attempts) {
const read = recognizeText(input, stageGlyphs, {
binThreshold,
minCharScore: 0.3,
});
const match = read.text
? closestBy(read.text, ALL_STAGE_ENTRIES, (e) => e.text)
: null;
if (match && match.score > stageScore) {
stageScore = match.score;
stageReading = read.text;
if (match.score >= MIN_MATCH_SCORE) stage = match.entry.stageId;
}
}
masked.delete();
bright.delete();
}
gray.delete();
return [
{
type: MAP_START_EVENT_TYPE,
t,
// mean like every other detector: a clean mode read must survive an
// unreadable stage (map-start is the only mode source for VoD matches),
// not be zeroed by it
confidence: (modeScore + stageScore) / 2,
data: { mode, stage },
debug: {
label: label?.text,
labelScore,
modeReading,
modeScore,
stageReading,
stageScore,
},
},
];
}
// just under the measured clean-read floor (fixtures 0.795-0.864,
// confirmed scan events 0.854-1.0)
return {
id: "map-start",
sufficientConfidence: 0.79,
gate,
parse,
};
}

View File

@@ -0,0 +1,97 @@
/**
* ALL map-start ROI coordinates, in canonical 1920x1080 space.
* Calibrated against the map-start/ fixtures via row/column profiling.
*
* The match-intro splash overlays live gameplay: a black ink splat
* top-center with the constant "MODE" label (~48px caps), the mode title
* (~76px BlitzBold, up to two lines), and an objective subtitle; the
* stage name bottom-right (~40px); and eight players' splash tags along
* the left/right edges.
*/
import type { Roi } from "../../canonical";
/**
* The constant "MODE" label (white caps centered x=960), kept tight
* inside the label's black pill (x≈825-1110): on bright stages
* (Mahi-Mahi) a wider crop picks up background that garbles the read.
*/
export const MODE_LABEL_ROI: Roi = { x: 850, y: 268, w: 220, h: 62 };
export const MODE_LABEL_TEXT_HEIGHT = 48;
/**
* Mode title block below the label, one or two lines (parse finds lines
* by row projection, not fixed positions). Ends above the objective
* subtitle (~y678); wide enough for the longest title ("Herrschaft" /
* "Spetterzone" ~660px) — at 520 wide the German H/t clipped ("lerrschaf").
*/
export const MODE_BLOCK_ROI: Roi = { x: 620, y: 380, w: 680, h: 285 };
export const MODE_TEXT_HEIGHT = 76;
/** A block row is text when it has at least this many bright pixels... */
export const LINE_MIN_ROW_PIXELS = 40;
/**
* ...and at least this fraction of the block's strongest row: background
* leak past the splat rim sets a scene-dependent floor (Robo ROM-en's
* bright mall merged title + junk into one 227px band without this).
*/
export const LINE_ROW_FRACTION = 0.25;
/** Text rows closer than this merge into one line band. */
export const LINE_GAP_TOLERANCE = 8;
/** Discard bands shorter than this (splat-texture speckle, drips). */
export const LINE_MIN_HEIGHT = 40;
/** Stage name, bottom-right over live gameplay. */
export const STAGE_ROI: Roi = { x: 1300, y: 984, w: 600, h: 56 };
export const STAGE_TEXT_HEIGHT = 40;
/** White text on the near-black splat binarizes cleanly and high. */
export const TEXT_BIN_THRESHOLD = 190;
/**
* Bright-background suppression (light stages like Mahi-Mahi: water ~200
* gray, docks 230+): text counts only near a near-black pixel (title
* borders splat ink, stage name has a drop shadow). Radius must exceed
* stroke half-width or it eats glyph cores: ~12 for the title, ~6 for
* the stage line.
*/
export const MASK_DARK_MAX = 70;
export const BLOCK_MASK_RADIUS = 12;
export const STAGE_MASK_RADIUS = 6;
/**
* Stage line also reads the per-pixel min channel at a higher threshold
* (blue water drops via its low red channel, white glyphs stay near 255).
* When the mask fails on an all-bright backdrop (Robo ROM-en's mall
* floor), parse tries the raw crop at these thresholds and keeps the best.
*/
export const STAGE_BIN_THRESHOLD = 210;
export const STAGE_RAW_BIN_THRESHOLDS: readonly number[] = [225, 235, 245];
/**
* Gate probes: splat ink flanking the "MODE" label and before the title,
* on spots verified solid across fixtures (mean ≤8, well above ink yet
* under the dark scoreboard pills ~51+). Flanking pair sits outside x
* 838-1082, past the widest label text ("Kampfart", "Vechtstijl").
*/
export const GATE_DARK_PROBES: readonly Roi[] = [
{ x: 780, y: 280, w: 30, h: 20 },
{ x: 1105, y: 280, w: 30, h: 20 },
{ x: 850, y: 355, w: 30, h: 20 },
{ x: 1030, y: 355, w: 30, h: 20 },
];
export const GATE_DARK_MAX_MEAN = 45;
/**
* Core of the label-to-title gap is solid splat ink: near-total darkness,
* zero bright pixels. Death's "Splatted by" line runs through here
* (bright fraction 0.17+); scoreboard pills only reach ~0.83 dark.
*/
export const GATE_INK_BAND: Roi = { x: 840, y: 350, w: 230, h: 36 };
export const GATE_INK_BAND_MAX_BRIGHT = 0.005;
export const GATE_INK_BAND_MIN_DARK = 0.95;
/** MODE_LABEL_ROI must contain near-white pixels... */
export const GATE_TEXT_MIN_MAX = 210;
/** ...at a text fill fraction: "MODE" fills ~0.25 of the tight ROI, while
* stray white on the other screens stays under ~0.13. */
export const GATE_TEXT_MIN_FRACTION = 0.05;
export const GATE_TEXT_MAX_FRACTION = 0.35;

View File

@@ -0,0 +1,24 @@
/**
* Ability badge templates at the minimap cards' badge size (⌀~44 on both
* the own cards and the enemy panel; one role, mains only — the cards show
* no sub-ability slots). Built via the death panel's badge compositor.
*/
import type { FrameData } from "../../image";
import { buildAbilityRole } from "../death/abilities";
import type { WeaponTemplate } from "../scoreboard/weapons";
import {
BADGE_ART_RATIO,
BADGE_TEMPLATE_SIZES,
MINIMAP_ABILITY_INK_THRESHOLD,
} from "./rois";
export function prepareMinimapAbilityTemplates(
icons: { id: string; image: FrameData }[],
): WeaponTemplate[] {
return buildAbilityRole(
icons,
BADGE_TEMPLATE_SIZES,
BADGE_ART_RATIO,
MINIMAP_ABILITY_INK_THRESHOLD,
);
}

View File

@@ -0,0 +1,657 @@
/**
* MinimapDetector: parses the in-match map overlay (opened with X) — the
* own-team callout cards (name, main weapon, the three main-ability
* badges), the enemy panel rows (weapon, abilities; the game shows no
* enemy names) — plus the stage, matched from the drawn map (stage.ts).
* The goal is the most complete read of every card/row; per-match state
* (respawn cross-outs, special charge, map control) is deliberately not
* reported.
*
* Two screen states still steer the reads without being emitted:
* - a respawning player's card is struck through with a large team-color
* X that covers the name and badges (own cards also lose the weapon;
* enemy rows keep theirs — the X spares the row's weapon icon). Reading
* through the X yields garbage, so an occlusion probe skips the covered
* fields and reports them null;
* - a charged special swaps the card/row background for a light camo
* pattern; weapons are full-color icon art matched against art-cropped
* template sets composited for the surface actually behind them — bg-40
* for the translucent dark cards/rows, bg-150 for the camo (dark
* templates anti-correlate there) — so a corner-brightness probe picks
* the template set, ink threshold, and score floor per card.
*/
import type {
AbilityWithUnknown,
MainWeaponId,
StageId,
} from "~/modules/in-game-lists/types";
import { toAbilityWithUnknown, toMainWeaponId } from "../../../scanner-types";
import { getCV, type Mat } from "../../cv";
import { type GlyphSet, scaleGlyphSet } from "../../glyphs";
import {
copyRoi,
cropRoi,
laplacianAbs,
maxBrightness,
meanBrightness,
type Roi,
} from "../../image";
import type { ScoreboardResources } from "../scoreboard/index";
import { type ParsedName, parseName } from "../scoreboard/names";
import {
disambiguateWeaponBySub,
matchSpecial,
tiedWeaponsWithDistinctSubs,
} from "../scoreboard/specials";
import { matchWeapon, type WeaponMatch } from "../scoreboard/weapons";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
badgeRoi,
CARD_LAYOUTS,
type CardSlot,
CROSS_MIN_FRACTION,
CROSS_SATURATION_MIN,
CROSS_VALUE_MIN,
ENEMY_BADGE_XS,
ENEMY_ROW_CYS,
enemyCrossRoi,
enemySubTileRoi,
enemyWeaponRoi,
GATE_BRIGHT_MIN_MAX,
GATE_CLOSE_DARK_PROBES,
GATE_CLOSE_X_BRIGHT,
GATE_CLOSE_X_DARK,
GATE_DARK_MAX_MEAN,
GATE_SPAWN_BRIGHT,
GATE_SPAWN_DARK_PROBES,
GATE_SPECTATOR_X_BRIGHT,
GATE_SPECTATOR_X_DARK,
MINIMAP_ABILITY_INK_THRESHOLD,
MINIMAP_WEAPON_INK_THRESHOLD,
NAME_BIN_THRESHOLD,
NAME_TEXT_HEIGHT,
PRESENCE_MIN_LAPLACIAN,
SPECIAL_READY_INK_THRESHOLD,
SPECIAL_READY_MIN_CORNER_MEAN,
SPECIAL_READY_WEAPON_MIN_SCORE,
SPECTATOR_ENEMY_DX,
SPECTATOR_NAME_TEXT_HEIGHTS,
SPECTATOR_SLOTS,
spectatorCardLayout,
WEAPON_MIN_SCORE,
} from "./rois";
import { matchStage, plannerSignature, type StageMatch } from "./stage";
export interface MinimapTeammate {
/** which callout card: super-jump slot, or the POV player's own card */
slot: CardSlot;
/** card name; null when covered by a respawn cross-out or unreadable */
name: string | null;
/** sendou main-weapon id; null when unreadable/covered */
weaponId: MainWeaponId | null;
/**
* the card's three main abilities, [head, clothes, shoes] (null per
* unreadable badge); empty when a respawn cross-out sits over the badges
*/
abilities: (AbilityWithUnknown | null)[];
}
export interface MinimapEnemy {
/**
* the POV overlay shows no enemy names (always null there); the
* spectator screen does, so spectator rows carry them
*/
name: string | null;
/** readable even on struck rows: the cross-out spares the weapon icon */
weaponId: MainWeaponId | null;
abilities: (AbilityWithUnknown | null)[];
}
export interface MinimapData {
/**
* sendou stage id, matched from the drawn map against the planner
* renders (stage.ts); null when no stage matched confidently or the
* planner signatures were not loaded. The mode is not identifiable this
* way (see stage.ts) and is left to the mode-bearing detectors.
*/
stage: StageId | null;
/**
* true when the frame is a casted stream's 8-player spectator map screen
* rather than the POV overlay: the alpha (left) column is reported as
* teammates (d-pad slots up/right/down/left) and the bravo (right)
* column as enemy rows — with names, which this screen shows
*/
spectator: boolean;
/** own-team callout cards; a slot missing from the frame is omitted */
teammates: MinimapTeammate[];
/** enemy panel rows, top to bottom */
enemies: MinimapEnemy[];
}
export const MINIMAP_EVENT_TYPE = "Minimap";
/** Badge match below this is reported as null (kept in debug). */
const ABILITY_MIN_SCORE = 0.45;
/**
* Light-camo (special-charged) probe: min of the two 8x8 top-corner means
* of the weapon box. Camo backgrounds brighten both corners (140-165); on
* a dark card at least one stays dark even when avatar bleed or a
* cross-out stroke lights up the other.
*/
function minTopCornerMean(gray: Mat, roi: Roi): number {
const corners: Roi[] = [
{ x: roi.x, y: roi.y, w: 8, h: 8 },
{ x: roi.x + roi.w - 8, y: roi.y, w: 8, h: 8 },
];
return Math.min(...corners.map((c) => meanBrightness(gray, c)));
}
/** fraction of the probe that is saturated-and-bright (cross-out strokes) */
function saturatedFraction(hsv: Mat, roi: Roi): number {
const m = copyRoi(hsv, roi);
const n = m.rows * m.cols;
const md = m.data;
let hit = 0;
for (let i = 0; i < n; i++) {
if (
md[i * 3 + 1]! >= CROSS_SATURATION_MIN &&
md[i * 3 + 2]! >= CROSS_VALUE_MIN
) {
hit++;
}
}
m.delete();
return hit / n;
}
export function createMinimapDetector(
resources: ScoreboardResources,
): Detector<MinimapData> {
const cv = getCV();
const nameGlyphs: GlyphSet | null = resources.nameGlyphs
? scaleGlyphSet(
resources.nameGlyphs,
NAME_TEXT_HEIGHT / resources.nameGlyphs.height,
)
: null;
const spectatorNameGlyphs: GlyphSet[] = resources.nameGlyphs
? SPECTATOR_NAME_TEXT_HEIGHTS.map((h) =>
h === NAME_TEXT_HEIGHT
? nameGlyphs!
: scaleGlyphSet(
resources.nameGlyphs!,
h / resources.nameGlyphs!.height,
),
)
: [];
const cardWeapons = resources.minimapCardWeapons ?? null;
const lightWeapons = resources.minimapLightWeapons ?? null;
const badges = resources.minimapAbilities ?? null;
const subWeapons = resources.minimapSubWeapons ?? null;
const plannerStages = resources.plannerStages ?? null;
/** Identify the stage from the drawn map; contributes to confidence. */
function detectStage(frame: Mat, confidences: number[]): StageMatch | null {
if (!plannerStages?.length) return null;
const sig = plannerSignature(frame);
const match = matchStage(sig, plannerStages);
if (match) confidences.push(match.score);
return match;
}
function probeGate(
gray: Mat,
darkProbes: readonly Roi[],
brightProbes: readonly Roi[],
): GateResult {
let darkOk = 0;
for (const roi of darkProbes) {
if (meanBrightness(gray, roi) <= GATE_DARK_MAX_MEAN) darkOk++;
}
let brightOk = 0;
for (const roi of brightProbes) {
if (maxBrightness(gray, roi) >= GATE_BRIGHT_MIN_MAX) brightOk++;
}
return {
pass: darkOk === darkProbes.length && brightOk === brightProbes.length,
score: (darkOk / darkProbes.length + brightOk / brightProbes.length) / 2,
};
}
/** POV overlay chrome: close-button disc + Spawn Point pill. */
function overlayGate(gray: Mat): GateResult {
return probeGate(
gray,
[
...GATE_CLOSE_DARK_PROBES,
...GATE_CLOSE_X_DARK,
...GATE_SPAWN_DARK_PROBES,
],
[...GATE_CLOSE_X_BRIGHT, GATE_SPAWN_BRIGHT],
);
}
/** Spectator screen: the X jump-button disc beside the 8th player card. */
function spectatorGate(gray: Mat): GateResult {
return probeGate(gray, GATE_SPECTATOR_X_DARK, GATE_SPECTATOR_X_BRIGHT);
}
function gate(frame: Mat): GateResult {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const overlay = overlayGate(gray);
const spectator = spectatorGate(gray);
gray.delete();
return {
pass: overlay.pass || spectator.pass,
score: Math.max(overlay.score, spectator.score),
variant: spectator.pass ? "spectator" : "overlay",
};
}
function matchBadges(
rgb: Mat,
centers: readonly (readonly [number, number])[],
inkThreshold: number,
confidences: number[],
debugRow: (WeaponMatch | null)[],
): (AbilityWithUnknown | null)[] {
if (!badges) return [null, null, null];
return centers.map(([cx, cy]) => {
const crop = cropRoi(rgb, badgeRoi(cx, cy));
const match = matchWeapon(crop, badges, { inkThreshold });
crop.delete();
debugRow.push(match);
confidences.push(Math.max(0, match.score));
return match.score >= ABILITY_MIN_SCORE
? toAbilityWithUnknown(match.id)
: null;
});
}
/**
* Near-tied weapon icons whose kits differ by sub (plain vs Custom
* Dualie Squelchers): let the card/row's team-tinted sub tile break the
* tie. Shape-only matching survives the tint, the camo surface, and a
* cross-out stroke clipping the tile.
*/
function resolveTieBySubTile(
rgb: Mat,
weapon: WeaponMatch,
tile: Roi,
): WeaponMatch {
if (!subWeapons?.length || !tiedWeaponsWithDistinctSubs(weapon))
return weapon;
const crop = cropRoi(rgb, tile);
const sub = matchSpecial(crop, subWeapons);
crop.delete();
return disambiguateWeaponBySub(weapon, sub);
}
/** Try the name band at each spectator glyph height; best read wins. */
function bestNameRead(gray: Mat, roi: Roi): ParsedName | null {
let best: ParsedName | null = null;
for (const set of spectatorNameGlyphs) {
const band = copyRoi(gray, roi);
const parsed = parseName(band, set, { binThreshold: NAME_BIN_THRESHOLD });
band.delete();
if (!best || parsed.confidence > best.confidence) best = parsed;
}
return best;
}
/**
* The spectator screen's 8-card grid doesn't share the overlay's ROIs
* (running the overlay parse against it reads phantom cards), so it gets
* its own card loop: same fields per card, both columns carry names.
*/
function parseSpectator(
frame: Mat,
gray: Mat,
t: number,
): DetectedEvent<MinimapData>[] {
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const hsv = new cv.Mat();
cv.cvtColor(rgb, hsv, cv.COLOR_RGB2HSV);
const lap = laplacianAbs(gray);
const confidences: number[] = [];
const debug: Record<string, unknown> = { spectator: true };
const teammates: MinimapTeammate[] = [];
const enemies: MinimapEnemy[] = [];
const cardDebug: Record<string, unknown>[] = [];
for (const dx of [0, SPECTATOR_ENEMY_DX]) {
for (let row = 0; row < 4; row++) {
const layout = spectatorCardLayout(row, dx);
const presence = meanBrightness(lap, layout.name);
if (presence < PRESENCE_MIN_LAPLACIAN) {
cardDebug.push({ dx, row, presence, skipped: true });
continue;
}
const crossFraction = saturatedFraction(hsv, layout.cross);
const occluded = crossFraction >= CROSS_MIN_FRACTION;
const cornerMin = minTopCornerMean(gray, layout.weapon);
const lightSurface = cornerMin >= SPECIAL_READY_MIN_CORNER_MEAN;
let name: string | null = null;
let nameRaw = "";
let weapon: WeaponMatch | null = null;
const badgeDebug: (WeaponMatch | null)[] = [];
let abilities: (AbilityWithUnknown | null)[] = [];
// the spectator cross-out sits clear of the weapon ROI (like
// overlay enemy rows), so the weapon stays readable when struck
const templates = lightSurface ? lightWeapons : cardWeapons;
if (templates) {
const crop = cropRoi(rgb, layout.weapon);
weapon = matchWeapon(crop, templates, {
inkThreshold: lightSurface
? SPECIAL_READY_INK_THRESHOLD
: Math.max(
MINIMAP_WEAPON_INK_THRESHOLD,
Math.round(cornerMin) + 50,
),
});
crop.delete();
weapon = resolveTieBySubTile(rgb, weapon, layout.subTile);
confidences.push(Math.max(0, weapon.score));
}
if (!occluded) {
const parsed = bestNameRead(gray, layout.name);
if (parsed) {
nameRaw = parsed.raw.text;
if (parsed.name.length > 0) name = parsed.name;
confidences.push(parsed.confidence);
}
abilities = matchBadges(
rgb,
layout.badges,
Math.max(MINIMAP_ABILITY_INK_THRESHOLD, Math.round(cornerMin) + 50),
confidences,
badgeDebug,
);
}
cardDebug.push({
dx,
row,
presence,
crossFraction,
occluded,
cornerMin,
lightSurface,
nameRaw,
weapon,
badges: badgeDebug,
});
const floor = lightSurface
? SPECIAL_READY_WEAPON_MIN_SCORE
: WEAPON_MIN_SCORE;
const matched =
weapon !== null && weapon.score >= floor ? weapon : null;
const fields = {
name,
weaponId: matched ? toMainWeaponId(matched.id) : null,
abilities,
};
if (dx === 0) {
teammates.push({ slot: SPECTATOR_SLOTS[row]!, ...fields });
} else {
enemies.push(fields);
}
}
}
debug.cards = cardDebug;
const stageMatch = detectStage(frame, confidences);
debug.stage = stageMatch;
rgb.delete();
hsv.delete();
lap.delete();
const confidence =
confidences.length > 0
? confidences.reduce((a, b) => a + b, 0) / confidences.length
: 0;
return [
{
type: MINIMAP_EVENT_TYPE,
t,
confidence,
data: {
stage: stageMatch?.stageId ?? null,
spectator: true,
teammates,
enemies,
},
debug,
},
];
}
function parse(
frame: Mat,
t: number,
gateResult?: GateResult,
): DetectedEvent<MinimapData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const isSpectator = gateResult?.variant
? gateResult.variant === "spectator"
: spectatorGate(gray).pass;
if (isSpectator) {
const events = parseSpectator(frame, gray, t);
gray.delete();
return events;
}
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const hsv = new cv.Mat();
cv.cvtColor(rgb, hsv, cv.COLOR_RGB2HSV);
const lap = laplacianAbs(gray);
const confidences: number[] = [];
const debug: Record<string, unknown> = {};
// 1. own-team callout cards
const teammates: MinimapTeammate[] = [];
const cardDebug: Record<string, unknown>[] = [];
for (const layout of CARD_LAYOUTS) {
// presence: the card is crisp UI, absent slots show blurred scene
const presence = meanBrightness(lap, layout.name);
if (presence < PRESENCE_MIN_LAPLACIAN) {
cardDebug.push({ slot: layout.slot, presence, skipped: true });
continue;
}
const crossFraction = saturatedFraction(hsv, layout.cross);
const occluded = crossFraction >= CROSS_MIN_FRACTION;
const cornerMin = minTopCornerMean(gray, layout.weapon);
const lightSurface = cornerMin >= SPECIAL_READY_MIN_CORNER_MEAN;
let name: string | null = null;
let nameRaw = "";
let weapon: WeaponMatch | null = null;
const badgeDebug: (WeaponMatch | null)[] = [];
let abilities: (AbilityWithUnknown | null)[] = [];
if (!occluded) {
if (nameGlyphs) {
const band = copyRoi(gray, layout.name);
const parsed = parseName(band, nameGlyphs, {
binThreshold: NAME_BIN_THRESHOLD,
});
band.delete();
nameRaw = parsed.raw.text;
if (parsed.name.length > 0) name = parsed.name;
confidences.push(parsed.confidence);
}
const templates = lightSurface ? lightWeapons : cardWeapons;
if (templates) {
const crop = cropRoi(rgb, layout.weapon);
weapon = matchWeapon(crop, templates, {
inkThreshold: lightSurface
? SPECIAL_READY_INK_THRESHOLD
: MINIMAP_WEAPON_INK_THRESHOLD,
});
crop.delete();
weapon = resolveTieBySubTile(rgb, weapon, layout.subTile);
confidences.push(Math.max(0, weapon.score));
}
abilities = matchBadges(
rgb,
layout.badges,
lightSurface
? Math.max(
MINIMAP_ABILITY_INK_THRESHOLD,
Math.round(cornerMin) + 50,
)
: MINIMAP_ABILITY_INK_THRESHOLD,
confidences,
badgeDebug,
);
}
cardDebug.push({
slot: layout.slot,
presence,
crossFraction,
occluded,
cornerMin,
lightSurface,
nameRaw,
weapon,
badges: badgeDebug,
});
const floor = lightSurface
? SPECIAL_READY_WEAPON_MIN_SCORE
: WEAPON_MIN_SCORE;
const matched = weapon !== null && weapon.score >= floor ? weapon : null;
// an occluding cross-out is itself proof the card is drawn
const hasEvidence =
occluded ||
name !== null ||
matched !== null ||
abilities.some((a) => a !== null);
if (!hasEvidence) continue;
teammates.push({
slot: layout.slot,
name,
weaponId: matched ? toMainWeaponId(matched.id) : null,
abilities,
});
}
debug.cards = cardDebug;
// 2. enemy panel rows
const enemies: MinimapEnemy[] = [];
const enemyDebug: Record<string, unknown>[] = [];
for (const cy of ENEMY_ROW_CYS) {
const weaponRoi = enemyWeaponRoi(cy);
const presence = meanBrightness(lap, weaponRoi);
if (presence < PRESENCE_MIN_LAPLACIAN) {
enemyDebug.push({ cy, presence, skipped: true });
continue;
}
const crossFraction = saturatedFraction(hsv, enemyCrossRoi(cy));
const occluded = crossFraction >= CROSS_MIN_FRACTION;
// light camo rows: pick the template variant by the weapon box's
// corner brightness and raise the ink threshold past that background
const cornerMin = minTopCornerMean(gray, weaponRoi);
const lightSurface = cornerMin >= SPECIAL_READY_MIN_CORNER_MEAN;
const templates = lightSurface ? lightWeapons : cardWeapons;
const inkThreshold = lightSurface
? SPECIAL_READY_INK_THRESHOLD
: Math.max(MINIMAP_WEAPON_INK_THRESHOLD, Math.round(cornerMin) + 50);
let weapon: WeaponMatch | null = null;
if (templates) {
const crop = cropRoi(rgb, weaponRoi);
weapon = matchWeapon(crop, templates, { inkThreshold });
crop.delete();
weapon = resolveTieBySubTile(rgb, weapon, enemySubTileRoi(cy));
confidences.push(Math.max(0, weapon.score));
}
const badgeDebug: (WeaponMatch | null)[] = [];
const abilities: (AbilityWithUnknown | null)[] = occluded
? []
: matchBadges(
rgb,
ENEMY_BADGE_XS.map((cx) => [cx, cy] as const),
Math.max(MINIMAP_ABILITY_INK_THRESHOLD, Math.round(cornerMin) + 50),
confidences,
badgeDebug,
);
enemyDebug.push({
cy,
presence,
crossFraction,
occluded,
lightSurface,
cornerMin,
weapon,
badges: badgeDebug,
});
const floor = lightSurface
? SPECIAL_READY_WEAPON_MIN_SCORE
: WEAPON_MIN_SCORE;
const matched = weapon !== null && weapon.score >= floor ? weapon : null;
enemies.push({
name: null,
weaponId: matched ? toMainWeaponId(matched.id) : null,
abilities,
});
}
debug.enemies = enemyDebug;
const stageMatch = detectStage(frame, confidences);
debug.stage = stageMatch;
gray.delete();
rgb.delete();
hsv.delete();
lap.delete();
const confidence =
confidences.length > 0
? confidences.reduce((a, b) => a + b, 0) / confidences.length
: 0;
return [
{
type: MINIMAP_EVENT_TYPE,
t,
confidence,
data: {
stage: stageMatch?.stageId ?? null,
spectator: false,
teammates,
enemies,
},
debug,
},
];
}
// sufficientConfidence sits just under the measured clean-read floor
// (fixtures 0.746-0.800; confirmed scan events reach down to 0.699, and
// those below the floor fall back to stagnation). The refine override
// matters here because a map-open's confidence keeps fluctuating upward,
// resetting the stagnation counter — without it a ~0.9s parse runs at
// the dense cadence for the whole map-open
return {
id: "minimap",
refineIntervalS: 0.4,
sufficientConfidence: 0.73,
gate,
parse,
};
}

View File

@@ -0,0 +1,254 @@
/**
* ALL minimap ROI coordinates, in canonical 1920x1080 space. Calibrated
* against the minimap/ fixtures via tools/dump-crops.ts plus template
* relocation sweeps (badges sit on an exact 48px pitch, pinning origins).
*
* The in-match map overlay (X) draws over gaussian-blurred gameplay: four
* own-team cards (POV player bottom-left) and an enemy panel top-right,
* each with weapon icon art, sub/special tiles, three ability badges
* (⌀~44); a respawning player is struck through with a team-color X, and
* a charged special swaps the card background for gray-green camo.
* Constant chrome (close disc, Spawn Point pill) gates on shape since the
* pill label is localized.
*/
import type { Roi } from "../../canonical";
/** "self" and "down" never coexist: the POV overlay has no down slot (the
* player's own card replaces it), the spectator grid has no self card. */
export type CardSlot = "up" | "left" | "right" | "self" | "down";
export interface CardLayout {
slot: CardSlot;
/** name text band (BlitzMain caps ~29px plus outline/descender margin) */
name: Roi;
/** main-weapon silhouette box (icons render ~31-48px tall) */
weapon: Roi;
/** sub-weapon tile: saturated team-color art, the ink-color anchor */
subTile: Roi;
/** the three main-ability badge centers, 48px pitch */
badges: readonly (readonly [number, number])[];
/** cross-out probe at card center: name/pill are unsaturated, X core isn't */
cross: Roi;
}
/**
* Right card = left shifted +1352px (verified only on the struck fixture).
* Self card differs: avatar leftmost, larger name inset, no d-pad.
*/
export const CARD_LAYOUTS: readonly CardLayout[] = [
{
slot: "up",
name: { x: 872, y: 46, w: 300, h: 44 },
weapon: { x: 860, y: 83, w: 84, h: 54 },
subTile: { x: 932, y: 98, w: 38, h: 41 },
badges: [
[1066, 114],
[1114, 114],
[1162, 114],
],
cross: { x: 925, y: 78, w: 60, h: 32 },
},
{
slot: "left",
name: { x: 198, y: 492, w: 300, h: 44 },
weapon: { x: 193, y: 529, w: 84, h: 54 },
subTile: { x: 265, y: 544, w: 38, h: 41 },
badges: [
[392, 564],
[440, 564],
[488, 564],
],
cross: { x: 258, y: 524, w: 60, h: 32 },
},
{
slot: "right",
name: { x: 1550, y: 492, w: 300, h: 44 },
weapon: { x: 1545, y: 529, w: 84, h: 54 },
subTile: { x: 1617, y: 544, w: 38, h: 41 },
badges: [
[1744, 564],
[1792, 564],
[1840, 564],
],
cross: { x: 1610, y: 524, w: 60, h: 32 },
},
{
slot: "self",
name: { x: 126, y: 942, w: 300, h: 46 },
weapon: { x: 118, y: 985, w: 94, h: 55 },
subTile: { x: 193, y: 995, w: 38, h: 36 },
badges: [
[320, 1014],
[368, 1014],
[416, 1014],
],
cross: { x: 255, y: 968, w: 60, h: 32 },
},
];
/** Enemy panel row centers (65px pitch) and per-row element boxes. */
export const ENEMY_ROW_CYS = [82, 147, 213, 278] as const;
export function enemyWeaponRoi(cy: number): Roi {
return { x: 1541, y: cy - 26, w: 58, h: 52 };
}
export function enemySubTileRoi(cy: number): Roi {
return { x: 1602, y: cy - 20, w: 39, h: 40 };
}
export const ENEMY_BADGE_XS = [1730, 1778, 1826] as const;
/** X arms meet in the dark gap between special tile and first badge (unsaturated when clean). */
export function enemyCrossRoi(cy: number): Roi {
return { x: 1685, y: cy - 12, w: 28, h: 24 };
}
/** Badge search box (badges ⌀~44; the box height keeps larger sets out). */
export function badgeRoi(cx: number, cy: number): Roi {
return { x: cx - 26, y: cy - 26, w: 52, h: 52 };
}
export const BADGE_TEMPLATE_SIZES = [38, 42, 46] as const;
/** Badge art fills the circle like the death panel's mains. */
export const BADGE_ART_RATIO = 1.0;
/**
* Badge ink threshold: near-black circles vs the enemy panel's translucent
* pink bleed (~150-180 in corners) — a constant penalty, ranking intact.
*/
export const MINIMAP_ABILITY_INK_THRESHOLD = 90;
/** BlitzMain caps measure 28-29px on every card (self included). */
export const NAME_TEXT_HEIGHT = 29;
export const NAME_BIN_THRESHOLD = 170;
/** Cross-out probe: fraction of saturated+bright HSV pixels. Struck 0.26-0.38, clean <=0.01. */
export const CROSS_SATURATION_MIN = 110;
export const CROSS_VALUE_MIN = 110;
export const CROSS_MIN_FRACTION = 0.08;
/**
* Weapon templates built with cropToArt (54px card box needs padding
* trimmed to fit larger icons — Splatana Stamper unmatchable otherwise).
* Dark surfaces match a bg-40 composite, special-ready camo a bg-150 one.
*/
/**
* Sub-tile silhouettes matched shape-only (specials.ts): survives team
* tint/camo/cross-out, used to split near-tied main-weapon icons.
*/
export const SUB_TILE_TEMPLATE_SIZES = [24, 27, 30, 33, 36] as const;
export const CARD_WEAPON_BACKGROUND = 40;
export const SPECIAL_READY_BACKGROUND = 150;
export const MINIMAP_WEAPON_TEMPLATE_SIZES = [
40, 44, 48, 52, 56, 60, 64,
] as const;
export const MINIMAP_WEAPON_INK_THRESHOLD = CARD_WEAPON_BACKGROUND + 50;
export const SPECIAL_READY_INK_THRESHOLD = SPECIAL_READY_BACKGROUND + 50;
/**
* Corner mean above this = special-ready camo (measures 140-165); a dark
* card keeps at least one top corner <=90 despite avatar/cross-out bleed.
*/
export const SPECIAL_READY_MIN_CORNER_MEAN = 120;
/**
* Camo surfaces score systematically lower (blob pattern depresses NCC):
* 0.45-0.61 on correct matches vs 0.77+ on dark surfaces.
*/
export const WEAPON_MIN_SCORE = 0.55;
export const SPECIAL_READY_WEAPON_MIN_SCORE = 0.42;
/**
* Overlay is crisp over a blurred scene: mean |Laplacian| over the name
* band/weapon box separates a drawn element from see-through background.
*/
export const PRESENCE_MIN_LAPLACIAN = 8;
/**
* Close-button gate: white ✕ on a dark disc (center (110,92) ±4px). Bright
* probes trace the crossing point + four arms, dark probes sit in the
* cardinal gaps — a plain bright blob misses the arms and fails.
*/
export const GATE_CLOSE_X_BRIGHT: readonly Roi[] = [
{ x: 104, y: 88, w: 12, h: 10 },
{ x: 88, y: 72, w: 8, h: 10 },
{ x: 124, y: 72, w: 8, h: 10 },
{ x: 88, y: 108, w: 8, h: 10 },
{ x: 124, y: 108, w: 8, h: 10 },
];
export const GATE_CLOSE_X_DARK: readonly Roi[] = [
{ x: 104, y: 62, w: 12, h: 8 },
{ x: 104, y: 116, w: 12, h: 8 },
{ x: 76, y: 88, w: 8, h: 10 },
{ x: 136, y: 88, w: 8, h: 10 },
];
/** Dark ring/background just outside the close-button disc. */
export const GATE_CLOSE_DARK_PROBES: readonly Roi[] = [
{ x: 88, y: 50, w: 20, h: 14 },
{ x: 88, y: 132, w: 20, h: 14 },
{ x: 58, y: 88, w: 14, h: 20 },
{ x: 132, y: 88, w: 14, h: 20 },
];
/** The white jump-arrows icon left of the (localized) pill label. */
export const GATE_SPAWN_BRIGHT: Roi = { x: 888, y: 963, w: 60, h: 60 };
export const GATE_SPAWN_DARK_PROBES: readonly Roi[] = [
{ x: 812, y: 958, w: 26, h: 14 },
{ x: 950, y: 1024, w: 60, h: 12 },
];
export const GATE_DARK_MAX_MEAN = 85;
export const GATE_BRIGHT_MIN_MAX = 210;
/**
* Spectator-variant gate: casted streams show the 8-player spectator
* screen and often cover the overlay gate's corner chrome, so this gates
* on the rarely-covered X jump-button disc beside the 8th card
* (bottom-right, center (1424,712) ±4px). Measured margins: bright>=249 /
* dark<=65 against the shared 210/85 thresholds.
*/
export const GATE_SPECTATOR_X_BRIGHT: readonly Roi[] = [
{ x: 1418, y: 706, w: 12, h: 12 },
{ x: 1411, y: 692, w: 12, h: 12 },
{ x: 1425, y: 692, w: 12, h: 12 },
{ x: 1411, y: 720, w: 12, h: 12 },
{ x: 1425, y: 720, w: 12, h: 12 },
];
export const GATE_SPECTATOR_X_DARK: readonly Roi[] = [
{ x: 1399, y: 708, w: 8, h: 8 },
{ x: 1441, y: 708, w: 8, h: 8 },
{ x: 1420, y: 682, w: 8, h: 8 },
{ x: 1420, y: 734, w: 8, h: 8 },
];
/**
* Spectator card grid: four cards per column, 120px row pitch, right =
* left +1348px. Left column is alpha team, right is bravo (reported as
* enemy rows). Mirrors the overlay's card layout; no struck/special-ready
* fixture attested yet, so those probes reuse overlay thresholds untuned.
*/
export const SPECTATOR_SLOTS: readonly CardSlot[] = [
"up",
"right",
"down",
"left",
];
export const SPECTATOR_ENEMY_DX = 1348;
const SPECTATOR_ROW_PITCH = 120;
export function spectatorCardLayout(
row: number,
dx: number,
): Omit<CardLayout, "slot"> {
const dy = SPECTATOR_ROW_PITCH * row;
return {
name: { x: 198 + dx, y: 306 + dy, w: 310, h: 44 },
weapon: { x: 196 + dx, y: 350 + dy, w: 66, h: 54 },
subTile: { x: 264 + dx, y: 354 + dy, w: 38, h: 42 },
badges: [
[390 + dx, 374 + dy],
[438 + dx, 374 + dy],
[486 + dx, 374 + dy],
],
cross: { x: 344 + dx, y: 362 + dy, w: 20, h: 24 },
};
}
/**
* Spectator names tried at both heights, more confident read wins: blur
* across captures moves the best fit between 29 and 30 per card.
*/
export const SPECTATOR_NAME_TEXT_HEIGHTS = [29, 30] as const;

View File

@@ -0,0 +1,224 @@
/**
* Stage identification for the minimap overlay by matching the drawn map
* against sendou.ink planner renders (assets/cv/planner/, one PNG per
* stage x mode, packed as a signature atlas).
*
* The map is drawn sharp over blurred gameplay, so a Laplacian edge mask
* isolates it; dropping saturated (team-ink) edges leaves a structural,
* ink-invariant signature. Downscaling+blurring also absorbs the POV (map
* fills screen) vs spectator (map smaller, centered) scale/offset
* difference, letting one atlas serve both via a translation search.
*
* Stage separates cleanly this way (NCC leads the next stage by
* ~0.17-0.26). Mode does NOT — the objective marker is the only per-mode
* difference and ink-invariance strips it — so the atlas keeps all five
* renders per stage and reports only the winning tile's stage; mode comes
* from map-start/scoreboard header instead.
*/
import type { StageId } from "~/modules/in-game-lists/types";
import { getCV, type Mat } from "../../cv";
import type { FrameData } from "../../image";
/** Downscaled signature dimensions (canonical 1920x1080 / 16). */
export const PLANNER_SIG_W = 120;
export const PLANNER_SIG_H = 68;
/** |Laplacian| floor separating render edges from the blur (blur ~0-5). */
const EDGE_MIN = 24;
/** HSV saturation at/above which an edge pixel is team ink, not structure. */
const INK_SATURATION_MIN = 80;
/** Half-range (signature px, ~16x canonical) of the alignment search. */
const MATCH_RANGE = 8;
/** Below this best NCC, or this lead over the next stage, report nothing. */
const MIN_SCORE = 0.5;
const MIN_MARGIN = 0.05;
export interface PlannerStage {
/** "<stageId>-<MODE>", e.g. "6-SZ" */
key: string;
/** sendou stage id */
stageId: StageId;
/** unit-L2-normalized structural signature, row-major PLANNER_SIG_W x _H */
sig: Float32Array;
}
export interface StageMatch {
stageId: StageId;
/** best NCC of the winning stage */
score: number;
/** lead over the best-scoring other stage */
margin: number;
}
/**
* Ink-invariant structural signature of a canonical-normalized RGBA frame:
* downscaled, blurred, unit-L2-normalized float mask of the non-ink render
* edges. Shared by the build tool and the runtime matcher so the atlas and
* the live frame are computed identically.
*/
export function plannerSignature(frame: Mat): Float32Array {
const cv = getCV();
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const hsv = new cv.Mat();
cv.cvtColor(rgb, hsv, cv.COLOR_RGB2HSV);
rgb.delete();
const lap = new cv.Mat();
cv.Laplacian(gray, lap, cv.CV_16S, 3);
gray.delete();
const edges = new cv.Mat();
cv.convertScaleAbs(lap, edges);
lap.delete();
const mask = new cv.Mat();
cv.threshold(edges, mask, EDGE_MIN, 255, cv.THRESH_BINARY);
edges.delete();
// drop saturated (ink) edges, keeping only the structural skeleton
const n = mask.rows * mask.cols;
const md = mask.data;
const hd = hsv.data;
for (let i = 0; i < n; i++) {
if (hd[i * 3 + 1]! >= INK_SATURATION_MIN) md[i] = 0;
}
hsv.delete();
const down = new cv.Mat();
cv.resize(
mask,
down,
new cv.Size(PLANNER_SIG_W, PLANNER_SIG_H),
0,
0,
cv.INTER_AREA,
);
mask.delete();
const blur = new cv.Mat();
cv.GaussianBlur(down, blur, new cv.Size(5, 5), 0);
down.delete();
const out = new Float32Array(PLANNER_SIG_W * PLANNER_SIG_H);
const bd = blur.data;
let sumSq = 0;
for (let i = 0; i < out.length; i++) {
out[i] = bd[i]!;
sumSq += out[i]! * out[i]!;
}
blur.delete();
const norm = Math.sqrt(sumSq) || 1;
for (let i = 0; i < out.length; i++) out[i]! /= norm;
return out;
}
/** Dot product of `a` against `b` shifted by (dx, dy) over their overlap. */
function shiftedDot(
a: Float32Array,
b: Float32Array,
dx: number,
dy: number,
): number {
let dot = 0;
for (let y = 0; y < PLANNER_SIG_H; y++) {
const sy = y + dy;
if (sy < 0 || sy >= PLANNER_SIG_H) continue;
const ar = y * PLANNER_SIG_W;
const br = sy * PLANNER_SIG_W;
for (let x = 0; x < PLANNER_SIG_W; x++) {
const sx = x + dx;
if (sx < 0 || sx >= PLANNER_SIG_W) continue;
dot += a[ar + x]! * b[br + sx]!;
}
}
return dot;
}
/** Best NCC of two unit signatures over a small translation search. */
function bestNcc(a: Float32Array, b: Float32Array): number {
let best = -1;
for (let dy = -MATCH_RANGE; dy <= MATCH_RANGE; dy += 2) {
for (let dx = -MATCH_RANGE; dx <= MATCH_RANGE; dx += 2) {
const v = shiftedDot(a, b, dx, dy);
if (v > best) best = v;
}
}
return best;
}
/**
* Identify the stage of a minimap frame's signature against the planner
* atlas. Returns null when no stage matches confidently (score floor) or two
* stages are too close to call (margin floor) — e.g. a stage not in the set.
*/
export function matchStage(
sig: Float32Array,
planners: readonly PlannerStage[],
): StageMatch | null {
if (planners.length === 0) return null;
const byStage = new Map<StageId, number>();
for (const p of planners) {
const score = bestNcc(sig, p.sig);
const prev = byStage.get(p.stageId);
if (prev === undefined || score > prev) byStage.set(p.stageId, score);
}
let bestId: StageId | null = null;
let best = -1;
let second = -1;
for (const [id, score] of byStage) {
if (score > best) {
second = best;
best = score;
bestId = id;
} else if (score > second) {
second = score;
}
}
const margin = second < 0 ? best : best - second;
if (bestId === null || best < MIN_SCORE || margin < MIN_MARGIN) return null;
return {
stageId: bestId,
score: Math.round(best * 1000) / 1000,
margin: Math.round(margin * 1000) / 1000,
};
}
export interface PlannerManifest {
width: number;
height: number;
/** tiles packed left-to-right, top-to-bottom, this many per row */
cols: number;
/** tile keys in packing order */
keys: string[];
}
/**
* Slice the packed signature atlas (grayscale uint8 tiles) back into
* unit-normalized PlannerStage signatures. Mirrors loadGlyphSet's atlas
* convention; the build tool writes the atlas + manifest.
*/
export function loadPlannerStages(
atlas: FrameData,
manifest: PlannerManifest,
): PlannerStage[] {
const { width, height, cols, keys } = manifest;
const aw = atlas.width;
const data = atlas.data;
return keys.map((key, i) => {
const tx = (i % cols) * width;
const ty = Math.floor(i / cols) * height;
const sig = new Float32Array(width * height);
let sumSq = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
// atlas is RGBA; the tiles are grayscale, so read the red channel
const v = data[((ty + y) * aw + (tx + x)) * 4]!;
sig[y * width + x] = v;
sumSq += v * v;
}
}
const norm = Math.sqrt(sumSq) || 1;
for (let j = 0; j < sig.length; j++) sig[j]! /= norm;
const stageId = Number(key.split("-")[0]) as StageId;
return { key, stageId, sig };
});
}

View File

@@ -0,0 +1,360 @@
/**
* ObjectiveDetector: parses the ranked in-match counter overlay top-center —
* each team's count plate, the penalty pill under it, which team is in
* control (controlling team's plate keeps its saturated team-color fill;
* otherwise near-black fill with digits in the team's ink color — see
* rois.ts), and the M:SS match timer above the plates.
*
* Digits are read as the trailing digit run (banner.ts) under several
* channel extractions — team-color ink on a black plate needs the
* brightest channel (dark blue ink is near-black in luminance), ink on a
* team-color fill needs the darkest channel — every extraction is tried at
* each threshold/size and the best-scoring read wins. A gate hit with no
* readable count on either side emits nothing (lookalike frame).
*
* `ObjectiveData` is a discriminated union on `mode`; only SZ exists so
* far. Identifying mode from the badge between the plates awaits TC/RM/CB
* fixtures.
*/
import { getCV, type Mat, minMaxLoc } from "../../cv";
import {
type GlyphSet,
type RecognizedChar,
recognizeText,
scaleGlyphSet,
} from "../../glyphs";
import {
copyRoi,
maxBrightness,
maxChannel,
meanBrightness,
minChannel,
type Roi,
} from "../../image";
import {
type BannerScoreRead,
isBetterRead,
trailingDigitRun,
} from "../scoreboard/banner";
import type { ScoreboardResources } from "../scoreboard/index";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
CONTROL_PLATE_MIN_SATURATION,
GATE_PLATE_MAX_STD,
GATE_SCORE_MIN_MAX_BRIGHTNESS,
GATE_TIMER_MAX_MEAN,
GATE_TIMER_MIN_MAX_BRIGHTNESS,
PENALTY_BIN_THRESHOLD,
PENALTY_PROBE_MAX_MEAN,
PENALTY_PROBE_MAX_STD,
PENALTY_PROBE_ROIS,
PENALTY_ROIS,
PENALTY_TEXT_HEIGHT,
PLATE_PROBE_ROIS,
SCORE_BIN_THRESHOLDS,
SCORE_ROIS,
SCORE_TEXT_HEIGHTS,
TIMER_BIN_THRESHOLD,
TIMER_DARK_PROBES,
TIMER_DIGIT_MIN_CONF,
TIMER_DIGIT_MIN_HEIGHT_RATIO,
TIMER_DIGIT_ROI,
TIMER_TEXT_HEIGHT,
} from "./rois";
export type ObjectiveData = SplatZonesObjectiveData;
export interface SplatZonesObjectiveData {
mode: "SZ";
/**
* seconds shown on the match timer above the plates ("3:35" = 215);
* null = unreadable. Counts down in regulation; the overtime display is
* unattested so far.
*/
time: number | null;
/** displayed count per team, [alpha, bravo]; null = unreadable */
score: [number | null, number | null];
/** penalty pill value per team; null = no pill (or unreadable) */
penalty: [number | null, number | null];
/** which team currently holds the zone (team-color plate fill) */
control: [boolean, boolean];
}
export const OBJECTIVE_EVENT_TYPE = "Objective";
/** How often the counter is worth checking (it changes at most 1/s). */
const CHECK_INTERVAL_SECONDS = 1;
/**
* Timeline content guard: consecutive counter reads merge only when they
* show the same state, so every actual tick/penalty/control change becomes
* its own event. `time` is deliberately not compared — the timer ticks
* every second, so comparing it would keep any two reads from ever merging.
*/
export function sameObjectiveData(a: unknown, b: unknown): boolean {
const da = a as ObjectiveData;
const db = b as ObjectiveData;
return (
da.mode === db.mode &&
da.score[0] === db.score[0] &&
da.score[1] === db.score[1] &&
da.penalty[0] === db.penalty[0] &&
da.penalty[1] === db.penalty[1] &&
da.control[0] === db.control[0] &&
da.control[1] === db.control[1]
);
}
interface SideRead {
score: BannerScoreRead;
penalty: BannerScoreRead | null;
control: boolean;
fill: { mean: number; saturation: number };
}
export function createObjectiveDetector(
resources: ScoreboardResources,
): Detector<ObjectiveData> {
const cv = getCV();
const scoreSets: GlyphSet[] = resources.paintDigits
? SCORE_TEXT_HEIGHTS.map((h) =>
scaleGlyphSet(
resources.paintDigits!,
h / resources.paintDigits!.height,
),
)
: [];
const penaltySet: GlyphSet | null = resources.paintDigits
? scaleGlyphSet(
resources.paintDigits,
PENALTY_TEXT_HEIGHT / resources.paintDigits.height,
)
: null;
const timerSet: GlyphSet | null = resources.paintDigits
? scaleGlyphSet(
resources.paintDigits,
TIMER_TEXT_HEIGHT / resources.paintDigits.height,
)
: null;
/** Mean and standard deviation of a grayscale ROI. */
function meanStd(gray: Mat, roi: Roi): { mean: number; std: number } {
const crop = copyRoi(gray, roi);
const { data } = crop;
let sum = 0;
for (const v of data) sum += v;
const mean = sum / data.length;
let varSum = 0;
for (const v of data) varSum += (v - mean) ** 2;
crop.delete();
return { mean, std: Math.sqrt(varSum / data.length) };
}
function plateProbeOk(gray: Mat, roi: Roi): boolean {
return meanStd(gray, roi).std <= GATE_PLATE_MAX_STD;
}
function scoreInkOk(frame: Mat, roi: Roi): boolean {
const band = maxChannel(frame, roi);
const { maxVal } = minMaxLoc(band);
band.delete();
return maxVal >= GATE_SCORE_MIN_MAX_BRIGHTNESS;
}
function gate(frame: Mat): GateResult {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const checks = [
...TIMER_DARK_PROBES.map(
(roi) => meanBrightness(gray, roi) <= GATE_TIMER_MAX_MEAN,
),
maxBrightness(gray, TIMER_DIGIT_ROI) >= GATE_TIMER_MIN_MAX_BRIGHTNESS,
plateProbeOk(gray, PLATE_PROBE_ROIS[0]),
plateProbeOk(gray, PLATE_PROBE_ROIS[1]),
scoreInkOk(frame, SCORE_ROIS[0]),
scoreInkOk(frame, SCORE_ROIS[1]),
];
gray.delete();
const passed = checks.filter(Boolean).length;
return { pass: passed === checks.length, score: passed / checks.length };
}
/**
* Best trailing-digit read of the band across channel extractions,
* thresholds, and glyph sizes (see module header for why one pass
* cannot cover both plate styles).
*/
function readScore(frame: Mat, gray: Mat, roi: Roi): BannerScoreRead {
let best: BannerScoreRead = {
value: null,
confidence: 0,
digits: 0,
reading: "",
};
const bands = [
copyRoi(gray, roi),
minChannel(frame, roi),
maxChannel(frame, roi),
];
for (const band of bands) {
for (const set of scoreSets) {
for (const binThreshold of SCORE_BIN_THRESHOLDS) {
const raw = recognizeText(band, set, {
binThreshold,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
const read = trailingDigitRun(raw, set);
if (isBetterRead(read, best)) best = read;
}
}
band.delete();
}
return best;
}
/**
* The match timer's M:SS over TIMER_DIGIT_ROI: white digits on the
* near-black box the gate already anchored on. The colon's two dots stack
* to well under the digit height floor, so a valid read is exactly three
* full-height digits — the minute, then the two second digits.
*/
function readTimer(gray: Mat): { value: number | null; reading: string } {
if (!timerSet) return { value: null, reading: "" };
const band = copyRoi(gray, TIMER_DIGIT_ROI);
const raw = recognizeText(band, timerSet, {
binThreshold: TIMER_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
band.delete();
const isTimerDigit = (c: RecognizedChar) =>
c.score >= TIMER_DIGIT_MIN_CONF &&
c.y1 - c.y0 >= timerSet.height * TIMER_DIGIT_MIN_HEIGHT_RATIO;
const digits = raw.chars.filter(isTimerDigit).map((c) => Number(c.char));
if (digits.length !== 3 || digits.some(Number.isNaN)) {
return { value: null, reading: raw.text };
}
const [minutes, secondsTens, secondsOnes] = digits as [
number,
number,
number,
];
if (secondsTens >= 6) return { value: null, reading: raw.text };
return {
value: minutes * 60 + secondsTens * 10 + secondsOnes,
reading: raw.text,
};
}
/** Penalty pill: presence probes first, then the white "+N" digits. */
function readPenalty(
frame: Mat,
gray: Mat,
side: 0 | 1,
): BannerScoreRead | null {
if (!penaltySet) return null;
const pillLike = PENALTY_PROBE_ROIS[side].every((roi) => {
const { mean, std } = meanStd(gray, roi);
return mean <= PENALTY_PROBE_MAX_MEAN && std <= PENALTY_PROBE_MAX_STD;
});
if (!pillLike) return null;
const band = minChannel(frame, PENALTY_ROIS[side]);
const raw = recognizeText(band, penaltySet, {
binThreshold: PENALTY_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
band.delete();
return trailingDigitRun(raw, penaltySet);
}
/**
* Control: the plate's fill (sampled over the probe strip) is the
* saturated team-color style instead of the near-black fill shown while
* not in control (see CONTROL_PLATE_MIN_SATURATION in rois.ts).
*/
function plateFill(
frame: Mat,
side: 0 | 1,
): { mean: number; saturation: number } {
const crop = copyRoi(frame, PLATE_PROBE_ROIS[side]);
const { data } = crop;
const channels = crop.channels();
let sum = 0;
let satSum = 0;
let count = 0;
for (let i = 0; i < data.length; i += channels) {
const r = data[i]!;
const g = data[i + 1]!;
const b = data[i + 2]!;
sum += Math.max(r, g, b);
satSum += Math.max(r, g, b) - Math.min(r, g, b);
count++;
}
crop.delete();
return { mean: sum / count, saturation: satSum / count };
}
function parse(frame: Mat, t: number): DetectedEvent<ObjectiveData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const sides = [0 as const, 1 as const].map((side): SideRead => {
const score = readScore(frame, gray, SCORE_ROIS[side]);
const penalty = readPenalty(frame, gray, side);
const fill = plateFill(frame, side);
return {
score,
penalty,
control:
score.value !== null &&
fill.saturation >= CONTROL_PLATE_MIN_SATURATION,
fill,
};
}) as [SideRead, SideRead];
const timer = readTimer(gray);
gray.delete();
// no readable count on either side = the gate hit a lookalike
if (sides.every((side) => side.score.value === null)) return [];
const confidences = sides.flatMap((side) => [
...(side.score.value !== null ? [side.score.confidence] : []),
...(side.penalty?.value != null ? [side.penalty.confidence] : []),
]);
return [
{
type: OBJECTIVE_EVENT_TYPE,
t,
confidence: confidences.reduce((a, b) => a + b, 0) / confidences.length,
data: {
mode: "SZ",
time: timer.value,
score: [sides[0].score.value, sides[1].score.value],
penalty: [
sides[0].penalty?.value ?? null,
sides[1].penalty?.value ?? null,
],
control: [sides[0].control, sides[1].control],
},
debug: {
timerReading: timer.reading,
scoreReadings: sides.map((side) => side.score.reading),
scoreConfidences: sides.map((side) => side.score.confidence),
penaltyReadings: sides.map((side) => side.penalty?.reading ?? null),
plateFills: sides.map((side) => side.fill),
},
},
];
}
return {
id: "objective",
checkIntervalS: CHECK_INTERVAL_SECONDS,
attachFrame: false,
gate,
parse,
};
}

View File

@@ -0,0 +1,118 @@
/**
* ALL objective-counter ROI coordinates, in canonical 1920x1080 space.
* Calibrated against the objective/ fixtures (pixel maps over the
* top-center band). Layout is only roughly mirror-symmetric around x=960
* (widest digit run "100" measures x816..888 left, x1039..1112 right), so
* score/plate-probe ROIs are measured per side, not mirrored.
*
* One counter plate per team flanks the mode's objective badge under the
* timer strip: small localized label line over big BlitzBold count
* digits (~41px). The controlling team's plate fills team-color; a
* non-controlling team (or both while neutral) is near-black with digits
* in team ink — the fill swap, not digit values, identifies control. A
* penalty adds a dark pill under the plate with a white "+N" (~29px).
*/
import { CANONICAL_WIDTH, type Roi } from "../../canonical";
/** Mirror a left-side ROI across the frame's vertical center line. */
function mirrorRoi(roi: Roi): Roi {
return { ...roi, x: CANONICAL_WIDTH - roi.x - roi.w };
}
/**
* Count digit band inside the plate below the label line. Wide enough
* for the full "100" run each side (x816..888 left, x1039..1112 right),
* clear of the plate rims (edge glow against a bright scene reads as ink).
*/
export const SCORE_ROIS: readonly [Roi, Roi] = [
{ x: 810, y: 156, w: 90, h: 46 },
{ x: 1034, y: 156, w: 84, h: 46 },
];
/**
* Penalty "+N" band inside the pill. The pill measures x 826..920,
* y 218..256 under the left plate; the band leaves the rounded ends out.
*/
export const PENALTY_ROIS: readonly [Roi, Roi] = (() => {
const left: Roi = { x: 832, y: 220, w: 82, h: 36 };
return [left, mirrorRoi(left)];
})();
/**
* Plate-fill probe strips inside each plate's LEFT rim, clear of the
* widest digit run (right strip isn't mirrored since that digit run
* reaches x1112). Both plate styles are a flat fill there (team color in
* control, near-black otherwise), so the gate accepts flatness (std)
* alone — fill brightness spans near-black to bright yellow.
*/
export const PLATE_PROBE_ROIS: readonly [Roi, Roi] = [
{ x: 800, y: 162, w: 10, h: 30 },
{ x: 1021, y: 162, w: 10, h: 30 },
];
/**
* Penalty-pill presence probes at the pill's rounded ends, clear of the
* widest "+100": translucent fill reads mid-dark and flat over any scene.
*/
export const PENALTY_PROBE_ROIS: readonly [[Roi, Roi], [Roi, Roi]] = (() => {
const leftPill: [Roi, Roi] = [
{ x: 828, y: 226, w: 8, h: 24 },
{ x: 910, y: 226, w: 8, h: 24 },
];
return [leftPill, [mirrorRoi(leftPill[1]), mirrorRoi(leftPill[0])]];
})();
export const GATE_PLATE_MAX_STD = 30;
/** A count digit's brightest channel clears this on either plate style. */
export const GATE_SCORE_MIN_MAX_BRIGHTNESS = 200;
/**
* Gate anchor: the match timer above the plates — white M:SS digits in a
* near-black box only the in-match HUD draws. In-match HUD reads <=30 on
* each dark probe; every other screen lights one past 70. Turf War and
* the death cam (one centered pill) also show a timer — there the plate
* probes and no-readable-count parse confirmation carry the rejection.
*/
export const TIMER_DIGIT_ROI: Roi = { x: 908, y: 54, w: 100, h: 40 };
export const TIMER_DARK_PROBES: readonly Roi[] = [
{ x: 915, y: 45, w: 80, h: 7 },
{ x: 897, y: 57, w: 8, h: 26 },
{ x: 1012, y: 57, w: 7, h: 26 },
];
export const GATE_TIMER_MAX_MEAN = 70;
export const GATE_TIMER_MIN_MAX_BRIGHTNESS = 240;
/**
* Timer's white M:SS digits measure 34px; the colon's dots stack under
* the digit height floor, so a plain height filter drops the colon.
*/
export const TIMER_TEXT_HEIGHT = 34;
export const TIMER_BIN_THRESHOLD = 160;
export const TIMER_DIGIT_MIN_CONF = 0.75;
export const TIMER_DIGIT_MIN_HEIGHT_RATIO = 0.82;
/**
* Count digits measure ~40-44px across attested plates; both scaled sets
* are tried, best trailing read wins (banner's settled/mid-pop pattern).
*/
export const SCORE_TEXT_HEIGHTS = [40, 44] as const;
export const PENALTY_TEXT_HEIGHT = 29;
/**
* Binarization thresholds vary with plate style (team-ink ~200+ on ~45
* black; white ~250 on team fills up to ~130 gray) — each read keeps its best.
*/
export const SCORE_BIN_THRESHOLDS = [160, 190] as const;
/** Penalty pill: white digits on the translucent dark fill (~100 gray). */
export const PENALTY_BIN_THRESHOLD = 170;
export const PENALTY_PROBE_MAX_MEAN = 165;
export const PENALTY_PROBE_MAX_STD = 30;
/**
* Control = plate fill is team-color style (measured over the probe
* strip): even dark inks (deep blue ~130 spread at ~50 brightness) stay
* saturated, while a non-controlling/neutral plate is unsaturated
* (attested fills >=112 vs <=19).
*/
export const CONTROL_PLATE_MIN_SATURATION = 60;

View File

@@ -0,0 +1,51 @@
/**
* The full detector registry — the single source of truth for "every
* detector that runs on a frame". New event types get added here and are
* picked up by the analyzer worker.
*/
import { createDeathDetector } from "./death/index";
import { createMapStartDetector } from "./map-start/index";
import { createMinimapDetector } from "./minimap/index";
import { createObjectiveDetector } from "./objective/index";
import {
createScoreboardDetector,
SCOREBOARD_EVENT_TYPE,
type ScoreboardResources,
} from "./scoreboard/index";
import {
createScoreboardBattleLogDetector,
SCOREBOARD_BATTLE_LOG_EVENT_TYPE,
} from "./scoreboard-battle-log/index";
import {
createScoreboardBattleLogReplayDetector,
SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE,
} from "./scoreboard-battle-log-replay/index";
import { createScoreboardOwnDetector } from "./scoreboard-own/index";
import type { Detector } from "./types";
/**
* Event types whose data is the full 8-player scoreboard shape
* (ScoreboardData): the results screen, the replay-browser detail, and the
* scoreboard-battle-log detail.
*/
export const SCOREBOARD_EVENT_TYPES: readonly string[] = [
SCOREBOARD_EVENT_TYPE,
SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE,
SCOREBOARD_BATTLE_LOG_EVENT_TYPE,
];
export function createAllDetectors(
resources: ScoreboardResources,
): Detector<unknown>[] {
return [
createScoreboardDetector(resources) as Detector<unknown>,
createScoreboardBattleLogReplayDetector(resources) as Detector<unknown>,
createScoreboardBattleLogDetector(resources) as Detector<unknown>,
createScoreboardOwnDetector(resources) as Detector<unknown>,
createDeathDetector(resources) as Detector<unknown>,
createMapStartDetector(resources) as Detector<unknown>,
createMinimapDetector(resources) as Detector<unknown>,
createObjectiveDetector(resources) as Detector<unknown>,
];
}

View File

@@ -0,0 +1,341 @@
/**
* DetectorScheduler: decides which detectors check frame t, and which pay
* for a parse. Three concerns:
*
* - Cadence: `searchIntervalS` while a gate fails (default 0.25s — VoDs cut
* screens to ~1s, below raw-gameplay lifetimes), dense `refineIntervalS`
* once it passes, `checkIntervalS` as a hard cap exempt from suppression.
* `nextDueT()` lets the caller skip a frame's readback when nothing's due.
* - Suppression: stops paying once a streak stagnates (`maxStagnantParses`
* + a time floor, since a gate can fire during a screen's entry animation
* before it's readable). `sufficientConfidence` suppresses immediately; a
* gate `signature` moving past `signatureTolerance` ends the streak
* (distinct browsed entries never drop those gates); `rearmCooldownS`
* adds a cooldown on top (death: animated-background flicker).
* - Activity: tracks last gate pass + open-match state for the VoD scanner
* to skip dead air.
*
* State keys to the scan's clock; `t` jumping backwards resets everything.
*/
import type { DetectedEvent } from "./types";
export interface SchedulingInfo {
id: string;
checkIntervalS?: number;
searchIntervalS?: number;
refineIntervalS?: number;
sufficientConfidence?: number;
rearmCooldownS?: number;
maxStagnantParses?: number;
}
export interface SchedulerOptions {
/**
* false = one-shot harness mode: every detector is due on every frame and
* parses are never suppressed
*/
suppressSteadyFrames: boolean;
/** check cadence while a detector's gate is passing (per-detector
* refineIntervalS overrides — for detectors whose parse is expensive
* enough that the dense default multiplies real cost) */
refineIntervalS: number;
/** check cadence while a detector's gate is failing (per-detector
* searchIntervalS overrides) */
searchIntervalS: number;
/** consecutive non-improving parses tolerated before suppression
* (per-detector maxStagnantParses overrides) */
maxStagnantParses: number;
/** seconds without improvement tolerated before suppression */
stagnantAfterS: number;
/** minimum confidence gain that counts as an improvement */
minImprovement: number;
/**
* a gate signature cell moving more than this since the streak's last
* parse means the screen's content changed and the streak resets
* (measured on battle log browsing: static-screen noise ≤2 per cell,
* entry flips ≥57)
*/
signatureTolerance: number;
/** seconds without any gate pass before the scan counts as calm */
quietAfterS: number;
/** a match opened this long ago without closing is assumed abandoned */
matchOpenMaxS: number;
/** event types that open a match (map-start) */
matchOpeningTypes: readonly string[];
/** event types that close a match (the scoreboard family) */
matchClosingTypes: readonly string[];
}
const DEFAULT_SCHEDULER_OPTIONS: SchedulerOptions = {
suppressSteadyFrames: true,
refineIntervalS: 0.15,
searchIntervalS: 0.25,
maxStagnantParses: 6,
// give a screen ~3s to animate in and produce its best read, whatever
// the sampling cadence
stagnantAfterS: 3,
minImprovement: 0.001,
signatureTolerance: 12,
quietAfterS: 15,
matchOpenMaxS: 8 * 60,
matchOpeningTypes: [],
matchClosingTypes: [],
};
/** events below this are too dubious to drive match-open/close state */
const MATCH_STATE_MIN_CONFIDENCE = 0.6;
/** tolerated backwards jitter in t before the session counts as restarted */
const RESET_TOLERANCE_S = 5;
const INTERVAL_EPSILON_S = 1e-6;
interface StreakState {
best: number;
stagnant: number;
lastImprovementT: number;
suppressed: boolean;
/** gate signature of the streak's last parsed frame */
signature: readonly number[] | undefined;
}
interface DetectorState {
info: SchedulingInfo;
lastCheckT: number | undefined;
gatePassing: boolean;
streak: StreakState | null;
/** signature reported by the latest passing gate */
lastGateSignature: readonly number[] | undefined;
/** parses skipped until this t after a sufficient read (rearmCooldownS) */
parseHoldUntilT: number;
}
export class DetectorScheduler {
#options: SchedulerOptions;
#states = new Map<string, DetectorState>();
#maxT = Number.NEGATIVE_INFINITY;
#lastActivityT = Number.NEGATIVE_INFINITY;
#matchOpenUntilT = Number.NEGATIVE_INFINITY;
constructor(
detectors: readonly SchedulingInfo[],
options: Partial<SchedulerOptions> = {},
) {
this.#options = { ...DEFAULT_SCHEDULER_OPTIONS, ...options };
for (const info of detectors) {
this.#states.set(info.id, freshState(info));
}
}
/** Drop all session state; `t` seeds the activity clock (chunk start). */
reset(t = Number.NEGATIVE_INFINITY): void {
for (const [id, state] of this.#states) {
this.#states.set(id, freshState(state.info));
}
this.#maxT = t;
this.#lastActivityT = t;
this.#matchOpenUntilT = Number.NEGATIVE_INFINITY;
}
/**
* Earliest t at which any detector wants a check — frames before it can
* skip analysis (and its canvas readback) entirely.
*/
nextDueT(): number {
let next = Number.POSITIVE_INFINITY;
for (const state of this.#states.values()) {
if (state.lastCheckT === undefined) return Number.NEGATIVE_INFINITY;
next = Math.min(next, state.lastCheckT + this.#interval(state));
}
return next;
}
/** Detector ids that should gate the frame at `t`. */
dueDetectors(t: number): string[] {
if (t + RESET_TOLERANCE_S < this.#maxT) this.reset(t);
// the first frame seeds the activity clock so a fresh session is never
// instantly calm — it has to earn its quiet period first
if (this.#lastActivityT === Number.NEGATIVE_INFINITY) {
this.#lastActivityT = t;
}
this.#maxT = Math.max(this.#maxT, t);
const due: string[] = [];
for (const [id, state] of this.#states) {
if (state.lastCheckT === undefined) {
due.push(id);
continue;
}
if (t - state.lastCheckT >= this.#interval(state) - INTERVAL_EPSILON_S) {
due.push(id);
}
}
return due;
}
/** Report a gate outcome for a detector this scheduler marked due. */
recordGate(
id: string,
t: number,
pass: boolean,
signature?: readonly number[],
): void {
const state = this.#states.get(id);
if (!state) return;
state.lastCheckT = t;
state.gatePassing = pass;
if (!pass) {
state.streak = null;
return;
}
this.#lastActivityT = Math.max(this.#lastActivityT, t);
if (
signature &&
state.streak?.signature &&
signaturesDiffer(
signature,
state.streak.signature,
this.#options.signatureTolerance,
)
) {
state.streak = null;
}
state.lastGateSignature = signature;
}
/** Whether the (passed) gate should be followed by a parse at `t`. */
shouldParse(id: string, t: number): boolean {
if (!this.#options.suppressSteadyFrames) return true;
const state = this.#states.get(id);
if (!state) return true;
if (state.info.checkIntervalS !== undefined) return true;
if (t < state.parseHoldUntilT) return false;
return !state.streak?.suppressed;
}
/** Report the outcome of a parse this scheduler approved. */
recordParse(
id: string,
t: number,
events: readonly Pick<DetectedEvent, "type" | "confidence">[],
): void {
this.#recordMatchState(t, events);
const state = this.#states.get(id);
if (!state || state.info.checkIntervalS !== undefined) return;
// no events counts as confidence 0: a false-firing gate on a static
// screen stagnates and gets suppressed just like a parsed one
const confidence = events.reduce(
(max, e) => Math.max(max, e.confidence),
0,
);
const { sufficientConfidence, rearmCooldownS } = state.info;
if (
sufficientConfidence !== undefined &&
confidence >= sufficientConfidence
) {
state.streak = {
best: confidence,
stagnant: 0,
lastImprovementT: t,
suppressed: true,
signature: state.lastGateSignature,
};
if (rearmCooldownS !== undefined) {
state.parseHoldUntilT = t + rearmCooldownS;
}
return;
}
if (!state.streak) {
state.streak = {
best: confidence,
stagnant: 0,
lastImprovementT: t,
suppressed: false,
signature: state.lastGateSignature,
};
return;
}
const streak = state.streak;
streak.signature = state.lastGateSignature;
if (confidence > streak.best + this.#options.minImprovement) {
streak.best = confidence;
streak.stagnant = 0;
streak.lastImprovementT = t;
return;
}
streak.stagnant += 1;
const maxStagnant =
state.info.maxStagnantParses ?? this.#options.maxStagnantParses;
if (
streak.stagnant >= maxStagnant &&
t - streak.lastImprovementT >= this.#options.stagnantAfterS
) {
streak.suppressed = true;
}
}
/**
* True when the footage at `t` is dead air as far as detection goes: no
* gate has passed for quietAfterS and no match is open — the VoD scanner
* may skim by keyframes instead of decoding densely.
*/
calm(t: number): boolean {
if (!this.#options.suppressSteadyFrames) return false;
return (
t - this.#lastActivityT >= this.#options.quietAfterS &&
t >= this.#matchOpenUntilT
);
}
#interval(state: DetectorState): number {
if (!this.#options.suppressSteadyFrames) return 0;
const { info } = state;
if (info.checkIntervalS !== undefined) return info.checkIntervalS;
const search = info.searchIntervalS ?? this.#options.searchIntervalS;
// while suppressed only the gate keeps running (to spot the screen
// changing), which the sparser search cadence covers
if (state.streak?.suppressed) return search;
if (!state.gatePassing) return search;
return info.refineIntervalS ?? this.#options.refineIntervalS;
}
#recordMatchState(
t: number,
events: readonly Pick<DetectedEvent, "type" | "confidence">[],
): void {
for (const event of events) {
if (event.confidence < MATCH_STATE_MIN_CONFIDENCE) continue;
if (this.#options.matchOpeningTypes.includes(event.type)) {
this.#matchOpenUntilT = Math.max(
this.#matchOpenUntilT,
t + this.#options.matchOpenMaxS,
);
} else if (this.#options.matchClosingTypes.includes(event.type)) {
this.#matchOpenUntilT = Math.min(this.#matchOpenUntilT, t);
}
}
}
}
function freshState(info: SchedulingInfo): DetectorState {
return {
info,
lastCheckT: undefined,
gatePassing: false,
streak: null,
lastGateSignature: undefined,
parseHoldUntilT: Number.NEGATIVE_INFINITY,
};
}
function signaturesDiffer(
a: readonly number[],
b: readonly number[],
tolerance: number,
): boolean {
if (a.length !== b.length) return true;
for (let i = 0; i < a.length; i++) {
if (Math.abs(a[i]! - b[i]!) > tolerance) return true;
}
return false;
}

View File

@@ -0,0 +1,134 @@
/**
* Replay code recognition ("R6KE-DO64-3CXD-XVKL"): bright green text under
* the team panels. Green on the dark background lands below the default
* grayscale binarization threshold, so recognition runs on the green
* channel, where the glyphs are near-white.
*/
import { getCV, type Mat } from "../../cv";
import {
type GlyphSet,
type RecognizedText,
recognizeText,
} from "../../glyphs";
import { cropRoi } from "../../image";
import { REPLAY_CODE_ROI } from "./rois";
/**
* FOT-RowdyStd's 'Q' is a '0' bowl with a small tail below the baseline;
* the bowl dominates template correlation, so a real Q ranks as '0' by a
* hair. Like the P/p rule in scoreboard/names.ts, the segment geometry
* decides what the templates cannot: a 0/O read whose ink reaches well
* below the line's baseline (the median ink bottom of the other
* alphanumerics) is a Q.
*/
const Q_TWINS = new Set(["0", "O"]);
const Q_DESCENT_MIN_PX = 4;
function resolveQsByDescent(raw: RecognizedText): string {
const anchors = raw.chars
.filter((c) => !Q_TWINS.has(c.char) && c.char !== "-")
.map((c) => c.y1)
.sort((a, b) => a - b);
if (anchors.length === 0) return raw.text;
const baseline = anchors[Math.floor(anchors.length / 2)]!;
return raw.chars
.map((c) =>
Q_TWINS.has(c.char) && c.y1 - baseline >= Q_DESCENT_MIN_PX ? "Q" : c.char,
)
.join("");
}
/**
* On blurry captures a narrow 'L' template can edge out the true 'U' by a
* hair. Segment geometry decides what templates can't: a U's right stroke
* fills the segment's top-right quadrant, where an L has no ink (measured
* 0.00-0.15 leak on true Ls vs 0.27+ on Us across fixtures). The margin only
* skips confident reads and stays generous, since no true L on the fixtures
* carries a 'U' candidate at all.
*/
const LU_SCORE_MARGIN = 0.12;
const LU_INK_THRESHOLD = 150;
const LU_TOP_RIGHT_MIN_FRACTION = 0.2;
function resolveUsByTopRightInk(
raw: RecognizedText,
green: Mat,
): RecognizedText {
const { cols, data } = green;
const chars = raw.chars.map((c) => {
if (c.char !== "L") return c;
const u = c.candidates?.find((k) => k.char === "U");
if (!u || c.score - u.score > LU_SCORE_MARGIN) return c;
const xMid = Math.ceil((c.x0 + c.x1) / 2);
const yMid = Math.floor((c.y0 + c.y1) / 2);
let ink = 0;
let total = 0;
for (let y = c.y0; y < yMid; y++) {
for (let x = xMid; x < c.x1; x++) {
total++;
if (data[y * cols + x]! > LU_INK_THRESHOLD) ink++;
}
}
return total > 0 && ink / total >= LU_TOP_RIGHT_MIN_FRACTION
? { ...c, char: "U" }
: c;
});
return { ...raw, chars, text: chars.map((c) => c.char).join("") };
}
export interface ParsedReplayCode {
/** normalized "XXXX-XXXX-XXXX-XXXX", or null when the shape is wrong */
code: string | null;
/** min glyph score across recognized characters */
confidence: number;
raw: RecognizedText;
}
const CODE_RE = /^[0-9A-Z]{4}(-[0-9A-Z]{4}){3}$/;
/**
* Restrict a glyph set to the characters codes can contain — a shallow
* view over the same template mats, so dispose only the source set.
*/
export function codeCharsetOf(set: GlyphSet): GlyphSet {
const glyphs = set.glyphs.filter((g) => /^[0-9A-Z-]$/.test(g.char));
const widths = glyphs.map((g) => g.mat.cols).sort((a, b) => a - b);
return {
glyphs,
height: set.height,
medianWidth: widths[Math.floor(widths.length / 2)] ?? set.medianWidth,
};
}
/** rgb: full normalized frame in RGB (not RGBA). */
export function parseReplayCode(rgb: Mat, glyphs: GlyphSet): ParsedReplayCode {
const cv = getCV();
const view = cropRoi(rgb, REPLAY_CODE_ROI);
const channels = new cv.MatVector();
cv.split(view, channels);
const g = channels.get(1);
const green = new cv.Mat();
g.copyTo(green);
g.delete();
channels.delete();
view.delete();
const raw = recognizeText(green, glyphs, {
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
const resolved = resolveUsByTopRightInk(raw, green);
green.delete();
let text = resolveQsByDescent(resolved).toUpperCase();
// Dashes are thin and can drop out of segmentation; a clean 16-char
// alphanumeric read is unambiguous, so re-insert them.
if (/^[0-9A-Z]{16}$/.test(text)) {
text = text.replace(/(.{4})(?=.)/g, "$1-");
}
return {
code: CODE_RE.test(text) ? text : null,
confidence: raw.confidence,
raw,
};
}

View File

@@ -0,0 +1,184 @@
/**
* Replay-browser header parsing. Same black auto-sized tag style as the
* live scoreboard header, but different content: the top line holds the
* recording timestamp ("3/7/2026 22:28") followed by the stage, the bottom
* line the lobby (bold) followed by the mode.
*
* The timestamp is locale-formatted and open-ended, so it is validated by
* shape and kept as a raw string; stage and lobby+mode snap to the closed
* sets shared with the live header.
*/
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { ScannerLobby } from "../../../scanner-types";
import type { Roi } from "../../canonical";
import type { Mat } from "../../cv";
import type { GlyphSet } from "../../glyphs";
import { ALL_STAGE_ENTRIES, LOBBY_MODE_COMBOS } from "../../localized";
import { closestBy } from "../../text";
import { readTagBand } from "../scoreboard/header";
import { HEADER_BOTTOM_BAND, HEADER_TOP_BAND } from "./rois";
export interface ParsedReplayHeader {
timestamp: string | null;
stage: StageId | null;
lobby: ScannerLobby | null;
mode: ModeShort | null;
/** min of the closed-set match scores that were attempted */
confidence: number;
debug: {
topReading: string;
bottomReading: string;
stageScore: number;
bottomScore: number;
};
}
const MIN_MATCH_SCORE = 0.62;
/**
* Lifted-blacks captures (720p streams upscaled and re-encoded) raise the
* tag background to ~80-115 gray, above readTagBand's default dark ceiling,
* so the tag-extent trim truncates the band to a sliver and the read comes
* back empty. A band whose closed-set snap fails is re-read with this
* ceiling; the retry is adopted only when it snaps at least as well.
*/
const TAG_DARK_MAX_LIFTED = 120;
/**
* "3/7/2026 22:28" and friends; capture the rest of the line (the stage).
* The console formats the date per locale — "7.3.2026" (de), "2026/3/7"
* (ja) — so any . / - separated triple followed by a time is accepted.
* Adjacent skinny time digits can read with a spurious gap on compressed
* captures ("14:1 1"), so a lone space is tolerated between them and
* stripped when the timestamp is assembled. Not left-anchored: the
* battle log line leads with a rank icon on ranked lobbies, which reads as
* a junk glyph before the date.
*/
const TIMESTAMP_RE =
/(\d{1,4}[./-]\d{1,2}[./-]\d{1,4})\s+(\d(?: ?\d)?: ?\d ?\d)\s*(.*)$/;
interface TopBandParse {
reading: string;
timestamp: string | null;
stage: StageId | null;
stageScore: number;
}
/**
* In the BlitzMain glyphs the digit forms are near-identical to their letter
* lookalikes (0/O, the 1/I/l/| bars), so a stage read can surface the digit
* ("R0M-en") and burn an edit the snap threshold cannot spare. Stage names
* are digit-free in every language (sole exception carries a '9'), so digits
* and bars fold to letters before snapping.
*/
function foldDigitLookalikes(s: string): string {
return s.replace(/0/g, "o").replace(/[1|]/g, "l");
}
function parseTopBand(reading: string): TopBandParse {
let timestamp: string | null = null;
let stage: StageId | null = null;
let stageScore = 0;
// The top band reads with the BlitzMain name glyphs, where 1/I/l/| are
// identical bars ("I9:04") and O rides a hair above 0 ("2O26"); in the
// digits-only timestamp every bar is a '1' and every O a '0'. Match on
// the normalized line, keep the stage part's original reading (the
// replacements are 1:1, so offsets line up).
const normalized = reading.replace(/[Il|]/g, "1").replace(/O/g, "0");
const m = TIMESTAMP_RE.exec(normalized);
const stageReading = m
? reading.slice(reading.length - m[3]!.length)
: reading;
if (m) timestamp = `${m[1]!} ${m[2]!.replace(/ /g, "")}`;
if (stageReading) {
const match = closestBy(
foldDigitLookalikes(stageReading),
ALL_STAGE_ENTRIES,
(e) => e.text,
);
if (match) {
stageScore = match.score;
if (match.score >= MIN_MATCH_SCORE) stage = match.entry.stageId;
}
}
return { reading, timestamp, stage, stageScore };
}
/** The two header tag bands; the battle log passes its own coordinates. */
export interface ReplayHeaderBands {
top: Roi;
bottom: Roi;
/**
* see TagBandOptions.tagLeadInMax; the battle log tags are not
* left-anchored
*/
tagLeadInMax?: number;
/** see TagBandOptions.tagColumnFraction; the battle log tags are tilted */
tagColumnFraction?: number;
}
const REPLAY_BANDS: ReplayHeaderBands = {
top: HEADER_TOP_BAND,
bottom: HEADER_BOTTOM_BAND,
};
export function parseReplayHeader(
gray: Mat,
topGlyphs: GlyphSet,
bottomGlyphs: GlyphSet,
bands: ReplayHeaderBands = REPLAY_BANDS,
): ParsedReplayHeader {
const leadIn = {
tagLeadInMax: bands.tagLeadInMax,
tagColumnFraction: bands.tagColumnFraction,
};
let top = parseTopBand(readTagBand(gray, bands.top, topGlyphs, leadIn));
if (top.stage === null) {
const retry = parseTopBand(
readTagBand(gray, bands.top, topGlyphs, {
...leadIn,
tagDarkMax: TAG_DARK_MAX_LIFTED,
}),
);
if (retry.stageScore >= top.stageScore) top = retry;
}
let bottomReading = readTagBand(gray, bands.bottom, bottomGlyphs, leadIn);
let bottomMatch = bottomReading
? closestBy(bottomReading, LOBBY_MODE_COMBOS, (c) => c.text)
: null;
if (!bottomMatch || bottomMatch.score < MIN_MATCH_SCORE) {
const reading = readTagBand(gray, bands.bottom, bottomGlyphs, {
...leadIn,
tagDarkMax: TAG_DARK_MAX_LIFTED,
});
const match = reading
? closestBy(reading, LOBBY_MODE_COMBOS, (c) => c.text)
: null;
if ((match?.score ?? 0) >= (bottomMatch?.score ?? 0)) {
bottomReading = reading;
bottomMatch = match;
}
}
let lobby: ScannerLobby | null = null;
let mode: ModeShort | null = null;
if (bottomMatch && bottomMatch.score >= MIN_MATCH_SCORE) {
lobby = bottomMatch.entry.lobby;
mode = bottomMatch.entry.mode;
}
return {
timestamp: top.timestamp,
stage: top.stage,
lobby,
mode,
confidence: Math.min(top.stageScore, bottomMatch?.score ?? 0),
debug: {
topReading: top.reading,
bottomReading,
stageScore: top.stageScore,
bottomScore: bottomMatch?.score ?? 0,
},
};
}

View File

@@ -0,0 +1,460 @@
/**
* ScoreboardBattleLogReplayDetector: parses the replay-browser detail
* screen — the same match data as the live scoreboard (header, team scores,
* 8 player rows) plus the recording timestamp and the replay code.
*
* Layout differs from the live scoreboard: the two team panels sit side by
* side and the replay owner's team may be on either side, so the
* VICTORY/DEFEAT panel tags are read to keep `players`/`matchScores`
* ordered winners-first like the live event. Field parsing reuses the
* scoreboard helpers with glyph sets rescaled to this screen's text sizes.
*/
import { getCV, type Mat } from "../../cv";
import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs";
import {
cropRoi,
maxBrightness,
maxChannel,
meanBrightness,
type Roi,
roiSignature,
} from "../../image";
import { RESULT_TAG_ENTRIES } from "../../localized";
import { closestBy } from "../../text";
import {
FULL_COUNT_TEAM_SCORE,
KO_MATCH_SCORE,
MATCH_SCORE_MIN_CONF,
} from "../scoreboard/banner";
import { type ParsedNumber, parseNumber } from "../scoreboard/digits";
import type {
ScoreboardData,
ScoreboardPlayer,
ScoreboardResources,
ScoreboardRowDebug,
} from "../scoreboard/index";
import { findPovIndex } from "../scoreboard/pov";
import { parseScoreboardRow, type RowRois } from "../scoreboard/row";
import type { DetectedEvent, Detector, GateResult } from "../types";
import { codeCharsetOf, type ParsedReplayCode, parseReplayCode } from "./code";
import { type ParsedReplayHeader, parseReplayHeader } from "./header";
import {
CODE_TEXT_HEIGHT,
GATE_CODE_BLUE_MAX,
GATE_CODE_GREEN_MIN,
GATE_CODE_MIN_FRACTION,
GATE_FLAT_MAX_MEAN,
GATE_FLAT_MIN_MEAN,
GATE_GAP_MAX_MEAN,
GATE_GAP_PROBES,
GATE_TEXT_MIN_MAX,
gateFlatProbe,
HEADER_LINE_HEIGHT,
HEADER_TIMESTAMP_HEIGHT,
HEADER_TOP_BAND,
MATCH_SCORE_DIGIT_HEIGHT,
MATCH_SCORE_ROIS,
NAME_TEXT_HEIGHT,
nameRoi,
PAINT_DIGIT_HEIGHT,
PANEL_XS,
paintRoi,
paintSuffixRoi,
povArrowRoi,
REPLAY_CODE_ROI,
RESULT_TAG_TEXT_HEIGHT,
ROW_CENTERS,
resultTagRoi,
STAT_DIGIT_HEIGHT,
specialIconRoi,
statRoi,
TEAM_DIGIT_HEIGHT,
teamScoreRoi,
weaponRoi,
} from "./rois";
export interface ScoreboardBattleLogReplayData extends ScoreboardData {
/** recording timestamp as shown, e.g. "3/7/2026 22:28"; locale-formatted */
timestamp: string | null;
/** "XXXX-XXXX-XXXX-XXXX" */
replayCode: string | null;
}
export const SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE =
"ScoreboardBattleLogReplay";
/** Replay pills are mid-gray (~61), not near-black; see matchWeapon docs. */
const REPLAY_INK_THRESHOLD = 90;
/**
* White banner digits on saturated team color: applies to the "Score:"
* banners AND the team totals — a green DEFEAT panel weighs in at ~184
* on the green-heavy grayscale, above the default 150.
*/
const BANNER_BIN_THRESHOLD = 190;
/** Canonical results the localized VICTORY/DEFEAT panel tags snap to. */
type PanelResult = "VICTORY" | "DEFEAT";
const RESULT_MIN_SCORE = 0.6;
/**
* The chunky outlined tag letters bridge at the default 150 on the
* max-channel image; 190 keeps the cores separated (and drops the gray
* gear/signal icons trailing the text).
*/
const RESULT_TAG_BIN_THRESHOLD = 190;
interface PanelParse {
players: ScoreboardPlayer[];
rows: ScoreboardRowDebug[];
teamScore: ParsedNumber | null;
matchScore: ParsedNumber | null;
result: PanelResult | null;
resultReading: string;
resultScore: number;
confidences: number[];
}
/** Fraction of ROI pixels matching the replay code's green (RGBA frame). */
function greenFraction(frame: Mat, roi: Roi): number {
const cv = getCV();
const view = cropRoi(frame, roi);
const cont = new cv.Mat();
view.copyTo(cont);
view.delete();
const d = cont.data;
const n = cont.rows * cont.cols;
let green = 0;
for (let i = 0; i < n; i++) {
if (
d[i * 4 + 1]! > GATE_CODE_GREEN_MIN &&
d[i * 4 + 2]! < GATE_CODE_BLUE_MAX
)
green++;
}
cont.delete();
return n > 0 ? green / n : 0;
}
export function createScoreboardBattleLogReplayDetector(
resources: ScoreboardResources,
): Detector<ScoreboardBattleLogReplayData> {
const cv = getCV();
const scaled = (set: GlyphSet | null, height: number): GlyphSet | null =>
set ? scaleGlyphSet(set, height / set.height) : null;
const nameGlyphs = scaled(resources.nameGlyphs, NAME_TEXT_HEIGHT);
const paintDigits = scaled(resources.paintDigits, PAINT_DIGIT_HEIGHT);
const statDigits = scaled(resources.statDigits, STAT_DIGIT_HEIGHT);
const teamBase = resources.teamDigits ?? resources.paintDigits;
const teamDigits = scaled(teamBase, TEAM_DIGIT_HEIGHT);
const matchScoreDigits = scaled(teamBase, MATCH_SCORE_DIGIT_HEIGHT);
/** Timestamp needs digits + '/' + ':' — only the names atlas has them. */
const headerTopGlyphs = scaled(resources.nameGlyphs, HEADER_TIMESTAMP_HEIGHT);
const headerBottomGlyphs = scaled(
resources.headerLineGlyphs,
HEADER_LINE_HEIGHT,
);
// Code and result tags render in FOT-RowdyStd — use the dedicated atlases
// when present; the BlitzMain-based fallbacks read them only roughly.
const resultGlyphs =
scaled(resources.replayResultGlyphs ?? null, RESULT_TAG_TEXT_HEIGHT) ??
scaled(resources.headerLineGlyphs, RESULT_TAG_TEXT_HEIGHT);
const codeGlyphs = resources.replayCodeGlyphs
? scaled(resources.replayCodeGlyphs, CODE_TEXT_HEIGHT)
: resources.nameGlyphs
? scaleGlyphSet(
codeCharsetOf(resources.nameGlyphs),
CODE_TEXT_HEIGHT / resources.nameGlyphs.height,
)
: null;
function gate(frame: Mat): GateResult {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
let flatOk = 0;
let suffixOk = 0;
for (const dx of PANEL_XS) {
for (const cy of ROW_CENTERS) {
const flat = meanBrightness(frame, gateFlatProbe(cy, dx));
if (flat >= GATE_FLAT_MIN_MEAN && flat <= GATE_FLAT_MAX_MEAN) flatOk++;
if (maxBrightness(gray, paintSuffixRoi(cy, dx)) > GATE_TEXT_MIN_MAX)
suffixOk++;
}
}
let gapOk = 0;
for (const roi of GATE_GAP_PROBES) {
if (meanBrightness(frame, roi) < GATE_GAP_MAX_MEAN) gapOk++;
}
const codeFraction = greenFraction(frame, REPLAY_CODE_ROI);
const rowCount = PANEL_XS.length * ROW_CENTERS.length;
const score =
(flatOk / rowCount +
suffixOk / rowCount +
gapOk / GATE_GAP_PROBES.length +
Math.min(1, codeFraction / (2 * GATE_CODE_MIN_FRACTION))) /
4;
const pass =
flatOk >= 7 &&
suffixOk >= 7 &&
gapOk === 2 &&
codeFraction >= GATE_CODE_MIN_FRACTION;
// browsing flips between replays never drop this gate, so it
// fingerprints the content that always differs between two battles
// (recording timestamp band + replay code) plus the name columns —
// the scheduler re-arms suppression when the fingerprint moves
const signature = pass ? contentSignature(gray) : undefined;
gray.delete();
return { pass, score, signature };
}
function contentSignature(gray: Mat): number[] {
const signature = roiSignature(gray, HEADER_TOP_BAND, 32, 2);
signature.push(...roiSignature(gray, REPLAY_CODE_ROI, 32, 1));
for (const dx of PANEL_XS) {
for (const cy of ROW_CENTERS) {
signature.push(...roiSignature(gray, nameRoi(cy, dx), 8, 1));
}
}
return signature;
}
function parsePanel(gray: Mat, rgb: Mat, dx: number): PanelParse {
const players: ScoreboardPlayer[] = [];
const rows: ScoreboardRowDebug[] = [];
const confidences: number[] = [];
const rowRois: RowRois = {
weapon: (cy) => weaponRoi(cy, dx),
specialIcon: (cy) => specialIconRoi(cy, dx),
paint: (cy) => paintRoi(cy, dx),
name: (cy) => nameRoi(cy, dx),
stat: (cy, i) => statRoi(cy, dx, i),
povArrow: (cy) => povArrowRoi(cy, dx),
};
const rowResources = {
weapons: resources.weapons,
specials: resources.specials,
paintDigits,
statDigits,
nameGlyphs,
};
for (const cy of ROW_CENTERS) {
// A short team (e.g. a 7-player private battle) renders no pill for
// the unused bottom row — just near-black panel background where the
// flat probe expects the mid-gray pill (the gate's flatOk >= 7 already
// tolerates the missing row). Skip it: no phantom player.
const flat = meanBrightness(rgb, gateFlatProbe(cy, dx));
if (flat < GATE_FLAT_MIN_MEAN || flat > GATE_FLAT_MAX_MEAN) continue;
// replay rows render smaller (icons ~26px, inside the live template
// set's slide range) on a lighter panel; the paint number is
// left-aligned so the "p" suffix lands inside the ROI on short paints
const row = parseScoreboardRow(
gray,
rgb,
cy,
rowRois,
rowResources,
confidences,
{
weaponInkThreshold: REPLAY_INK_THRESHOLD,
paintDropLoweredTrailing: true,
},
);
players.push(row.player);
rows.push(row.debug);
}
// The panel's point total is read only to recognize a knockout below
// (the count times five: only a knockout's full count reaches 500);
// it is never emitted as a score.
let teamScore: ParsedNumber | null = null;
if (teamDigits) {
const crop = cropRoi(gray, teamScoreRoi(dx));
teamScore = parseNumber(crop, teamDigits, {
binThreshold: BANNER_BIN_THRESHOLD,
});
crop.delete();
confidences.push(teamScore.confidence);
}
let matchScore: ParsedNumber | null = null;
if (matchScoreDigits) {
const crop = cropRoi(gray, MATCH_SCORE_ROIS[dx === 0 ? 0 : 1]!);
matchScore = parseNumber(crop, matchScoreDigits, {
binThreshold: BANNER_BIN_THRESHOLD,
});
if (
matchScore.confidence < MATCH_SCORE_MIN_CONF ||
(matchScore.value !== null && matchScore.value > KO_MATCH_SCORE)
) {
matchScore = { ...matchScore, value: null };
}
crop.delete();
confidences.push(matchScore.confidence);
// No number under the floor + a full team count = the KNOCKOUT! burst
// sitting where the banner's score would be. Report the count it won at
// rather than a hole; an unreadable banner on a lesser total stays null.
if (
matchScore.value === null &&
teamScore?.value === FULL_COUNT_TEAM_SCORE
) {
matchScore = { ...matchScore, value: KO_MATCH_SCORE };
}
}
let result: PanelParse["result"] = null;
let resultReading = "";
let resultScore = 0;
if (resultGlyphs) {
const bright = maxChannel(rgb, resultTagRoi(dx));
const raw = recognizeText(bright, resultGlyphs, {
binThreshold: RESULT_TAG_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.25,
});
bright.delete();
resultReading = raw.text;
if (resultReading) {
const match = closestBy(
resultReading,
RESULT_TAG_ENTRIES,
(e) => e.text,
);
if (match) {
resultScore = match.score;
if (match.score >= RESULT_MIN_SCORE)
result = match.entry.canonical as PanelResult;
}
}
}
return {
players,
rows,
teamScore,
matchScore,
result,
resultReading,
resultScore,
confidences,
};
}
function parse(
frame: Mat,
t: number,
): DetectedEvent<ScoreboardBattleLogReplayData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const [left, right] = PANEL_XS.map((dx) => parsePanel(gray, rgb, dx)) as [
PanelParse,
PanelParse,
];
// Winners first. Trust a confident VICTORY/DEFEAT tag read; when the
// tags are inconclusive, the higher "Score:" banner marks the winner
// (the shown match score decides the game). Default to left otherwise.
let swapped = false;
if (left.result !== null || right.result !== null) {
swapped = left.result === "DEFEAT" || right.result === "VICTORY";
} else if (
left.matchScore?.value != null &&
right.matchScore?.value != null
) {
swapped = right.matchScore.value > left.matchScore.value;
}
const [winner, loser] = swapped ? [right, left] : [left, right];
// POV arrow row, indexed into the winners-first players ordering
const povIndex = findPovIndex(
[...winner.rows, ...loser.rows].map((r) => r.povFraction),
);
let header: ParsedReplayHeader | null = null;
if (headerTopGlyphs && headerBottomGlyphs) {
header = parseReplayHeader(gray, headerTopGlyphs, headerBottomGlyphs);
}
let code: ParsedReplayCode | null = null;
if (codeGlyphs) {
code = parseReplayCode(rgb, codeGlyphs);
}
gray.delete();
rgb.delete();
const confidences = [
...winner.confidences,
...loser.confidences,
...(header ? [header.confidence] : []),
...(code ? [code.confidence] : []),
];
const confidence =
confidences.length > 0
? confidences.reduce((a, b) => a + b, 0) / confidences.length
: 0;
return [
{
type: SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE,
t,
confidence,
data: {
lobby: header?.lobby ?? null,
mode: header?.mode ?? null,
stage: header?.stage ?? null,
timestamp: header?.timestamp ?? null,
replayCode: code?.code ?? null,
matchScores: [
winner.matchScore?.value ?? null,
loser.matchScore?.value ?? null,
],
players: [...winner.players, ...loser.players],
povIndex,
},
debug: {
rows: [...winner.rows, ...loser.rows],
teamScoreConf: [
winner.teamScore?.confidence ?? 0,
loser.teamScore?.confidence ?? 0,
],
matchScoreConf: [
winner.matchScore?.confidence ?? 0,
loser.matchScore?.confidence ?? 0,
],
header: header?.debug,
codeRaw: code?.raw.text,
winnerSide: swapped ? "right" : "left",
resultTags: {
left: {
reading: left.resultReading,
score: left.resultScore,
result: left.result,
},
right: {
reading: right.resultReading,
score: right.resultScore,
result: right.result,
},
},
},
},
];
}
// no rearm cooldown — distinct replays browsed in quick succession are
// told apart by content. sufficientConfidence just under the measured
// clean-read floor (fixtures 0.808-0.890)
return {
id: "scoreboard-battle-log-replay",
sufficientConfidence: 0.8,
gate,
parse,
};
}

View File

@@ -0,0 +1,155 @@
/**
* ALL scoreboard-battle-log-replay ROI coordinates, in canonical 1920x1080
* space. Calibrated against scoreboard-battle-log-replay/private-battle-
* splat-zones-hagglefish via tools/overlay-rois.ts and column-projection
* measurement.
*
* The replay-browser detail screen shows two team panels SIDE BY SIDE
* (right = left shifted by PANEL_DX), four gray pill rows each — mid-gray
* (~61) with darker gaps (~23), unlike the live scoreboard's near-black
* pills (~12). Below the panels: replay owner line + bright green replay
* code; above: two "Score:" banners and the stage-photo header (line 1 =
* timestamp + stage, line 2 = lobby + mode).
*/
import type { Roi } from "../../canonical";
/** Vertical centers of the 4 player rows within each panel. */
export const ROW_CENTERS = [573, 654, 735, 816] as const;
/** Horizontal shift from a left-panel ROI to its right-panel twin. */
export const PANEL_DX = 676;
/** dx per panel: [left (index 0), right (index 1)]. */
export const PANEL_XS = [0, PANEL_DX] as const;
/**
* Weapon icon search region — full pill height so the largest template
* (64; icons render ~60-64px) fits with slide room (matchTemplate skips
* taller templates).
*/
export function weaponRoi(cy: number, dx: number): Roi {
return { x: 522 + dx, y: cy - 34, w: 96, h: 68 };
}
/**
* Special-weapon icon on a black disc above the third stat counter (~25x29
* art at x 1109-1134, y cy-29..cy). Only read to break near-tied weapon
* matches with different specials; box stays inside the disc since the
* mid-gray pill around it sits above matchSpecial's ink threshold.
*/
export function specialIconRoi(cy: number, dx: number): Roi {
return { x: 1104 + dx, y: cy - 32, w: 38, h: 33 };
}
/**
* Player name text region (white, left-aligned; descenders reach cy+18).
* Long names run into the paint column; parse paint first and trim at its
* leftmost digit. Kept narrower than the first glyph to dodge weapon-icon bleed.
*/
export function nameRoi(cy: number, dx: number): Roi {
return { x: 620 + dx, y: cy - 16, w: 226, h: 37 };
}
/**
* Paint digits, LEFT-aligned from x~843 (~18px pitch); the trailing "p"
* moves with digit count, so short (3-digit) paints put it inside this
* region — parseNumber's digit-only charset drops it.
*/
export function paintRoi(cy: number, dx: number): Roi {
return { x: 821 + dx, y: cy - 15, w: 88, h: 34 };
}
/**
* Gate anchor over the "p" after the paint number. Since it's left-aligned,
* "p" position tracks digit count (x 898-907 after 3 digits, 915-924
* after 4) — the probe spans both.
*/
export function paintSuffixRoi(cy: number, dx: number): Roi {
return { x: 896 + dx, y: cy - 13, w: 31, h: 26 };
}
/**
* Stat counter digits (zero-padded, "x" prefix excluded), cy+4..cy+23 —
* the top edge must stay below the stat icons, which bleed ink above it.
*/
export function statRoi(cy: number, dx: number, index: 0 | 1 | 2): Roi {
const x = [1000, 1057, 1114][index]!;
return { x: x + dx, y: cy + 3, w: 36, h: 24 };
}
/**
* POV arrow: smaller replay-browser arrow on the pill's left edge (x
* 487-530). Right edge stays short of the weapon-icon region (x 522+).
*/
export function povArrowRoi(cy: number, dx: number): Roi {
return { x: 480 + dx, y: cy - 32, w: 54, h: 56 };
}
/** Team totals ("440p") on the VICTORY/DEFEAT banner, digits ending x~1119. */
export function teamScoreRoi(dx: number): Roi {
return { x: 1040 + dx, y: 481, w: 86, h: 36 };
}
/** VICTORY / DEFEAT tag on each panel banner — decides which panel won (owner's team may sit either side). */
export function resultTagRoi(dx: number): Roi {
return { x: 540 + dx, y: 460, w: 220, h: 50 };
}
/** The colored "Score: NN" banners; digits after the constant label. */
export const MATCH_SCORE_ROIS: readonly [Roi, Roi] = [
{ x: 742, y: 340, w: 130, h: 56 },
{ x: 1620, y: 340, w: 130, h: 56 },
];
/**
* Header bands on the stage-photo banner: line 1 timestamp + stage tag,
* line 2 lobby + mode. Tags size to their text; the header parser trims
* each band to the tag extent.
*/
export const HEADER_TOP_BAND: Roi = { x: 500, y: 68, w: 560, h: 46 };
/**
* Wide lobby tags push the mode tag right ("Anarchy Battle (Open)" +
* "Rainmaker" ends x~1134), so the band runs past the longest observed pair.
*/
export const HEADER_BOTTOM_BAND: Roi = { x: 500, y: 124, w: 700, h: 58 };
/**
* Bright green replay code line ("XXXX-XXXX-XXXX-XXXX"), left-aligned
* after the magnifier icon. Width tracks the glyphs, so a wide-letter
* code can run past x=913 — box extends into the background to fit it.
*/
export const REPLAY_CODE_ROI: Roi = { x: 574, y: 960, w: 400, h: 38 };
/** Gate probe: flat pill background strip between paint "p" and first stat "x" — mid-gray here, not near-black. */
export function gateFlatProbe(cy: number, dx: number): Roi {
return { x: 930 + dx, y: cy - 10, w: 42, h: 20 };
}
/** Dark gap between row 1 and row 2 pills, one strip per panel. */
export const GATE_GAP_PROBES: readonly Roi[] = [
{ x: 560, y: 611, w: 540, h: 5 },
{ x: 560 + PANEL_DX, y: 611, w: 540, h: 5 },
];
/** Flat pill strips must sit in this mid-gray band. */
export const GATE_FLAT_MIN_MEAN = 45;
export const GATE_FLAT_MAX_MEAN = 78;
/** The inter-pill gap is darker than the pills. */
export const GATE_GAP_MAX_MEAN = 40;
/** The paint "p" suffix region must contain bright pixels. */
export const GATE_TEXT_MIN_MAX = 180;
/** Replay-code color probe: fraction of REPLAY_CODE_ROI pixels that are green-ish (high G, low B) — unique to this screen. */
export const GATE_CODE_GREEN_MIN = 140;
export const GATE_CODE_BLUE_MAX = 90;
export const GATE_CODE_MIN_FRACTION = 0.03;
/** Text metrics measured on the fixture, used for glyph scaling / tooling. */
export const NAME_TEXT_HEIGHT = 24;
export const PAINT_DIGIT_HEIGHT = 26;
export const STAT_DIGIT_HEIGHT = 20;
export const TEAM_DIGIT_HEIGHT = 27;
export const MATCH_SCORE_DIGIT_HEIGHT = 41;
export const HEADER_TIMESTAMP_HEIGHT = 24;
export const HEADER_LINE_HEIGHT = 29;
export const RESULT_TAG_TEXT_HEIGHT = 27;
export const CODE_TEXT_HEIGHT = 25;

View File

@@ -0,0 +1,417 @@
/**
* ScoreboardBattleLogDetector: parses the Recent Battles detail screen
* (battle log) — the same match data as the live scoreboard (header, team
* scores, 8 player rows) plus the recording timestamp, but no replay code.
*
* The two team panels sit STACKED (observed winner on top, confirmed by the
* VICTORY/DEFEAT tags) and the row text renders at the live scoreboard's
* sizes, so field parsing reuses the scoreboard helpers with the shared
* glyph sets unscaled — only the ROI geometry is this screen's own. The
* header is the replay browser's (timestamp + stage / lobby + mode tags),
* parsed with battle log bands.
*/
import { getCV, type Mat } from "../../cv";
import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs";
import {
cropRoi,
maxBrightness,
maxChannel,
meanBrightness,
roiSignature,
} from "../../image";
import { RESULT_TAG_ENTRIES } from "../../localized";
import { closestBy } from "../../text";
import {
type BannerScoreRead,
FULL_COUNT_TEAM_SCORE,
parseBannerScore,
resolveMatchScores,
} from "../scoreboard/banner";
import { type ParsedNumber, parseNumber } from "../scoreboard/digits";
import type {
ScoreboardData,
ScoreboardPlayer,
ScoreboardResources,
ScoreboardRowDebug,
} from "../scoreboard/index";
import { findPovIndex } from "../scoreboard/pov";
import { parseScoreboardRow, type RowRois } from "../scoreboard/row";
import {
type ParsedReplayHeader,
parseReplayHeader,
} from "../scoreboard-battle-log-replay/header";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
GATE_COLOR_MIN_SATURATION,
GATE_COLOR_PROBES,
GATE_DARK_MAX_MEAN,
GATE_TEXT_MIN_MAX,
gateDarkProbe,
HEADER_BOTTOM_BAND,
HEADER_LINE_HEIGHT,
HEADER_TAG_COLUMN_FRACTION,
HEADER_TAG_LEAD_IN_MAX,
HEADER_TIMESTAMP_HEIGHT,
HEADER_TOP_BAND,
MATCH_SCORE_DIGIT_HEIGHT,
MATCH_SCORE_ROIS,
nameRoi,
PANEL_DYS,
paintRoi,
paintSuffixRoi,
povArrowRoi,
RESULT_TAG_TEXT_HEIGHT,
ROW_CENTERS,
resultTagRoi,
specialIconRoi,
statRoi,
teamScoreRoi,
weaponRoi,
} from "./rois";
export interface ScoreboardBattleLogData extends ScoreboardData {
/** recording timestamp as shown, e.g. "5/8/2026 19:16"; locale-formatted */
timestamp: string | null;
}
export const SCOREBOARD_BATTLE_LOG_EVENT_TYPE = "ScoreboardBattleLog";
/**
* White outlined team totals on the panel's saturated color band — a yellow
* band grays at ~190, so binarize just above it (the digit cores are ~250).
*/
const TEAM_SCORE_BIN_THRESHOLD = 205;
/** Canonical results the localized VICTORY/DEFEAT panel tags snap to. */
type PanelResult = "VICTORY" | "DEFEAT";
const RESULT_MIN_SCORE = 0.6;
/**
* The tag letters render in the team's ink color on the gray stamp box
* (~75 gray, trailing status icons ~120), so binarize the max-channel
* image just above the icons — every ink color's brightest channel
* clears this.
*/
const RESULT_TAG_BIN_THRESHOLD = 140;
interface PanelParse {
players: ScoreboardPlayer[];
rows: ScoreboardRowDebug[];
teamScore: ParsedNumber | null;
result: PanelResult | null;
resultReading: string;
resultScore: number;
confidences: number[];
}
export function createScoreboardBattleLogDetector(
resources: ScoreboardResources,
): Detector<ScoreboardBattleLogData> {
const cv = getCV();
const scaled = (set: GlyphSet | null, height: number): GlyphSet | null =>
set ? scaleGlyphSet(set, height / set.height) : null;
const teamDigits = resources.teamDigits ?? resources.paintDigits;
const matchScoreSets = teamDigits
? [scaleGlyphSet(teamDigits, MATCH_SCORE_DIGIT_HEIGHT / teamDigits.height)]
: [];
/** Timestamp needs digits + '/' + ':' — only the names atlas has them. */
const headerTopGlyphs = scaled(resources.nameGlyphs, HEADER_TIMESTAMP_HEIGHT);
const headerBottomGlyphs = scaled(
resources.headerLineGlyphs,
HEADER_LINE_HEIGHT,
);
// The tags render in FOT-RowdyStd — use the dedicated atlas when present;
// the BlitzMain-based fallback reads them only roughly.
const resultGlyphs =
scaled(resources.replayResultGlyphs ?? null, RESULT_TAG_TEXT_HEIGHT) ??
scaled(resources.headerLineGlyphs, RESULT_TAG_TEXT_HEIGHT);
/** Mean-RGB saturation (max minus min channel) of a probe ROI. */
function probeSaturation(frame: Mat, roi: (typeof GATE_COLOR_PROBES)[0]) {
const view = cropRoi(frame, roi);
const cont = new cv.Mat();
view.copyTo(cont);
view.delete();
const d = cont.data;
const n = cont.rows * cont.cols;
let r = 0;
let g = 0;
let b = 0;
for (let i = 0; i < n; i++) {
r += d[i * 4]!;
g += d[i * 4 + 1]!;
b += d[i * 4 + 2]!;
}
cont.delete();
if (n === 0) return 0;
return (Math.max(r, g, b) - Math.min(r, g, b)) / n;
}
function gate(frame: Mat): GateResult {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
let darkOk = 0;
let suffixOk = 0;
for (const dy of PANEL_DYS) {
for (const base of ROW_CENTERS) {
const cy = base + dy;
if (meanBrightness(frame, gateDarkProbe(cy)) < GATE_DARK_MAX_MEAN)
darkOk++;
if (maxBrightness(gray, paintSuffixRoi(cy)) > GATE_TEXT_MIN_MAX)
suffixOk++;
}
}
let colorOk = 0;
for (const roi of GATE_COLOR_PROBES) {
if (probeSaturation(frame, roi) >= GATE_COLOR_MIN_SATURATION) colorOk++;
}
const rowCount = PANEL_DYS.length * ROW_CENTERS.length;
const score =
(darkOk / rowCount +
suffixOk / rowCount +
colorOk / GATE_COLOR_PROBES.length) /
3;
const pass = darkOk >= 7 && suffixOk >= 7 && colorOk === 3;
// browsing flips between entries never drop this gate, so it
// fingerprints the content that always differs between two battles
// (recording timestamp + stage tag) plus the name column — the
// scheduler re-arms suppression when the fingerprint moves
const signature = pass ? contentSignature(gray) : undefined;
gray.delete();
return { pass, score, signature };
}
function contentSignature(gray: Mat): number[] {
const signature = roiSignature(gray, HEADER_TOP_BAND, 32, 2);
for (const dy of PANEL_DYS) {
for (const base of ROW_CENTERS) {
signature.push(...roiSignature(gray, nameRoi(base + dy), 8, 1));
}
}
return signature;
}
function parsePanel(gray: Mat, rgb: Mat, dy: number): PanelParse {
const players: ScoreboardPlayer[] = [];
const rows: ScoreboardRowDebug[] = [];
const confidences: number[] = [];
const rowRois: RowRois = {
weapon: weaponRoi,
specialIcon: specialIconRoi,
paint: paintRoi,
name: nameRoi,
stat: statRoi,
povArrow: povArrowRoi,
};
for (const base of ROW_CENTERS) {
const row = parseScoreboardRow(
gray,
rgb,
base + dy,
rowRois,
resources,
confidences,
);
players.push(row.player);
rows.push(row.debug);
}
// The panel's point total is read only to recognize a knockout (the
// count times five: only a knockout's full count reaches 500); it is
// never emitted as a score.
let teamScore: ParsedNumber | null = null;
if (teamDigits) {
const crop = cropRoi(gray, teamScoreRoi(dy));
teamScore = parseNumber(crop, teamDigits, {
binThreshold: TEAM_SCORE_BIN_THRESHOLD,
});
crop.delete();
confidences.push(teamScore.confidence);
}
let result: PanelParse["result"] = null;
let resultReading = "";
let resultScore = 0;
if (resultGlyphs) {
const bright = maxChannel(rgb, resultTagRoi(dy));
const raw = recognizeText(bright, resultGlyphs, {
binThreshold: RESULT_TAG_BIN_THRESHOLD,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.25,
});
bright.delete();
resultReading = raw.text;
if (resultReading) {
const match = closestBy(
resultReading,
RESULT_TAG_ENTRIES,
(e) => e.text,
);
if (match) {
resultScore = match.score;
if (match.score >= RESULT_MIN_SCORE)
result = match.entry.canonical as PanelResult;
}
}
}
return {
players,
rows,
teamScore,
result,
resultReading,
resultScore,
confidences,
};
}
function parse(
frame: Mat,
t: number,
): DetectedEvent<ScoreboardBattleLogData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const [top, bottom] = PANEL_DYS.map((dy) => parsePanel(gray, rgb, dy)) as [
PanelParse,
PanelParse,
];
let left: BannerScoreRead | null = null;
let right: BannerScoreRead | null = null;
if (matchScoreSets.length > 0) {
left = parseBannerScore(gray, MATCH_SCORE_ROIS[0], matchScoreSets);
right = parseBannerScore(gray, MATCH_SCORE_ROIS[1], matchScoreSets);
}
const swapped = decideSwapped(top, bottom, left, right);
const [winner, loser] = swapped ? [bottom, top] : [top, bottom];
// POV arrow row, indexed into the winners-first players ordering
const povIndex = findPovIndex(
[...winner.rows, ...loser.rows].map((r) => r.povFraction),
);
const knockout = winner.teamScore?.value === FULL_COUNT_TEAM_SCORE;
let matchScores: [number | null, number | null] = [null, null];
let bannerDebug: object | undefined;
if (left && right) {
matchScores = resolveMatchScores({ left, right, knockout });
winner.confidences.push(left.confidence, right.confidence);
bannerDebug = { left, right, knockout };
}
let header: ParsedReplayHeader | null = null;
if (headerTopGlyphs && headerBottomGlyphs) {
header = parseReplayHeader(gray, headerTopGlyphs, headerBottomGlyphs, {
top: HEADER_TOP_BAND,
bottom: HEADER_BOTTOM_BAND,
tagLeadInMax: HEADER_TAG_LEAD_IN_MAX,
tagColumnFraction: HEADER_TAG_COLUMN_FRACTION,
});
}
gray.delete();
rgb.delete();
const confidences = [
...winner.confidences,
...loser.confidences,
...(header ? [header.confidence] : []),
];
const confidence =
confidences.length > 0
? confidences.reduce((a, b) => a + b, 0) / confidences.length
: 0;
return [
{
type: SCOREBOARD_BATTLE_LOG_EVENT_TYPE,
t,
confidence,
data: {
lobby: header?.lobby ?? null,
mode: header?.mode ?? null,
stage: header?.stage ?? null,
timestamp: header?.timestamp ?? null,
matchScores,
players: [...winner.players, ...loser.players],
povIndex,
},
debug: {
rows: [...winner.rows, ...loser.rows],
teamScoreConf: [
winner.teamScore?.confidence ?? 0,
loser.teamScore?.confidence ?? 0,
],
matchScore: bannerDebug,
header: header?.debug,
winnerSide: swapped ? "bottom" : "top",
resultTags: {
top: {
reading: top.resultReading,
score: top.resultScore,
result: top.result,
},
bottom: {
reading: bottom.resultReading,
score: bottom.resultScore,
result: bottom.result,
},
},
},
},
];
}
// no rearm cooldown — distinct battles browsed in quick succession are
// told apart by content: the gate signature re-arms scheduler suppression
// and the timeline merges via sameScoreboardMatch (same as the replay
// browser)
return {
id: "scoreboard-battle-log",
sufficientConfidence: 0.8,
gate,
parse,
};
}
/**
* Whether the winner sits in the bottom panel. A confident VICTORY/DEFEAT
* tag decides (the distressed tag texture usually reads below the floor);
* otherwise the panel totals are checked against the banner scores — the
* total is the count times five, and only a knockout winner's reaches 500.
* Default: winner on top, which every observed battle log screen shows.
*/
function decideSwapped(
top: PanelParse,
bottom: PanelParse,
left: BannerScoreRead | null,
right: BannerScoreRead | null,
): boolean {
if (top.result !== null || bottom.result !== null) {
return top.result === "DEFEAT" || bottom.result === "VICTORY";
}
const topTotal = top.teamScore?.value ?? null;
const bottomTotal = bottom.teamScore?.value ?? null;
if (topTotal === FULL_COUNT_TEAM_SCORE) return false;
if (bottomTotal === FULL_COUNT_TEAM_SCORE) return true;
if (
left?.value != null &&
right?.value != null &&
topTotal !== null &&
bottomTotal !== null &&
topTotal !== bottomTotal
) {
const hi = Math.max(left.value, right.value) * 5;
const lo = Math.min(left.value, right.value) * 5;
if (topTotal === lo && bottomTotal === hi) return true;
}
return false;
}

View File

@@ -0,0 +1,145 @@
/**
* ALL scoreboard-battle-log ROI coordinates, in canonical 1920x1080 space.
* Calibrated against scoreboard-battle-log/private-battle-splat-zones-
* makomart and x-battle-clam-blitz-lemuria via scripts/scanner/dump-crops.ts
* and column-projection measurement.
*
* The Recent Battles detail screen shows two team panels STACKED (bottom =
* top shifted by PANEL_DY), four near-black pill rows each on a dark panel
* whose top band/border carry the team's ink color. Text sizes match the
* live scoreboard (row glyphs reused unscaled, only columns differ). Above
* the panels: split "Score:"/KNOCKOUT! banner and stage-photo header
* (line 1 = timestamp + stage, line 2 = lobby + mode). No replay code line.
*/
import type { Roi } from "../../canonical";
/** Vertical centers of the 4 player rows within the top panel. */
export const ROW_CENTERS = [453, 519, 585, 651] as const;
/** Vertical shift from a top-panel ROI to its bottom-panel twin. */
export const PANEL_DY = 366;
/** dy per panel: [top (index 0), bottom (index 1)]. */
export const PANEL_DYS = [0, PANEL_DY] as const;
/**
* Weapon icon search region — icons render at live-scoreboard sizes; 56px
* height excludes larger replay-browser templates (matchTemplate skips them).
*/
export function weaponRoi(cy: number): Roi {
return { x: 1040, y: cy - 28, w: 76, h: 56 };
}
/**
* Special-weapon icon on the pill above the third stat counter (~x
* 1645-1692, y cy-20..cy+2). Only read to break near-tied weapon matches
* with different specials; bounded at cy+2 to keep counter digits out.
*/
export function specialIconRoi(cy: number): Roi {
return { x: 1642, y: cy - 24, w: 52, h: 26 };
}
/**
* Player name text region (white, left-aligned x=1114). Long names run
* into paint; parse paint first and trim at its leftmost digit.
*/
export function nameRoi(cy: number): Roi {
return { x: 1110, y: cy - 14, w: 208, h: 32 };
}
/** Paint amount digits, right-aligned ending at x=1396 (the "p" suffix is excluded). */
export function paintRoi(cy: number): Roi {
return { x: 1312, y: cy - 17, w: 88, h: 34 };
}
/** The constant white "p" after the paint number — used as a gate anchor. */
export function paintSuffixRoi(cy: number): Roi {
return { x: 1398, y: cy - 14, w: 18, h: 28 };
}
/** Stat counter digits (two, zero-padded; the small "x" prefix is excluded). */
export function statRoi(cy: number, index: 0 | 1 | 2): Roi {
const x = [1530, 1593, 1656][index]!;
return { x, y: cy + 3, w: 30, h: 22 };
}
/**
* POV arrow on the panel left of the pill (x 977-1024). Right edge stays
* short of the pill's rounded cap (~x 1032).
*/
export function povArrowRoi(cy: number): Roi {
return { x: 968, y: cy - 32, w: 60, h: 62 };
}
/**
* Team point totals ("500 p") on the colored top band, ending x=1704.
* Only read to recognize a knockout (winner total 500), not the score.
*/
export function teamScoreRoi(dy: number): Roi {
return { x: 1614, y: 371 + dy, w: 96, h: 38 };
}
/**
* VICTORY/DEFEAT tag on each panel's top band — confirms which panel won
* (observed always the top one; letters render in team ink on gray stamp).
*/
export function resultTagRoi(dy: number): Roi {
return { x: 990, y: 358 + dy, w: 215, h: 52 };
}
/**
* The two sides of the "Score:" banner above the panels. Left follows the
* localized label (from x~985), right ends x~1744. A knockout replaces
* the winning side's value with the KNOCKOUT! burst.
*/
export const MATCH_SCORE_ROIS: readonly [Roi, Roi] = [
{ x: 985, y: 278, w: 265, h: 40 },
{ x: 1648, y: 278, w: 100, h: 40 },
];
/**
* Header bands on the stage-photo banner. Line 1: timestamp + stage tag
* (rank icon on ranked lobbies shifts the left edge); line 2: lobby
* (bold, x=809) + mode. Bands hug the tag rows tightly since the photo
* below has bright pixels that would ink glyph bottoms; readTagBand scans
* for the tag start (HEADER_TAG_LEAD_IN_MAX) instead of anchoring an edge.
*/
export const HEADER_TOP_BAND: Roi = { x: 800, y: 74, w: 668, h: 32 };
export const HEADER_BOTTOM_BAND: Roi = { x: 800, y: 122, w: 728, h: 46 };
export const HEADER_TAG_LEAD_IN_MAX = 40;
/** see TagBandOptions.tagColumnFraction — the tags are subtly tilted */
export const HEADER_TAG_COLUMN_FRACTION = 0.75;
/**
* Gate probe: strip between paint "p" (ends 1413) and first stat "x"
* (starts 1517) is always empty pill background (near-black ~20).
*/
export function gateDarkProbe(cy: number): Roi {
return { x: 1422, y: cy - 10, w: 78, h: 20 };
}
/**
* Ink-color probes discriminate against two lookalike results screens:
* panels' top bands and score banner are saturated team color here (109+),
* while the live scoreboard is neutral gray and the replay browser only
* saturated at the first spot (its own banner).
*/
export const GATE_COLOR_PROBES: readonly Roi[] = [
{ x: 1300, y: 382, w: 100, h: 16 },
{ x: 1300, y: 748, w: 100, h: 16 },
{ x: 1400, y: 292, w: 60, h: 14 },
];
/** Mean-RGB saturation (max minus min channel) floor for the color probes. */
export const GATE_COLOR_MIN_SATURATION = 60;
/** The strip between p suffix and stats must stay near-black. */
export const GATE_DARK_MAX_MEAN = 45;
/** The paint "p" suffix region must contain bright (white) pixels. */
export const GATE_TEXT_MIN_MAX = 180;
/** Text metrics measured on the fixtures, used for glyph scaling / tooling. */
export const MATCH_SCORE_DIGIT_HEIGHT = 28;
export const HEADER_TIMESTAMP_HEIGHT = 21;
export const HEADER_LINE_HEIGHT = 26;
export const RESULT_TAG_TEXT_HEIGHT = 30;

View File

@@ -0,0 +1,32 @@
/**
* Ability badge templates for the personal-results gear cards. Same
* composite-and-resize pipeline as the death panel (death/abilities.ts),
* at this screen's smaller badge sizes and its gray-strip ink threshold.
*/
import type { FrameData } from "../../image";
import { type AbilityTemplates, buildAbilityRole } from "../death/abilities";
import {
OWN_ABILITY_ART_RATIO,
OWN_ABILITY_INK_THRESHOLD,
OWN_ABILITY_MAIN_SIZES,
OWN_ABILITY_SUB_SIZES,
} from "./rois";
export function prepareOwnAbilityTemplates(
icons: { id: string; image: FrameData }[],
): AbilityTemplates {
return {
mains: buildAbilityRole(
icons,
OWN_ABILITY_MAIN_SIZES,
OWN_ABILITY_ART_RATIO,
OWN_ABILITY_INK_THRESHOLD,
),
subs: buildAbilityRole(
icons,
OWN_ABILITY_SUB_SIZES,
OWN_ABILITY_ART_RATIO,
OWN_ABILITY_INK_THRESHOLD,
),
};
}

View File

@@ -0,0 +1,268 @@
/**
* ScoreboardOwnDetector: parses the personal results screen (the "your
* results" view after a match) — lobby/mode/stage from the header tags
* (identical to the live scoreboard header, parsing is shared), the
* player's main weapon from the weapon card's title tag, and the own gear
* abilities from the three gear cards' badge strips.
*
* The weapon arrives as text: the title is OCR'd with the death-weapon
* atlas (the only atlas carrying the weapon-name charset) rescaled to this
* screen's title size, then snapped against every language's main-weapon
* names at once and reported under its canonical English name.
*/
import type {
AbilityWithUnknown,
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import {
type ScannerLobby,
toAbilityWithUnknown,
toMainWeaponId,
} from "../../../scanner-types";
import { getCV, type Mat } from "../../cv";
import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs";
import { copyRoi, cropRoi, maxBrightness, meanBrightness } from "../../image";
import { closestBy, matchKey } from "../../text";
import { LOCALIZED_WEAPON_NAMES } from "../death/localized-messages";
import { ALL_WEAPON_ENTRIES, type WeaponEntry } from "../death/weapon-names";
import { type ParsedHeader, parseHeader } from "../scoreboard/header";
import type { ScoreboardResources } from "../scoreboard/index";
import { matchWeapon, type WeaponMatch } from "../scoreboard/weapons";
import type { DetectedEvent, Detector, GateResult } from "../types";
import {
GATE_PANEL_MAX_MEAN,
GATE_PANEL_PROBES,
GATE_STRIP_MAX_MEAN,
GATE_STRIP_MIN_MEAN,
GATE_TEXT_MIN_MAX,
GATE_TITLE_TEXT_PROBES,
GEAR_ROWS,
gateStripProbe,
gearMainRoi,
gearSubRoi,
OWN_ABILITY_INK_THRESHOLD,
WEAPON_TITLE_BAND,
WEAPON_TITLE_BIN_THRESHOLD,
WEAPON_TITLE_TEXT_HEIGHT,
} from "./rois";
export interface ScoreboardOwnData {
/** from the header tag; null when unreadable */
lobby: ScannerLobby | null;
mode: ModeShort | null;
stage: StageId | null;
/** the player's main weapon; null if unreadable */
weaponId: MainWeaponId | null;
/**
* own gear abilities, [head, clothes, shoes] rows of
* [main, sub, sub, sub] ability ids
*/
abilities: AbilityWithUnknown[][];
}
export const SCOREBOARD_OWN_EVENT_TYPE = "ScoreboardOwn";
/** Snapped weapon reading below this is reported as null (kept in debug). */
const WEAPON_MIN_SCORE = 0.55;
interface WeaponCandidate {
text: string;
entry: WeaponEntry;
}
/**
* Every string the weapon card title can show: all languages' localized
* main-weapon names plus the canonical English names (localized-messages
* omits names identical to English).
*/
let weaponCandidates: WeaponCandidate[] | null = null;
function mainWeaponCandidates(): WeaponCandidate[] {
if (weaponCandidates) return weaponCandidates;
const mains = ALL_WEAPON_ENTRIES.filter((e) => e.type === "MAIN");
const byName = new Map(mains.map((e) => [e.name, e]));
const seen = new Set<string>();
weaponCandidates = [];
const push = (text: string, entry: WeaponEntry | undefined) => {
const k = matchKey(text);
if (!entry || seen.has(k)) return;
seen.add(k);
weaponCandidates!.push({ text, entry });
};
for (const entry of mains) push(entry.name, entry);
for (const names of Object.values(LOCALIZED_WEAPON_NAMES)) {
for (const { text, name } of names) push(text, byName.get(name));
}
return weaponCandidates;
}
export function createScoreboardOwnDetector(
resources: ScoreboardResources,
): Detector<ScoreboardOwnData> {
const cv = getCV();
const titleGlyphs: GlyphSet | null = resources.deathWeaponGlyphs
? scaleGlyphSet(
resources.deathWeaponGlyphs,
WEAPON_TITLE_TEXT_HEIGHT / resources.deathWeaponGlyphs.height,
)
: null;
const abilities = resources.ownAbilities ?? null;
function gate(frame: Mat): GateResult {
let panelOk = 0;
for (const roi of GATE_PANEL_PROBES) {
if (meanBrightness(frame, roi) < GATE_PANEL_MAX_MEAN) panelOk++;
}
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
let textOk = 0;
for (const roi of GATE_TITLE_TEXT_PROBES) {
if (maxBrightness(gray, roi) > GATE_TEXT_MIN_MAX) textOk++;
}
gray.delete();
let stripOk = 0;
for (let row = 0; row < GEAR_ROWS; row++) {
const mean = meanBrightness(frame, gateStripProbe(row));
if (mean >= GATE_STRIP_MIN_MEAN && mean <= GATE_STRIP_MAX_MEAN) stripOk++;
}
const score =
(panelOk / GATE_PANEL_PROBES.length +
textOk / GATE_TITLE_TEXT_PROBES.length +
stripOk / GEAR_ROWS) /
3;
const pass =
panelOk === GATE_PANEL_PROBES.length &&
textOk === GATE_TITLE_TEXT_PROBES.length &&
stripOk === GEAR_ROWS;
return { pass, score };
}
function parse(frame: Mat, t: number): DetectedEvent<ScoreboardOwnData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
const rgb = new cv.Mat();
cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB);
const confidences: number[] = [];
// header tags sit at the live scoreboard's positions — shared parser
let header: ParsedHeader | null = null;
if (resources.headerLobbyGlyphs && resources.headerLineGlyphs) {
header = parseHeader(
gray,
resources.headerLobbyGlyphs,
resources.headerLineGlyphs,
);
confidences.push(header.confidence);
}
// Weapon card title, snapped to the main-weapon closed set. The band is
// recognized whole, NOT via readTagBand: the tag is fixed-width (the
// band lies entirely inside it, there is nothing to trim away), and the
// extent trim actively harms it — long names render horizontally
// condensed, whose dense antialiased columns fail the dark-or-bright
// tag-column test and truncate the read mid-name.
let weapon: string | null = null;
let weaponId: MainWeaponId | null = null;
let weaponScore = 0;
let weaponReading = "";
if (titleGlyphs) {
const band = copyRoi(gray, WEAPON_TITLE_BAND);
weaponReading = recognizeText(band, titleGlyphs, {
binThreshold: WEAPON_TITLE_BIN_THRESHOLD,
spaceGap: 9,
minCharScore: 0.3,
}).text.trim();
band.delete();
const match = weaponReading
? closestBy(weaponReading, mainWeaponCandidates(), (c) => c.text)
: null;
if (match) {
weaponScore = match.score;
if (match.score >= WEAPON_MIN_SCORE) {
weapon = match.entry.entry.name;
weaponId = toMainWeaponId(match.entry.entry.id);
}
}
confidences.push(weaponScore);
}
// gear-card ability strips: [head, clothes, shoes] x [main, sub, sub, sub]
const abilityRows: AbilityWithUnknown[][] = [];
const abilityDebug: (WeaponMatch | null)[][] = [];
if (abilities) {
for (let row = 0; row < GEAR_ROWS; row++) {
const ids: AbilityWithUnknown[] = [];
const debug: (WeaponMatch | null)[] = [];
const mainCrop = cropRoi(rgb, gearMainRoi(row));
const main = matchWeapon(mainCrop, abilities.mains, {
inkThreshold: OWN_ABILITY_INK_THRESHOLD,
});
mainCrop.delete();
ids.push(toAbilityWithUnknown(main.id) ?? "UNKNOWN");
debug.push(main);
confidences.push(Math.max(0, main.score));
for (let slot = 0; slot < 3; slot++) {
const crop = cropRoi(rgb, gearSubRoi(row, slot));
const sub = matchWeapon(crop, abilities.subs, {
inkThreshold: OWN_ABILITY_INK_THRESHOLD,
});
crop.delete();
ids.push(toAbilityWithUnknown(sub.id) ?? "UNKNOWN");
debug.push(sub);
confidences.push(Math.max(0, sub.score));
}
abilityRows.push(ids);
abilityDebug.push(debug);
}
}
gray.delete();
rgb.delete();
const confidence =
confidences.length > 0
? confidences.reduce((a, b) => a + b, 0) / confidences.length
: 0;
return [
{
type: SCOREBOARD_OWN_EVENT_TYPE,
t,
confidence,
data: {
lobby: header?.lobby ?? null,
mode: header?.mode ?? null,
stage: header?.stage ?? null,
weaponId,
abilities: abilityRows,
},
debug: {
header: header?.debug,
weaponName: weapon,
weaponReading,
weaponScore,
abilityRows: abilityDebug.map((row) =>
row.map((m) => m && { top: m.top, score: m.score }),
),
},
},
];
}
// just under the measured clean-read floor (fixtures 0.562-0.669,
// confirmed scan events 0.612-0.635 — this screen's ability-grid scores
// keep the mean low even on perfect reads)
return {
id: "scoreboard-own",
sufficientConfidence: 0.55,
gate,
parse,
};
}

View File

@@ -0,0 +1,90 @@
/**
* ALL scoreboard-own ROI coordinates, in canonical 1920x1080 space.
* Calibrated against scoreboard-own/private-battle-splat-zones-museum via
* tools/dump-crops.ts and column/row projection measurement.
*
* The personal results screen ("your results") shows the same header tags
* as the live scoreboard (same positions, reuses its bands) over a dark
* panel (~35) with the player's banner, medals list, and four bottom
* cards: weapon card (yellow-bordered title tag, big render on white
* square) then one gear card per slot, each with a gray ability strip of
* one main badge (⌀~49) and three sub badges (⌀~38).
*/
import type { Roi } from "../../canonical";
/**
* Weapon card title tag interior (black, white name left-aligned). Band
* starts inside the tag so readTagBand's extent trim anchors immediately.
*/
export const WEAPON_TITLE_BAND: Roi = { x: 876, y: 766, w: 200, h: 32 };
/** Tight cap height of the title text at 1080p. */
export const WEAPON_TITLE_TEXT_HEIGHT = 18;
/** White-core title text on the black tag binarizes high, like the burst text. */
export const WEAPON_TITLE_BIN_THRESHOLD = 190;
/** Gear cards [head, clothes, shoes]: main-ability badge center x per row. */
export const GEAR_MAIN_CXS = [1142, 1372, 1602] as const;
/** Sub badge center x offsets from the row's main badge center. */
const GEAR_SUB_DXS = [48, 88, 127] as const;
/** All badges share one vertical center (the ability strip line). */
export const GEAR_BADGE_CY = 927;
export const GEAR_ROWS = 3;
/**
* Search boxes around each badge; heights double as the size filter
* (matchTemplate skips templates taller than the ROI).
*/
export function gearMainRoi(row: number): Roi {
const cx = GEAR_MAIN_CXS[row]!;
return { x: cx - 32, y: GEAR_BADGE_CY - 32, w: 64, h: 64 };
}
export function gearSubRoi(row: number, slot: number): Roi {
const cx = GEAR_MAIN_CXS[row]! + GEAR_SUB_DXS[slot]!;
return { x: cx - 26, y: GEAR_BADGE_CY - 26, w: 52, h: 52 };
}
/** Template heights (px at 1080p) per badge role (main ⌀~49, sub ⌀~38). */
export const OWN_ABILITY_MAIN_SIZES = [45, 49, 53] as const;
export const OWN_ABILITY_SUB_SIZES = [34, 38, 42] as const;
/**
* Icon art diameter as fraction of badge box: ratio peaks at 1.0 for both
* roles (art overflows the circle slightly), so the ring contributes nothing.
*/
export const OWN_ABILITY_ART_RATIO = 1.0;
/**
* Ink threshold inside a badge box. Unlike the death panel these sit on a
* light-gray strip (~140-155); 170 keeps the strip out while art still
* clears it on its max channel.
*/
export const OWN_ABILITY_INK_THRESHOLD = 170;
/**
* Gate probes. Results panel is uniform dark (~35, edge ~54) at spots
* dodging the banner/medals/cards; each title tag holds bright white
* text at its left edge; the strip's gray shows after the third sub badge.
*/
export const GATE_PANEL_PROBES: readonly Roi[] = [
{ x: 860, y: 245, w: 30, h: 20 },
{ x: 1690, y: 395, w: 30, h: 20 },
{ x: 875, y: 695, w: 30, h: 20 },
{ x: 1400, y: 985, w: 30, h: 20 },
];
export const GATE_PANEL_MAX_MEAN = 65;
/** Left edge of each card's title text (weapon card first). */
export const GATE_TITLE_TEXT_PROBES: readonly Roi[] = [
{ x: 880, y: 768, w: 70, h: 26 },
{ x: 1090, y: 768, w: 70, h: 26 },
{ x: 1319, y: 768, w: 70, h: 26 },
{ x: 1550, y: 768, w: 70, h: 26 },
];
/** Title text regions must contain bright (white) pixels. */
export const GATE_TEXT_MIN_MAX = 180;
/** Gray ability-strip gap after the third sub badge, one per gear card. */
export function gateStripProbe(row: number): Roi {
return { x: GEAR_MAIN_CXS[row]! + 148, y: 917, w: 10, h: 18 };
}
export const GATE_STRIP_MIN_MEAN = 110;
export const GATE_STRIP_MAX_MEAN = 200;

View File

@@ -0,0 +1,286 @@
/**
* "Score:" banner parsing for the results screens. Each side of the colored
* banner shows one team's game score (0-100) as white BlitzBold digits after
* a localized label — some languages render no label at all, so the digits'
* x position is not fixed. A knockout replaces the winning side's value with
* the localized KNOCKOUT! burst, whose letters only weakly match digit
* templates (real digits score 0.9+); the knockout itself is recognized from
* the winner's team total instead (the box prints the count times five, and
* only a knockout's full 100 count reaches 500). The score value bounces as
* it lands, so a frame may catch the digits settled or mid-pop — this module
* parses at every size and binarization threshold and keeps the best
* read (valid over none, longer digit run over shorter, then confidence).
*/
import { getCV, type Mat } from "../../cv";
import {
type GlyphSet,
type RecognizedChar,
type RecognizedText,
recognizeText,
} from "../../glyphs";
import { copyRoi, type Roi } from "../../image";
/** The count a knockout wins at — the burst hides it, so it is never read. */
export const KO_MATCH_SCORE = 100;
/**
* The team box prints the count times five ("440 p" alongside a 88 banner),
* so a knockout's full 100 count shows as 500 — a total only a knockout
* reaches, which is what separates a burst-covered banner from an unread one.
*/
export const FULL_COUNT_TEAM_SCORE = KO_MATCH_SCORE * 5;
/**
* Replay-screen reads below this floor are discarded rather than trusted as
* a score — burst/label letters overlapping a score ROI match digit
* templates at ~0.4 there.
*/
export const MATCH_SCORE_MIN_CONF = 0.6;
/**
* Char floor for the live banner's trailing-digit run. KNOCKOUT! letters
* have matched digit templates at up to 0.62 (the ko-hagglefish fixture's
* "07"), while genuine banner digits score 0.79+ across every fixture —
* including 720p upscales.
*/
const DIGIT_MIN_CONF = 0.75;
/** White banner digits on saturated team color (yellow ink grays at ~170). */
const BANNER_SCORE_BIN_THRESHOLD = 205;
/**
* Second binarization pass for pale team colors: a light banner (gray ~220,
* with the wave-crest highlight brighter still) binarizes solid white at the
* base threshold, gluing label and digits into one giant unmatchable blob
* that can swallow all but the last digit. Only the ~250 digit ink survives
* this threshold. Every pass always runs; a swallowed background can only
* shorten the digit run, never lengthen it, so the longer run wins
* regardless of confidence (the truncated read's surviving digit is genuine
* ink and scores just as well).
*/
const BANNER_SCORE_BRIGHT_BIN_THRESHOLD = 240;
/**
* Third pass for the brightest banners: a yellow battle-log banner grays at
* ~245 near the wave crest, so even the bright pass keeps label ink attached
* and erodes the digits below the confidence floor. Only the ~250 digit
* cores survive this threshold.
*/
const BANNER_SCORE_BRIGHTEST_BIN_THRESHOLD = 248;
/**
* Digits of one number nearly touch; anything further apart than this
* fraction of a digit width is the label (or an unreadable glyph) ending
* the run.
*/
const DIGIT_GAP_MAX_RATIO = 0.55;
/**
* A score digit spans the set's full height; the label's lowercase letters
* top out ~0.75 of it, so they cannot pass as digits even when their shapes
* correlate.
*/
const DIGIT_MIN_HEIGHT_RATIO = 0.82;
/**
* The banner's bright wave-crest highlight can dip into the score line as a
* wide ~12px-tall streak whose columns merge into the digits' segments and
* ruin their ink extents. Every real digit is at least ~26px tall, so ink
* components shorter than this are wiped before recognition.
*/
const MIN_COMPONENT_HEIGHT = 20;
export interface BannerScoreRead {
/** the side's score; null when unread (knockout burst, blur, label-only) */
value: number | null;
/** min glyph score across the accepted digits (0 when none) */
confidence: number;
/** digits in the accepted run (0 when unread) */
digits: number;
/** best raw reading, for debugging */
reading: string;
}
const EMPTY_READ: BannerScoreRead = {
value: null,
confidence: 0,
digits: 0,
reading: "",
};
/**
* Reads one banner side's score from `roi`: recognizes with each digit set
* (one per on-screen text size) at each binarization threshold and keeps
* the best read — a valid value beats none, a longer digit run beats a
* shorter one, confidence breaks ties. The score is the trailing run of
* full-height, confidently-matched digits — everything the localized label
* or the KNOCKOUT! burst leaves in the ROI fails at least one of those
* tests.
*/
export function parseBannerScore(
gray: Mat,
roi: Roi,
sets: readonly GlyphSet[],
): BannerScoreRead {
const crop = copyRoi(gray, roi);
clearShortBlobs(crop);
let best = EMPTY_READ;
for (const binThreshold of [
BANNER_SCORE_BIN_THRESHOLD,
BANNER_SCORE_BRIGHT_BIN_THRESHOLD,
BANNER_SCORE_BRIGHTEST_BIN_THRESHOLD,
]) {
for (const set of sets) {
const raw = recognizeText(crop, set, {
binThreshold,
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
});
const read = trailingDigitRun(raw, set);
if (isBetterRead(read, best)) best = read;
}
}
crop.delete();
return best;
}
/**
* Winner-first score pair from the two banner sides. A confirmed knockout
* (winner team total = 500) dominates: the winner reports the full count no
* matter what was read off the burst-covered side, and the loser is the
* more confident read (genuine digits score well clear of burst letters
* that survive the floor). Without a knockout, ranked scores never tie, so
* when both sides read the higher value is the winner's; one unreadable
* side cannot be attributed to a team, so nothing is reported.
*/
export function resolveMatchScores({
left,
right,
knockout,
}: {
left: BannerScoreRead;
right: BannerScoreRead;
knockout: boolean;
}): [number | null, number | null] {
if (knockout) {
const loser =
left.value !== null && right.value !== null
? left.confidence >= right.confidence
? left
: right
: left.value !== null
? left
: right;
return [KO_MATCH_SCORE, loser.value];
}
if (left.value !== null && right.value !== null) {
return left.value >= right.value
? [left.value, right.value]
: [right.value, left.value];
}
return [null, null];
}
/** Zero out ink components shorter than any digit (see MIN_COMPONENT_HEIGHT). */
function clearShortBlobs(band: Mat): void {
const cv = getCV();
const bin = new cv.Mat();
cv.threshold(band, bin, BANNER_SCORE_BIN_THRESHOLD, 255, cv.THRESH_BINARY);
const labels = new cv.Mat();
const stats = new cv.Mat();
const centroids = new cv.Mat();
const count = cv.connectedComponentsWithStats(
bin,
labels,
stats,
centroids,
8,
);
bin.delete();
centroids.delete();
const s = stats.data32S;
const short = new Uint8Array(count);
for (let i = 1; i < count; i++) {
short[i] = s[i * 5 + cv.CC_STAT_HEIGHT]! < MIN_COMPONENT_HEIGHT ? 1 : 0;
}
stats.delete();
const lab = labels.data32S;
const out = band.data;
for (let i = 0; i < out.length; i++) {
if (short[lab[i]!]!) out[i] = 0;
}
labels.delete();
}
/**
* Read preference shared by every multi-attempt digit read: a valid value
* beats none, a longer digit run beats a shorter one, confidence breaks
* ties.
*/
export function isBetterRead(
read: BannerScoreRead,
best: BannerScoreRead,
): boolean {
if ((read.value !== null) !== (best.value !== null)) {
return read.value !== null;
}
if (read.digits !== best.digits) return read.digits > best.digits;
return read.confidence > best.confidence;
}
export interface TrailingDigitOptions {
/** char floor a glyph must clear to count as a digit of the number */
minCharScore?: number;
/** min ink height as a fraction of the set height (drops labels, '+') */
minHeightRatio?: number;
/** values above this are rejected as misreads */
maxValue?: number;
}
/**
* The trailing run of full-height, confidently-matched digits of a
* recognized line — the number-on-a-plate read shared by the score banner
* and the objective counters, where a localized label / burst / '+' sign
* precedes the digits and must fail at least one of the floors.
*/
export function trailingDigitRun(
raw: RecognizedText,
set: GlyphSet,
options: TrailingDigitOptions = {},
): BannerScoreRead {
const {
minCharScore = DIGIT_MIN_CONF,
minHeightRatio = DIGIT_MIN_HEIGHT_RATIO,
maxValue = KO_MATCH_SCORE,
} = options;
const maxGap = Math.max(4, Math.round(set.medianWidth * DIGIT_GAP_MAX_RATIO));
const isScoreDigit = (c: RecognizedChar) =>
c.score >= minCharScore && c.y1 - c.y0 >= set.height * minHeightRatio;
const run: RecognizedChar[] = [];
let i = raw.chars.length - 1;
for (; i >= 0; i--) {
const c = raw.chars[i]!;
if (!isScoreDigit(c)) break;
if (run.length > 0 && run[0]!.x0 - c.x1 > maxGap) break;
run.unshift(c);
}
if (run.length === 0) return { ...EMPTY_READ, reading: raw.text };
// A further digit left of the run means an unreadable glyph split the
// number (turf war percentages read "48", ".", "7") — the tail is not
// the score.
for (let k = i; k >= 0; k--) {
if (isScoreDigit(raw.chars[k]!)) {
return { ...EMPTY_READ, reading: raw.text };
}
}
const value = Number.parseInt(run.map((c) => c.char).join(""), 10);
if (value > maxValue) return { ...EMPTY_READ, reading: raw.text };
return {
value,
confidence: Math.min(...run.map((c) => c.score)),
digits: run.length,
reading: raw.text,
};
}

View File

@@ -0,0 +1,58 @@
/**
* Number field parsing on top of glyph recognition.
*/
import type { Mat } from "../../cv";
import {
type GlyphSet,
type RecognizedText,
recognizeText,
} from "../../glyphs";
export interface ParsedNumber {
value: number | null;
/** min glyph score across recognized digits */
confidence: number;
/** x of the leftmost digit, relative to the crop (null when nothing found) */
leftX: number | null;
raw: RecognizedText;
}
/**
* Digits share a cap line; a lowercase suffix ("p") starts at x-height,
* ~7px lower at the sizes we parse. A trailing char whose ink top sits at
* least this far below the other chars' top line is the suffix, not a digit.
*/
const LOWERED_TRAILING_MIN_PX = 5;
export function parseNumber(
gray: Mat,
digits: GlyphSet,
options: { binThreshold?: number; dropLoweredTrailing?: boolean } = {},
): ParsedNumber {
const raw = recognizeText(gray, digits, {
spaceGap: Number.POSITIVE_INFINITY,
minCharScore: 0.3,
binThreshold: options.binThreshold,
});
// The replay paint column is left-aligned, so its "p" suffix moves with
// the digit count and can land inside the ROI, where the digit-only
// charset misreads it (a "6"). The geometry still tells it apart.
let chars = raw.chars;
if (options.dropLoweredTrailing && chars.length > 1) {
const capY0 = Math.min(...chars.slice(0, -1).map((c) => c.y0));
if (chars[chars.length - 1]!.y0 - capY0 >= LOWERED_TRAILING_MIN_PX) {
chars = chars.slice(0, -1);
}
}
const text = chars.map((c) => c.char).join("");
const isNumeric = /^[0-9]+$/.test(text);
return {
value: isNumeric ? Number.parseInt(text, 10) : null,
confidence:
chars.length > 0
? Math.min(...chars.map((c) => c.score))
: raw.confidence,
leftX: chars.length > 0 ? chars[0]!.x0 : null,
raw,
};
}

View File

@@ -0,0 +1,193 @@
/**
* Header parsing: lobby type ("X Battle"), mode ("Splat Zones") and stage
* ("Scorch Gorge") from the black tags above the team boxes.
*
* Tags auto-size to their text, so the stage's x position depends on mode
* length. Each band is trimmed to the tag extent (near-black bg + white
* text; the map thumbnail around it is mid-brightness), then OCR'd as one
* line and snapped against every language's mode × stage combos
* (core/localized.ts) — reported values are always sendou.ink ids.
*/
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { ScannerLobby } from "../../../scanner-types";
import { getCV, type Mat } from "../../cv";
import {
type GlyphSet,
type RecognizeOptions,
recognizeText,
} from "../../glyphs";
import { copyRoi } from "../../image";
import { ALL_LOBBY_ENTRIES, MODE_STAGE_COMBOS } from "../../localized";
import { closestBy } from "../../text";
import { HEADER_LINE_BAND, HEADER_LOBBY_BAND } from "./rois";
export interface ParsedHeader {
lobby: ScannerLobby | null;
mode: ModeShort | null;
stage: StageId | null;
/** min of the closed-set match scores that were attempted */
confidence: number;
debug: {
lobbyReading: string;
lineReading: string;
lobbyScore: number;
lineScore: number;
};
}
/** Accept a closed-set match only above this score (1 = exact). */
const MIN_MATCH_SCORE = 0.62;
/** A column belongs to a tag when nearly all its pixels are dark bg or bright text. */
const TAG_COLUMN_FRACTION = 0.85;
const TAG_DARK_MAX = 75;
const TAG_BRIGHT_MIN = 165;
/** Stop extending the tag after this many consecutive non-tag columns. */
const TAG_GAP_TOLERANCE = 6;
/**
* Trim a band crop to the black-tag extent: the longest run of tag columns
* starting within `maxLeadIn` of the left edge (a dark photo edge can fake
* a short run before the real tag), each run extended right until the tag
* ends. Returns a zero-width range when no tag is present at all.
*/
function tagExtent(
crop: Mat,
darkMax: number,
maxLeadIn: number,
columnFraction: number,
): { start: number; end: number } {
const { cols, rows, data } = crop;
let best = { start: 0, end: 0 };
let start = -1;
let end = 0;
let gap = 0;
const takeRun = () => {
if (start !== -1 && end - start > best.end - best.start) {
best = { start, end };
}
start = -1;
gap = 0;
};
for (let x = 0; x < cols; x++) {
let tagLike = 0;
for (let y = 0; y < rows; y++) {
const v = data[y * cols + x]!;
if (v < darkMax || v > TAG_BRIGHT_MIN) tagLike++;
}
if (tagLike / rows >= columnFraction) {
if (start === -1) {
if (x > maxLeadIn) break;
start = x;
}
end = x + 1;
gap = 0;
} else if (start !== -1 && ++gap > TAG_GAP_TOLERANCE) {
takeRun();
} else if (start === -1 && x > maxLeadIn) {
break;
}
}
takeRun();
return best;
}
export interface TagBandOptions extends RecognizeOptions {
/**
* Dark ceiling for the tag-extent trim. Lifted-blacks captures (720p
* streams upscaled and re-encoded) raise the tag background above the
* default, truncating the trim to a sliver — callers whose closed-set
* snap fails retry with a lifted ceiling.
*/
tagDarkMax?: number;
/**
* Non-tag columns tolerated before the tag begins. The battle log tags
* are not left-anchored (a leading rank icon shifts line 1 per lobby
* type), so its bands start on the stage photo and scan for the tag.
*/
tagLeadInMax?: number;
/**
* Tag-like row fraction a column must reach. The battle log tags are
* subtly tilted, so a horizontal band always catches a few photo rows
* above or below the box — those bands pass a looser fraction.
*/
tagColumnFraction?: number;
}
/**
* OCR one header band: trim the crop to the black-tag extent, then
* recognize it as a single line. Shared with the scoreboard-battle-log-replay
* header, whose tags have the same style at different positions/sizes.
*/
export function readTagBand(
gray: Mat,
band: { x: number; y: number; w: number; h: number },
glyphs: GlyphSet,
options: TagBandOptions = {},
): string {
const crop = copyRoi(gray, band);
const { start, end } = tagExtent(
crop,
options.tagDarkMax ?? TAG_DARK_MAX,
options.tagLeadInMax ?? TAG_GAP_TOLERANCE,
options.tagColumnFraction ?? TAG_COLUMN_FRACTION,
);
if (end - start < 12) {
crop.delete();
return "";
}
const cv = getCV();
const view = crop.roi(new cv.Rect(start, 0, end - start, crop.rows));
const trimmed = new cv.Mat();
view.copyTo(trimmed);
view.delete();
crop.delete();
const result = recognizeText(trimmed, glyphs, {
spaceGap: 9,
minCharScore: 0.3,
...options,
});
trimmed.delete();
return result.text.trim();
}
export function parseHeader(
gray: Mat,
lobbyGlyphs: GlyphSet,
lineGlyphs: GlyphSet,
): ParsedHeader {
const lobbyReading = readTagBand(gray, HEADER_LOBBY_BAND, lobbyGlyphs);
const lineReading = readTagBand(gray, HEADER_LINE_BAND, lineGlyphs);
const lobbyMatch = lobbyReading
? closestBy(lobbyReading, ALL_LOBBY_ENTRIES, (e) => e.text)
: null;
const lineMatch = lineReading
? closestBy(lineReading, MODE_STAGE_COMBOS, (c) => c.text)
: null;
const lobby =
lobbyMatch && lobbyMatch.score >= MIN_MATCH_SCORE
? lobbyMatch.entry.lobby
: null;
let mode: ModeShort | null = null;
let stage: StageId | null = null;
if (lineMatch && lineMatch.score >= MIN_MATCH_SCORE) {
mode = lineMatch.entry.mode;
stage = lineMatch.entry.stageId;
}
const attempted = [lobbyMatch?.score ?? 0, lineMatch?.score ?? 0];
return {
lobby,
mode,
stage,
confidence: Math.min(...attempted),
debug: {
lobbyReading,
lineReading,
lobbyScore: lobbyMatch?.score ?? 0,
lineScore: lineMatch?.score ?? 0,
},
};
}

Some files were not shown because too many files have changed in this diff Show More