mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-27 05:38:09 -05:00
Scanner WIP (#3239)
This commit is contained in:
@@ -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
3
.gitignore
vendored
@@ -34,3 +34,6 @@ dump
|
||||
.e2e-build-marker
|
||||
|
||||
notepad.txt
|
||||
|
||||
# proprietary game fonts for the scanner glyph-atlas builders (scripts/scanner)
|
||||
/assets/fonts/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
6
app/components/ObjectiveTimeline.module.css
Normal file
6
app/components/ObjectiveTimeline.module.css
Normal 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);
|
||||
}
|
||||
284
app/components/ObjectiveTimeline.tsx
Normal file
284
app/components/ObjectiveTimeline.tsx
Normal 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}`;
|
||||
}
|
||||
17
app/components/StageBannerBox.module.css
Normal file
17
app/components/StageBannerBox.module.css
Normal 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;
|
||||
}
|
||||
33
app/components/StageBannerBox.tsx
Normal file
33
app/components/StageBannerBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
|
||||
105
app/components/objective-timeline-utils.test.ts
Normal file
105
app/components/objective-timeline-utils.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
109
app/components/objective-timeline-utils.ts
Normal file
109
app/components/objective-timeline-utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
.tierPillContainer {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
margin-inline-start: var(--s-2);
|
||||
}
|
||||
|
||||
.organizationName {
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -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={
|
||||
|
||||
118
app/features/img-export/core/RunComps.test.ts
Normal file
118
app/features/img-export/core/RunComps.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
132
app/features/img-export/core/RunComps.ts
Normal file
132
app/features/img-export/core/RunComps.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
967
app/features/scanner-ingest/ScannerIngestRepository.server.ts
Normal file
967
app/features/scanner-ingest/ScannerIngestRepository.server.ts
Normal 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();
|
||||
}
|
||||
296
app/features/scanner-ingest/actions/scanner-ingest.server.ts
Normal file
296
app/features/scanner-ingest/actions/scanner-ingest.server.ts
Normal 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();
|
||||
}
|
||||
285
app/features/scanner-ingest/core/Matches.test.ts
Normal file
285
app/features/scanner-ingest/core/Matches.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
375
app/features/scanner-ingest/core/Matches.ts
Normal file
375
app/features/scanner-ingest/core/Matches.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
756
app/features/scanner-ingest/core/Scoreboards.test.ts
Normal file
756
app/features/scanner-ingest/core/Scoreboards.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
502
app/features/scanner-ingest/core/Scoreboards.ts
Normal file
502
app/features/scanner-ingest/core/Scoreboards.ts
Normal 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;
|
||||
}
|
||||
82
app/features/scanner-ingest/core/VodMatches.test.ts
Normal file
82
app/features/scanner-ingest/core/VodMatches.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
32
app/features/scanner-ingest/core/VodMatches.ts
Normal file
32
app/features/scanner-ingest/core/VodMatches.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
1
app/features/scanner-ingest/routes/scanner-ingest.ts
Normal file
1
app/features/scanner-ingest/routes/scanner-ingest.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { action } from "../actions/scanner-ingest.server";
|
||||
33
app/features/scanner-ingest/scanner-ingest-schemas.ts
Normal file
33
app/features/scanner-ingest/scanner-ingest-schemas.ts
Normal 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;
|
||||
}
|
||||
47
app/features/scanner-ingest/scanner-ingest-vod-schemas.ts
Normal file
47
app/features/scanner-ingest/scanner-ingest-vod-schemas.ts
Normal 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>;
|
||||
176
app/features/scanner/README.md
Normal file
176
app/features/scanner/README.md
Normal 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.
|
||||
75
app/features/scanner/capture/sampler.ts
Normal file
75
app/features/scanner/capture/sampler.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
9
app/features/scanner/capture/ticker.worker.ts
Normal file
9
app/features/scanner/capture/ticker.worker.ts
Normal 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);
|
||||
};
|
||||
95
app/features/scanner/capture/vod-frames.ts
Normal file
95
app/features/scanner/capture/vod-frames.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
61
app/features/scanner/components/AbilityGrid.tsx
Normal file
61
app/features/scanner/components/AbilityGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
app/features/scanner/components/App.tsx
Normal file
56
app/features/scanner/components/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
app/features/scanner/components/DeathCard.tsx
Normal file
79
app/features/scanner/components/DeathCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
app/features/scanner/components/EventCard.tsx
Normal file
158
app/features/scanner/components/EventCard.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
47
app/features/scanner/components/EventTypeIcon.tsx
Normal file
47
app/features/scanner/components/EventTypeIcon.tsx
Normal 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" />;
|
||||
}
|
||||
64
app/features/scanner/components/EventsSummary.tsx
Normal file
64
app/features/scanner/components/EventsSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
app/features/scanner/components/FrameThumb.tsx
Normal file
108
app/features/scanner/components/FrameThumb.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
558
app/features/scanner/components/LivePage.tsx
Normal file
558
app/features/scanner/components/LivePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
app/features/scanner/components/MapStartCard.tsx
Normal file
45
app/features/scanner/components/MapStartCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
344
app/features/scanner/components/MatchCard.tsx
Normal file
344
app/features/scanner/components/MatchCard.tsx
Normal 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}`;
|
||||
}
|
||||
137
app/features/scanner/components/MatchLobbyTabs.tsx
Normal file
137
app/features/scanner/components/MatchLobbyTabs.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
}
|
||||
38
app/features/scanner/components/MetaChips.tsx
Normal file
38
app/features/scanner/components/MetaChips.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
app/features/scanner/components/MinimapCard.tsx
Normal file
140
app/features/scanner/components/MinimapCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
app/features/scanner/components/ObjectiveCard.tsx
Normal file
57
app/features/scanner/components/ObjectiveCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
app/features/scanner/components/ScoreboardCard.tsx
Normal file
142
app/features/scanner/components/ScoreboardCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
app/features/scanner/components/ScoreboardOwnCard.tsx
Normal file
69
app/features/scanner/components/ScoreboardOwnCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
729
app/features/scanner/components/ScreenshotPage.tsx
Normal file
729
app/features/scanner/components/ScreenshotPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
911
app/features/scanner/components/VodPage.tsx
Normal file
911
app/features/scanner/components/VodPage.tsx
Normal 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);
|
||||
}
|
||||
42
app/features/scanner/components/dedupe-events.ts
Normal file
42
app/features/scanner/components/dedupe-events.ts
Normal 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"])),
|
||||
};
|
||||
}
|
||||
259
app/features/scanner/components/events-csv.ts
Normal file
259
app/features/scanner/components/events-csv.ts
Normal 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);
|
||||
}
|
||||
252
app/features/scanner/components/fixture-export.ts
Normal file
252
app/features/scanner/components/fixture-export.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
}
|
||||
37
app/features/scanner/components/format.ts
Normal file
37
app/features/scanner/components/format.ts
Normal 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));
|
||||
}
|
||||
59
app/features/scanner/components/labels.ts
Normal file
59
app/features/scanner/components/labels.ts
Normal 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];
|
||||
}
|
||||
281
app/features/scanner/components/sendou-ingest.ts
Normal file
281
app/features/scanner/components/sendou-ingest.ts
Normal 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)}` : ""}`;
|
||||
}
|
||||
89
app/features/scanner/components/sendou-upload.ts
Normal file
89
app/features/scanner/components/sendou-upload.ts
Normal 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
|
||||
);
|
||||
}
|
||||
1218
app/features/scanner/components/styles.css
Normal file
1218
app/features/scanner/components/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
10
app/features/scanner/components/thumbnail.ts
Normal file
10
app/features/scanner/components/thumbnail.ts
Normal 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);
|
||||
}
|
||||
97
app/features/scanner/core/ability-harvest.ts
Normal file
97
app/features/scanner/core/ability-harvest.ts
Normal 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;
|
||||
}
|
||||
14
app/features/scanner/core/canonical.ts
Normal file
14
app/features/scanner/core/canonical.ts
Normal 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;
|
||||
}
|
||||
90
app/features/scanner/core/cv.ts
Normal file
90
app/features/scanner/core/cv.ts
Normal 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);
|
||||
}
|
||||
80
app/features/scanner/core/detectors/death/abilities.ts
Normal file
80
app/features/scanner/core/detectors/death/abilities.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
832
app/features/scanner/core/detectors/death/index.ts
Normal file
832
app/features/scanner/core/detectors/death/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
9916
app/features/scanner/core/detectors/death/localized-messages.ts
Normal file
9916
app/features/scanner/core/detectors/death/localized-messages.ts
Normal file
File diff suppressed because it is too large
Load Diff
146
app/features/scanner/core/detectors/death/rois.ts
Normal file
146
app/features/scanner/core/detectors/death/rois.ts
Normal 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;
|
||||
245
app/features/scanner/core/detectors/death/weapon-names.ts
Normal file
245
app/features/scanner/core/detectors/death/weapon-names.ts
Normal 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"),
|
||||
];
|
||||
365
app/features/scanner/core/detectors/map-start/index.ts
Normal file
365
app/features/scanner/core/detectors/map-start/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
97
app/features/scanner/core/detectors/map-start/rois.ts
Normal file
97
app/features/scanner/core/detectors/map-start/rois.ts
Normal 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;
|
||||
24
app/features/scanner/core/detectors/minimap/abilities.ts
Normal file
24
app/features/scanner/core/detectors/minimap/abilities.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
657
app/features/scanner/core/detectors/minimap/index.ts
Normal file
657
app/features/scanner/core/detectors/minimap/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
254
app/features/scanner/core/detectors/minimap/rois.ts
Normal file
254
app/features/scanner/core/detectors/minimap/rois.ts
Normal 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;
|
||||
224
app/features/scanner/core/detectors/minimap/stage.ts
Normal file
224
app/features/scanner/core/detectors/minimap/stage.ts
Normal 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 };
|
||||
});
|
||||
}
|
||||
360
app/features/scanner/core/detectors/objective/index.ts
Normal file
360
app/features/scanner/core/detectors/objective/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
118
app/features/scanner/core/detectors/objective/rois.ts
Normal file
118
app/features/scanner/core/detectors/objective/rois.ts
Normal 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;
|
||||
51
app/features/scanner/core/detectors/registry.ts
Normal file
51
app/features/scanner/core/detectors/registry.ts
Normal 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>,
|
||||
];
|
||||
}
|
||||
341
app/features/scanner/core/detectors/scheduler.ts
Normal file
341
app/features/scanner/core/detectors/scheduler.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
268
app/features/scanner/core/detectors/scoreboard-own/index.ts
Normal file
268
app/features/scanner/core/detectors/scoreboard-own/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
90
app/features/scanner/core/detectors/scoreboard-own/rois.ts
Normal file
90
app/features/scanner/core/detectors/scoreboard-own/rois.ts
Normal 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;
|
||||
286
app/features/scanner/core/detectors/scoreboard/banner.ts
Normal file
286
app/features/scanner/core/detectors/scoreboard/banner.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
58
app/features/scanner/core/detectors/scoreboard/digits.ts
Normal file
58
app/features/scanner/core/detectors/scoreboard/digits.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
193
app/features/scanner/core/detectors/scoreboard/header.ts
Normal file
193
app/features/scanner/core/detectors/scoreboard/header.ts
Normal 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
Reference in New Issue
Block a user