diff --git a/.gitignore b/.gitignore index 473235b62..31a272f21 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ notepad.txt # proprietary game fonts for the scanner glyph-atlas builders (scripts/scanner) /assets/fonts/ + +*.mp4 +*.mkv \ No newline at end of file diff --git a/app/components/GameTimeline.module.css b/app/components/GameTimeline.module.css new file mode 100644 index 000000000..30e626b44 --- /dev/null +++ b/app/components/GameTimeline.module.css @@ -0,0 +1,120 @@ +.root { + position: relative; + display: flex; + flex-direction: column; + gap: var(--s-3); + /* horizontal touch drags scrub, vertical ones keep scrolling the page */ + touch-action: pan-y; +} + +/* pinned to the shared plot area: both cards pad horizontally by --s-3 and + keep their y-labels inside the same fixed gutter */ +.plotOverlay { + position: absolute; + inset: 0 var(--s-3) 0 calc(var(--s-3) + var(--plot-gutter)); + pointer-events: none; +} + +.scrubLine { + position: absolute; + top: 0; + bottom: 0; + border-left: 2px dotted var(--color-text-high); +} + +.readout { + position: absolute; + z-index: 2; + display: flex; + flex-direction: column; + gap: var(--s-1-5); + max-width: 16rem; + padding: var(--s-2) var(--s-2-5); + border: var(--border-style); + border-radius: var(--radius-box); + background-color: var(--color-bg); + font-size: var(--font-xs); + transform: translateY(-50%); + white-space: nowrap; +} + +.readoutPinned { + top: 0; + left: 50%; + transform: translateX(-50%); + max-width: calc(100% - var(--s-2)); + white-space: normal; +} + +.readoutTitle { + font-weight: var(--weight-semi); + color: var(--color-text-high); +} + +.readoutTeam { + display: flex; + flex-direction: column; + gap: var(--s-1); +} + +.readoutTeamHeader { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--s-1-5); +} + +.readoutTeamName { + font-weight: var(--weight-semi); +} + +.readoutScore { + font-variant-numeric: tabular-nums; +} + +.readoutPenalty, +.readoutControl { + color: var(--color-text-high); +} + +.swatch { + flex-shrink: 0; + width: 8px; + height: 8px; + border-radius: 2px; + + &.swatchAlpha { + background-color: var(--color-chart-alpha); + } + + &.swatchBravo { + background-color: var(--color-chart-bravo); + } +} + +.readoutStatusRow { + display: flex; + align-items: center; + gap: var(--s-1-5); + padding-left: calc(8px + var(--s-1-5)); +} + +.readoutStatusLabel { + &.statusLabelDead { + color: var(--color-error); + } + + &.statusLabelSpecial { + color: var(--color-info); + } +} + +.readoutWeapons { + display: flex; + align-items: center; + gap: var(--s-1); +} + +.unknownWeapon { + opacity: 0.5; +} diff --git a/app/components/GameTimeline.tsx b/app/components/GameTimeline.tsx new file mode 100644 index 000000000..152e11217 --- /dev/null +++ b/app/components/GameTimeline.tsx @@ -0,0 +1,377 @@ +/** + * A game's two scanned-timeline charts stacked on one shared time axis and + * plot width: per-player status bands above the objective-counter chart. + * Hovering scrubs over both — a dotted cursor line spans the charts and a + * readout next to the cursor shows the moment's elapsed time, match clock, + * scores, penalties, who was in control, who was splatted and who had their + * special ready. The chart's own tooltip is turned off in favor of the + * readout. + */ +import clsx from "clsx"; +import { memo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { clamp } from "remeda"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { abilityImageUrl } from "~/utils/urls"; +import styles from "./GameTimeline.module.css"; +import { Image, WeaponImage } from "./Image"; +import { + ObjectiveTimeline, + type ObjectiveTimelineEvent, +} from "./ObjectiveTimeline"; +import { + formatElapsed, + smoothPenalties, + TIMELINE_PLOT_GUTTER_PX, +} from "./objective-timeline-utils"; +import { + PLAYER_STATUS_TAIL_SECONDS, + PlayerStatusTimeline, + type PlayerStatusTimelineSample, + type PlayerStatusTimelineTeam, + statusSpans, +} from "./PlayerStatusTimeline"; + +/** Cursor position past this fraction of the plot flips the readout to its left side. */ +const READOUT_FLIP_RATIO = 0.55; +const READOUT_CURSOR_GAP_PX = 12; + +interface GameTimelineProps { + objectiveEvents?: readonly ObjectiveTimelineEvent[]; + playerStatusSamples?: readonly PlayerStatusTimelineSample[]; + teams: readonly [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam]; +} + +interface ScrubPosition { + /** px from the plot area's left edge */ + x: number; + /** px from the plot area's top edge */ + y: number; + width: number; + /** touch scrubs pin the readout to the top so the finger doesn't hide it */ + pinned: boolean; +} + +export function GameTimeline({ + objectiveEvents, + playerStatusSamples, + teams, +}: GameTimelineProps) { + const [scrub, setScrub] = useState(null); + const plotRef = useRef(null); + + const objective = (objectiveEvents ?? []).toSorted((a, b) => a.t - b.t); + const samples = (playerStatusSamples ?? []).toSorted((a, b) => a.t - b.t); + const domain = timelineDomain(objective, samples); + if (!domain) return null; + + const handlePointer = (event: React.PointerEvent) => { + const rect = plotRef.current?.getBoundingClientRect(); + if (!rect || rect.width <= 0) return; + setScrub({ + x: clamp(event.clientX - rect.left, { min: 0, max: rect.width }), + y: clamp(event.clientY - rect.top, { min: 0, max: rect.height }), + width: rect.width, + pinned: event.pointerType === "touch", + }); + }; + + return ( +
{ + // touch fires a leave as the finger lifts; keep the readout up instead + if (event.pointerType !== "touch") setScrub(null); + }} + > + +
+ {scrub ? ( + + ) : null} +
+
+ ); +} + +/** Memoized so scrubbing re-renders only the overlay, not the chart canvas. */ +const TimelineCharts = memo(function TimelineCharts({ + objectiveEvents, + playerStatusSamples, + teams, +}: GameTimelineProps) { + const objective = (objectiveEvents ?? []).toSorted((a, b) => a.t - b.t); + const samples = (playerStatusSamples ?? []).toSorted((a, b) => a.t - b.t); + const domain = timelineDomain(objective, samples); + if (!domain) return null; + + return ( + <> + {samples.length > 0 ? ( + + ) : null} + {objective.length > 0 ? ( + + ) : null} + + ); +}); + +function ScrubReadout({ + scrub, + domain, + objective, + samples, + teams, +}: { + scrub: ScrubPosition; + domain: [number, number]; + objective: readonly ObjectiveTimelineEvent[]; + samples: readonly PlayerStatusTimelineSample[]; + teams: GameTimelineProps["teams"]; +}) { + const { t } = useTranslation(["common"]); + const [min, max] = domain; + const time = min + (scrub.x / scrub.width) * (max - min); + const objectiveNow = objectiveStateAt(objective, time); + const statusNow = playerStatusAt(samples, time); + const flipped = scrub.x > scrub.width * READOUT_FLIP_RATIO; + + return ( + <> +
+
+
+ {formatElapsed(time)} + {objectiveNow?.clock != null + ? ` · ${t("common:objectiveTimeline.timeLeft", { + time: formatElapsed(objectiveNow.clock), + })}` + : null} +
+ {([0, 1] as const).map((side) => ( +
+
+ + + {teams[side].label} + + {objectiveNow ? ( + + {objectiveNow.scores[side] ?? "?"} + + ) : null} + {objectiveNow?.penalties[side] != null ? ( + + {t("common:objectiveTimeline.penalty", { + value: objectiveNow.penalties[side], + })} + + ) : null} + {objectiveNow?.control[side] ? ( + + {t("common:objectiveTimeline.inControl")} + + ) : null} +
+ {statusNow ? ( + <> + + + + ) : null} +
+ ))} +
+ + ); +} + +function StatusWeaponsRow({ + label, + kind, + slots, + weapons, +}: { + label: string; + kind: "dead" | "special"; + slots: number[]; + weapons: (MainWeaponId | null)[]; +}) { + if (slots.length === 0) return null; + + return ( +
+ + {label} + + + {slots.map((slot) => + weapons[slot] != null ? ( + + ) : ( + ? + ), + )} + +
+ ); +} + +function timelineDomain( + objective: readonly ObjectiveTimelineEvent[], + samples: readonly PlayerStatusTimelineSample[], +): [number, number] | null { + const start = Math.min( + objective[0]?.t ?? Number.POSITIVE_INFINITY, + samples[0]?.t ?? Number.POSITIVE_INFINITY, + ); + const end = Math.max( + objective[objective.length - 1]?.t ?? Number.NEGATIVE_INFINITY, + samples.length > 0 + ? samples[samples.length - 1]!.t + PLAYER_STATUS_TAIL_SECONDS + : Number.NEGATIVE_INFINITY, + ); + if (!Number.isFinite(start) || !Number.isFinite(end)) return null; + return [start, Math.max(end, start + 1)]; +} + +interface ObjectiveStateAtTime { + /** seconds shown on the match timer at the latest read; null = unreadable */ + clock: number | null; + /** last readable count per team at the scrubbed moment */ + scores: [number | null, number | null]; + penalties: [number | null, number | null]; + control: [boolean, boolean]; +} + +/** State implied by the last objective read at or before `time`, scores carried across unreadable reads. */ +function objectiveStateAt( + sorted: readonly ObjectiveTimelineEvent[], + time: number, +): ObjectiveStateAtTime | null { + let index = -1; + for (let i = 0; i < sorted.length; i++) { + if (sorted[i]!.t > time) break; + index = i; + } + if (index === -1) return null; + + const scores: [number | null, number | null] = [null, null]; + for (let i = 0; i <= index; i++) { + scores[0] = sorted[i]!.data.score[0] ?? scores[0]; + scores[1] = sorted[i]!.data.score[1] ?? scores[1]; + } + const penalties = ([0, 1] as const).map( + (side) => + smoothPenalties( + sorted.map((event) => ({ + t: event.t, + penalty: event.data.penalty[side], + })), + )[index] ?? null, + ) as [number | null, number | null]; + const latest = sorted[index]!; + + return { + clock: latest.data.time, + scores, + penalties, + control: [latest.data.control[0], latest.data.control[1]], + }; +} + +interface PlayerStatusAtTime { + /** slot indexes inside a splatted band at the scrubbed moment, per side */ + dead: [number[], number[]]; + /** slot indexes inside a special-ready band at the scrubbed moment, per side */ + special: [number[], number[]]; +} + +/** Matches the rendered bands: a player counts only while inside a drawn span. */ +function playerStatusAt( + sorted: readonly PlayerStatusTimelineSample[], + time: number, +): PlayerStatusAtTime | null { + if (sorted.length === 0) return null; + + const activeSlots = (side: 0 | 1, kind: "dead" | "special") => + [0, 1, 2, 3].filter((slot) => + statusSpans(sorted, (sample) => sample[kind][side][slot]!).some( + (span) => span.start <= time && time <= span.end, + ), + ); + + return { + dead: [activeSlots(0, "dead"), activeSlots(1, "dead")], + special: [activeSlots(0, "special"), activeSlots(1, "special")], + }; +} diff --git a/app/components/ObjectiveTimeline.tsx b/app/components/ObjectiveTimeline.tsx index 42584e904..4081f5c1c 100644 --- a/app/components/ObjectiveTimeline.tsx +++ b/app/components/ObjectiveTimeline.tsx @@ -30,7 +30,11 @@ 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"; +import { + formatElapsed, + smoothPenalties, + TIMELINE_PLOT_GUTTER_PX, +} from "./objective-timeline-utils"; ChartJS.register( LinearScale, @@ -68,9 +72,15 @@ export interface ObjectiveTimelineEvent { export function ObjectiveTimeline({ events, teamLabels, + domain, + showTooltip = true, }: { events: readonly ObjectiveTimelineEvent[]; teamLabels: readonly [string, string]; + /** x-axis range override, to share the player-status timeline's axis */ + domain?: [number, number]; + /** off when a parent renders its own scrub readout over the chart */ + showTooltip?: boolean; }) { const { t } = useTranslation(["common"]); const colors = useThemeColors({ @@ -166,17 +176,19 @@ export function ObjectiveTimeline({ animation: false, maintainAspectRatio: false, interaction: { mode: "index", intersect: false }, + layout: { autoPadding: false }, scales: { x: { type: "linear", - min: sorted[0]!.t, - max: sorted[sorted.length - 1]!.t, + min: domain?.[0] ?? sorted[0]!.t, + max: domain?.[1] ?? sorted[sorted.length - 1]!.t, grid: { color: colors.border }, border: { color: colors.borderHigh }, ticks: { color: colors.text, maxRotation: 0, maxTicksLimit: 8, + align: "inner", callback: (value) => formatElapsed(Number(value)), }, }, @@ -192,6 +204,9 @@ export function ObjectiveTimeline({ afterBuildTicks: (axis) => { axis.ticks = countAxisTicks(axis.max); }, + afterFit: (axis) => { + axis.width = TIMELINE_PLOT_GUTTER_PX; + }, ticks: { color: colors.text, autoSkip: false }, }, }, @@ -205,6 +220,7 @@ export function ObjectiveTimeline({ }, }, tooltip: { + enabled: showTooltip, filter: (item) => item.datasetIndex < 2, callbacks: { title: (items) => { @@ -266,16 +282,6 @@ function gridColor( 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); diff --git a/app/components/PlayerStatusTimeline.module.css b/app/components/PlayerStatusTimeline.module.css new file mode 100644 index 000000000..789a7ab95 --- /dev/null +++ b/app/components/PlayerStatusTimeline.module.css @@ -0,0 +1,94 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--s-2); + background-color: var(--color-bg-high); + border-radius: var(--radius-box); + padding: var(--s-2-5) var(--s-3); +} + +.legend { + display: flex; + gap: var(--s-4); + justify-content: center; + font-size: var(--font-xs); + color: var(--color-text-high); +} + +.legendItem { + display: flex; + align-items: center; + gap: var(--s-1); +} + +.legendSwatchDead, +.legendSwatchSpecial { + width: 10px; + height: 10px; + border-radius: 2px; +} + +.legendSwatchDead { + background-color: var(--color-error); +} + +.legendSwatchSpecial { + background-color: var(--color-info); +} + +.team { + display: flex; + flex-direction: column; + gap: var(--s-1); +} + +.teamLabel { + font-size: var(--font-xs); + font-weight: 600; + color: var(--color-text-high); +} + +.row { + display: flex; + align-items: center; +} + +.slotLabel { + display: flex; + justify-content: flex-end; + flex-shrink: 0; + width: var(--plot-gutter, 36px); + padding-right: var(--s-2); +} + +.track { + position: relative; + flex: 1; + height: 14px; + overflow: hidden; + border-radius: var(--radius-selector); + background-color: var(--color-bg-higher); +} + +.spanDead, +.spanSpecial { + position: absolute; + top: 2px; + bottom: 2px; + min-width: 3px; + border-radius: 2px; +} + +.spanDead { + background-color: var(--color-error); + opacity: 0.75; +} + +.spanSpecial { + background-color: var(--color-info); + opacity: 0.75; +} + +.unknownWeapon { + opacity: 0.5; +} diff --git a/app/components/PlayerStatusTimeline.tsx b/app/components/PlayerStatusTimeline.tsx new file mode 100644 index 000000000..2ac487bcf --- /dev/null +++ b/app/components/PlayerStatusTimeline.tsx @@ -0,0 +1,182 @@ +/** + * Per-player status bands over a game's scanned icon-strip reads: one row + * per player (weapon icon as the label), a band while the player was + * splatted and another while they held their special, both teams stacked. + * Rendered above the ObjectiveTimeline chart on the same `t` seconds axis — + * pass `domain` so both span the same range. Reads re-confirm an unchanged + * state every few seconds; a longer sample gap means the HUD was not + * observed, so bands never bridge across one (the state there is unknown, + * not continued). + */ +import { useTranslation } from "react-i18next"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { abilityImageUrl } from "~/utils/urls"; +import { Image, WeaponImage } from "./Image"; +import { + formatElapsed, + TIMELINE_PLOT_GUTTER_PX, +} from "./objective-timeline-utils"; +import styles from "./PlayerStatusTimeline.module.css"; + +/** Consecutive reads further apart than this leave an unknown gap. */ +const MAX_BRIDGE_SECONDS = 15; + +/** Trailing open band drawn this long past its last confirming read. */ +export const PLAYER_STATUS_TAIL_SECONDS = 1; + +type PlayerFlags = readonly [boolean, boolean, boolean, boolean]; + +/** One icon-strip read, sides in `[alpha, bravo]` order. */ +export interface PlayerStatusTimelineSample { + /** seconds into the source (video, stream or game) the read was made at */ + t: number; + special: readonly [PlayerFlags, PlayerFlags]; + dead: readonly [PlayerFlags, PlayerFlags]; +} + +export interface PlayerStatusTimelineTeam { + label: string; + /** weapon per slot in row order; null/absent slots render a placeholder */ + weapons: (MainWeaponId | null)[]; +} + +export function PlayerStatusTimeline({ + samples, + teams, + domain, +}: { + samples: readonly PlayerStatusTimelineSample[]; + teams: readonly [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam]; + /** x-axis range override, to share the objective chart's axis */ + domain?: [number, number]; +}) { + const { t } = useTranslation(["common"]); + const sorted = samples.toSorted((a, b) => a.t - b.t); + if (sorted.length === 0) return null; + + const min = Math.min(domain?.[0] ?? Number.POSITIVE_INFINITY, sorted[0]!.t); + const max = Math.max( + domain?.[1] ?? 0, + sorted[sorted.length - 1]!.t + PLAYER_STATUS_TAIL_SECONDS, + ); + const range = Math.max(1, max - min); + const leftOf = (span: StatusSpan) => `${((span.start - min) / range) * 100}%`; + const widthOf = (span: StatusSpan) => + `${((span.end - span.start) / range) * 100}%`; + const titleOf = (label: string, span: StatusSpan) => + `${label} · ${formatElapsed(span.start)}–${formatElapsed(span.end)}`; + + return ( +
+
+ + + {t("common:playerStatusTimeline.splatted")} + + + + {t("common:playerStatusTimeline.specialReady")} + +
+ {([0, 1] as const).map((side) => ( +
+
{teams[side].label}
+ {[0, 1, 2, 3].map((slot) => ( +
+
+ +
+
+ {statusSpans(sorted, (sample) => sample.dead[side][slot]!).map( + (span, i) => ( +
+ ), + )} + {statusSpans( + sorted, + (sample) => sample.special[side][slot]!, + ).map((span, i) => ( +
+ ))} +
+
+ ))} +
+ ))} +
+ ); +} + +function SlotWeapon({ weaponSplId }: { weaponSplId: MainWeaponId | null }) { + if (weaponSplId === null) { + return ( + ? + ); + } + return ; +} + +interface StatusSpan { + start: number; + end: number; +} + +/** + * Contiguous stretches where the flag held true: a span opens at its first + * true read and closes at the read that shows false — or one second past + * its last confirmation when the next read is too far away (or the series + * ends) to know what happened in between. + */ +export function statusSpans( + sorted: readonly PlayerStatusTimelineSample[], + flagOf: (sample: PlayerStatusTimelineSample) => boolean, +): StatusSpan[] { + const spans: StatusSpan[] = []; + let start: number | null = null; + let lastTrueT = 0; + for (const sample of sorted) { + const flag = flagOf(sample); + if (start !== null && sample.t - lastTrueT > MAX_BRIDGE_SECONDS) { + spans.push({ start, end: lastTrueT + PLAYER_STATUS_TAIL_SECONDS }); + start = null; + } + if (flag) { + start ??= sample.t; + lastTrueT = sample.t; + } else if (start !== null) { + spans.push({ start, end: sample.t }); + start = null; + } + } + if (start !== null) + spans.push({ start, end: lastTrueT + PLAYER_STATUS_TAIL_SECONDS }); + return spans; +} diff --git a/app/components/match-page/MatchTimeline.tsx b/app/components/match-page/MatchTimeline.tsx index 41c3d82c2..d8e625a68 100644 --- a/app/components/match-page/MatchTimeline.tsx +++ b/app/components/match-page/MatchTimeline.tsx @@ -30,12 +30,11 @@ import { Ability } from "../Ability"; import { Avatar } from "../Avatar"; import { SendouButton } from "../elements/Button"; import { SendouPopover } from "../elements/Popover"; +import { GameTimeline } from "../GameTimeline"; import { Image, ModeImage, StageImage, WeaponImage } from "../Image"; -import { - ObjectiveTimeline, - type ObjectiveTimelineEvent, -} from "../ObjectiveTimeline"; +import type { ObjectiveTimelineEvent } from "../ObjectiveTimeline"; import { matchScoresFromObjective } from "../objective-timeline-utils"; +import type { PlayerStatusTimelineSample } from "../PlayerStatusTimeline"; import styles from "./MatchTimeline.module.css"; import { type InferredSubstitution, inferSubstitutions } from "./utils"; import type { WeaponPoolWeapon } from "./WeaponPool"; @@ -93,6 +92,8 @@ export interface TimelineMap { bravo: TimelineScoreboardPlayer[]; /** Objective-counter reads ([alpha, bravo] values) charted above the stats tables. */ objective?: ObjectiveTimelineEvent[]; + /** Per-player splat/special bands ([alpha, bravo]) charted above the objective chart. */ + playerStatus?: PlayerStatusTimelineSample[]; }; } @@ -442,12 +443,20 @@ function TimelineScoreboardSection({ {isExpanded ? (
- {scoreboard.objective && scoreboard.objective.length > 0 ? ( - - ) : null} + player.weaponSplId), + }, + { + label: teams.bravo.name, + weapons: scoreboard.bravo.map((player) => player.weaponSplId), + }, + ]} + />
0 + ? `${hours}:${String(minutes).padStart(2, "0")}:${rest}` + : `${minutes}:${rest}`; +} + function medianFilterValues( values: readonly (number | null)[], ): (number | null)[] { diff --git a/app/features/match-page-test/routes/match-page-test.tsx b/app/features/match-page-test/routes/match-page-test.tsx index d4222351e..697aa7fd2 100644 --- a/app/features/match-page-test/routes/match-page-test.tsx +++ b/app/features/match-page-test/routes/match-page-test.tsx @@ -23,12 +23,16 @@ 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 type { PlayerStatusTimelineSample } from "~/components/PlayerStatusTimeline"; 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(); +/** Icon-strip reads of the same made-up game, for previewing the status bands. */ +const MOCK_PLAYER_STATUS_SAMPLES = mockPlayerStatusSamples(); + type ActionVariant = | "winner" | "counterpick-stage" @@ -699,6 +703,7 @@ export default function MatchPageTestRoute() { }, scoreboard: { objective: MOCK_OBJECTIVE_EVENTS, + playerStatus: MOCK_PLAYER_STATUS_SAMPLES, scores: [100, 0], alpha: [ { @@ -912,3 +917,32 @@ function mockObjectiveEvents(): ObjectiveTimelineEvent[] { return events; } + +/** + * Staggered respawn and special cycles per player over the same game as + * `mockObjectiveEvents`, sampled at the same cadence. + */ +function mockPlayerStatusSamples(): PlayerStatusTimelineSample[] { + const DURATION_SECONDS = 190; + const SAMPLE_EVERY_SECONDS = 2; + + const samples: PlayerStatusTimelineSample[] = []; + for ( + let t = SAMPLE_EVERY_SECONDS; + t <= DURATION_SECONDS; + t += SAMPLE_EVERY_SECONDS + ) { + const flags = (kind: "dead" | "special", side: number) => + [0, 1, 2, 3].map((slot) => { + const phase = t + slot * 17 + side * 31; + return kind === "dead" ? phase % 61 < 8 : phase % 47 < 12; + }) as [boolean, boolean, boolean, boolean]; + + samples.push({ + t, + dead: [flags("dead", 0), flags("dead", 1)], + special: [flags("special", 0), flags("special", 1)], + }); + } + return samples; +} diff --git a/app/features/scanner-ingest/ScannerIngestRepository.server.test.ts b/app/features/scanner-ingest/ScannerIngestRepository.server.test.ts index 771119045..e9c04bc96 100644 --- a/app/features/scanner-ingest/ScannerIngestRepository.server.test.ts +++ b/app/features/scanner-ingest/ScannerIngestRepository.server.test.ts @@ -299,6 +299,7 @@ function testMatch(partial: Partial = {}): ScannerMatch { replayCode: null, cast: false, objective: null, + playerStatus: 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]!)) }, diff --git a/app/features/scanner-ingest/core/Matches.test.ts b/app/features/scanner-ingest/core/Matches.test.ts index 3138bbb75..6dc2380d0 100644 --- a/app/features/scanner-ingest/core/Matches.test.ts +++ b/app/features/scanner-ingest/core/Matches.test.ts @@ -37,6 +37,7 @@ function testMatch(partial: Partial = {}): ScannerMatch { replayCode: null, cast: false, objective: null, + playerStatus: 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]!)) }, @@ -283,3 +284,53 @@ describe("mergeMatches", () => { expect(merged.matchScores).toEqual([100, 52]); }); }); + +describe("playerStatus", () => { + const STATUS: NonNullable = { + samples: [ + { + t: 60, + time: 240, + special: [ + [true, false, false, false], + [false, false, false, false], + ], + dead: [ + [false, false, false, false], + [false, true, false, false], + ], + }, + ], + }; + + it("merges whole-series first-ingest-wins", () => { + const filled = Matches.mergeMatches( + testMatch(), + testMatch({ playerStatus: STATUS }), + ); + expect(filled.merged.playerStatus).toEqual(STATUS); + expect(filled.changed).toBe(true); + + const kept = Matches.mergeMatches( + testMatch({ playerStatus: STATUS }), + testMatch({ playerStatus: { samples: [] } }), + ); + expect(kept.merged.playerStatus).toEqual(STATUS); + }); + + it("side-aligning an incoming match swaps its status samples too", () => { + const incoming = sideSwapped(testMatch({ playerStatus: STATUS })); + const { merged } = Matches.mergeMatches( + testMatch({ winner: null, matchScores: null, playerStatus: null }), + incoming, + ); + expect(merged.playerStatus!.samples[0]!.dead).toEqual([ + [false, true, false, false], + [false, false, false, false], + ]); + expect(merged.playerStatus!.samples[0]!.special).toEqual([ + [false, false, false, false], + [true, false, false, false], + ]); + }); +}); diff --git a/app/features/scanner-ingest/core/Matches.ts b/app/features/scanner-ingest/core/Matches.ts index b3e090848..d357a8e0c 100644 --- a/app/features/scanner-ingest/core/Matches.ts +++ b/app/features/scanner-ingest/core/Matches.ts @@ -7,6 +7,7 @@ import type { ScannerMatch, ScannerMatchObjective, ScannerMatchPlayer, + ScannerMatchPlayerStatus, ScannerMatchTeam, } from "~/features/scanner/core/scanner-match"; import { inGameNameWithoutDiscriminator } from "~/utils/strings"; @@ -52,6 +53,11 @@ export function canonicalMatch(match: ScannerMatch): ScannerMatch { cast: match.cast, objective: match.objective === null ? null : canonicalObjective(match.objective), + // `?? null` also normalizes rows stored before the field existed + playerStatus: + match.playerStatus == null + ? null + : canonicalPlayerStatus(match.playerStatus), teams: [canonicalTeam(match.teams[0]), canonicalTeam(match.teams[1])], winner: match.winner, pov: @@ -128,6 +134,7 @@ export function mergeMatches( // whole-series first-ingest-wins: interleaving two partial sample // series from different scans is not attempted objective: existing.objective ?? oriented.objective, + playerStatus: existing.playerStatus ?? oriented.playerStatus, teams: [ mergeTeam(existing.teams[0], oriented.teams[0]), mergeTeam(existing.teams[1], oriented.teams[1]), @@ -167,6 +174,19 @@ function canonicalObjective( }; } +function canonicalPlayerStatus( + playerStatus: ScannerMatchPlayerStatus, +): ScannerMatchPlayerStatus { + return { + samples: playerStatus.samples.map((sample) => ({ + t: sample.t, + time: sample.time, + special: [[...sample.special[0]], [...sample.special[1]]], + dead: [[...sample.dead[0]], [...sample.dead[1]]], + })), + }; +} + function canonicalTeam(team: ScannerMatchTeam): ScannerMatchTeam { return { players: team.players.map(canonicalPlayer), @@ -291,6 +311,16 @@ function swapSides(match: ScannerMatch): ScannerMatch { control: [sample.control[1], sample.control[0]], })), }, + playerStatus: + match.playerStatus == null + ? null + : { + samples: match.playerStatus.samples.map((sample) => ({ + ...sample, + special: [sample.special[1], sample.special[0]], + dead: [sample.dead[1], sample.dead[0]], + })), + }, }; } diff --git a/app/features/scanner-ingest/core/Scoreboards.test.ts b/app/features/scanner-ingest/core/Scoreboards.test.ts index f75d8041c..44dbe7ed8 100644 --- a/app/features/scanner-ingest/core/Scoreboards.test.ts +++ b/app/features/scanner-ingest/core/Scoreboards.test.ts @@ -3,6 +3,7 @@ import type { ScannerMatch, ScannerMatchObjective, ScannerMatchPlayer, + ScannerMatchPlayerStatus, } from "~/features/scanner/core/scanner-match"; import type { ScannerLobby } from "~/features/scanner/scanner-types"; import type { @@ -58,6 +59,7 @@ function testMatch({ abilities = {}, povIndex = null, objective = null, + playerStatus = null, }: { t?: number; mode?: ModeShort | null; @@ -68,6 +70,7 @@ function testMatch({ abilities?: Record; povIndex?: number | null; objective?: ScannerMatchObjective | null; + playerStatus?: ScannerMatchPlayerStatus | null; } = {}): ScannerMatch { const players = names.map( (name, i): ScannerMatchPlayer => ({ @@ -91,6 +94,7 @@ function testMatch({ replayCode: null, cast: false, objective, + playerStatus, teams: [{ players: players.slice(0, 4) }, { players: players.slice(4) }], winner: 0, pov: @@ -122,6 +126,25 @@ function testObjective(): ScannerMatchObjective { }; } +function testPlayerStatus(): ScannerMatchPlayerStatus { + return { + samples: [ + { + t: 595, + time: 305, + special: [ + [true, false, false, false], + [false, false, false, false], + ], + dead: [ + [false, false, false, false], + [false, true, false, false], + ], + }, + ], + }; +} + /** The same game reported with sides in the other on-screen order. */ function swapSides(match: ScannerMatch): ScannerMatch { return { @@ -139,6 +162,16 @@ function swapSides(match: ScannerMatch): ScannerMatch { control: [sample.control[1], sample.control[0]], })), }, + playerStatus: + match.playerStatus === null + ? null + : { + samples: match.playerStatus.samples.map((sample) => ({ + ...sample, + special: [sample.special[1], sample.special[0]], + dead: [sample.dead[1], sample.dead[0]], + })), + }, winner: match.winner === null ? null : match.winner === 0 ? 1 : 0, matchScores: match.matchScores === null @@ -482,6 +515,43 @@ describe("deriveScoreboardData", () => { expect(swapped!.objective).toEqual(straight!.objective); }); + it("rebases status samples onto the same origin as the counter's", () => { + const data = derive([ + { + data: testMatch({ + objective: testObjective(), + playerStatus: testPlayerStatus(), + }), + povUserId: null, + }, + ]); + + // the status read at 595 came first, so it is the shared origin + expect(data!.playerStatus!.samples[0]!.t).toBe(0); + expect(data!.objective!.samples.map((sample) => sample.t)).toEqual([5, 35]); + }); + + it("derives status samples winner-first", () => { + const straight = derive([ + { + data: testMatch({ playerStatus: testPlayerStatus() }), + povUserId: null, + }, + ]); + const swapped = derive([ + { + data: swapSides(testMatch({ playerStatus: testPlayerStatus() })), + povUserId: null, + }, + ]); + + expect(straight!.playerStatus!.samples[0]!.dead).toEqual([ + [false, false, false, false], + [false, true, false, false], + ]); + expect(swapped!.playerStatus).toEqual(straight!.playerStatus); + }); + it("leaves out the objective of a match with no counter reads", () => { const data = derive([{ data: testMatch(), povUserId: null }]); diff --git a/app/features/scanner-ingest/core/Scoreboards.ts b/app/features/scanner-ingest/core/Scoreboards.ts index c481fae0c..490d94519 100644 --- a/app/features/scanner-ingest/core/Scoreboards.ts +++ b/app/features/scanner-ingest/core/Scoreboards.ts @@ -1,6 +1,7 @@ import type { ScannerMatch, ScannerMatchObjective, + ScannerMatchPlayerStatus, } from "~/features/scanner/core/scanner-match"; import type { ScannerLobby } from "~/features/scanner/scanner-types"; import type { @@ -219,6 +220,12 @@ export interface IngestedScoreboardData { * was read. */ objective?: ScannerMatchObjective; + /** + * per-player special/death samples, teams winner-first and `t` rebased + * onto the same origin as `objective` so both chart on one axis. Absent + * when the icon strip was never read. + */ + playerStatus?: ScannerMatchPlayerStatus; } /** @@ -271,6 +278,7 @@ export function deriveScoreboardData({ scores: view.scores, players, ...(view.objective ? { objective: view.objective } : null), + ...(view.playerStatus ? { playerStatus: view.playerStatus } : null), }; } @@ -298,6 +306,8 @@ interface WinnerFirstView { players: WinnerFirstPlayer[]; /** counter progress with both the sides and `t` already winner-first */ objective: ScannerMatchObjective | null; + /** status samples winner-first, on the same rebased `t` axis */ + playerStatus: ScannerMatchPlayerStatus | null; povIndex: number | null; /** chronological walk key: wall-clock, else video time, else input order */ order: number; @@ -331,6 +341,8 @@ function winnerFirstView( return null; } + const progressFirstT = firstProgressT(match); + return { lobby: match.lobby, mode: match.mode, @@ -343,7 +355,16 @@ function winnerFirstView( ...player, name: player.name ?? "", })), - objective: winnerFirstObjective(match.objective, match.winner), + objective: winnerFirstObjective( + match.objective, + match.winner, + progressFirstT, + ), + playerStatus: winnerFirstPlayerStatus( + match.playerStatus ?? null, + match.winner, + progressFirstT, + ), povIndex: match.pov === null ? null @@ -354,20 +375,34 @@ function winnerFirstView( }; } +/** + * The shared `t` origin of a match's progress series: the earliest counter + * or status read, so both rebase onto one axis and stay aligned without + * the source video. + */ +function firstProgressT(match: ScannerMatch): number { + const ts = [ + ...(match.objective?.samples ?? []).map((sample) => sample.t), + ...(match.playerStatus?.samples ?? []).map((sample) => sample.t), + ]; + return ts.length > 0 ? Math.min(...ts) : 0; +} + /** * 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. + * game's first progress read so the samples stay meaningful without the + * source video. */ function winnerFirstObjective( objective: ScannerMatchObjective | null, winner: 0 | 1, + firstT: number, ): ScannerMatchObjective | null { if (!objective || objective.samples.length === 0) return null; const winnerFirst = (pair: [T, T]): [T, T] => winner === 0 ? [pair[0], pair[1]] : [pair[1], pair[0]]; - const firstT = Math.min(...objective.samples.map((sample) => sample.t)); return { mode: objective.mode, @@ -381,6 +416,27 @@ function winnerFirstObjective( }; } +/** The status samples winner-first on the shared rebased `t` axis. */ +function winnerFirstPlayerStatus( + playerStatus: ScannerMatchPlayerStatus | null, + winner: 0 | 1, + firstT: number, +): ScannerMatchPlayerStatus | null { + if (!playerStatus || playerStatus.samples.length === 0) return null; + + const winnerFirst = (pair: [T, T]): [T, T] => + winner === 0 ? [pair[0], pair[1]] : [pair[1], pair[0]]; + + return { + samples: playerStatus.samples.map((sample) => ({ + t: sample.t - firstT, + time: sample.time, + special: winnerFirst(sample.special), + dead: winnerFirst(sample.dead), + })), + }; +} + /** * 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 diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index 6c9b21810..4d65b9a06 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -10,9 +10,8 @@ detected game per object, every field nullable — which feed `/ingest` 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. +English-only (no i18next) and `tests/node-test-compat.ts` uses a default +export to stay a `node:test` drop-in. ## Commands @@ -83,12 +82,68 @@ sequenceDiagram (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 + only the SZ member so far). The objective parse also emits a second + event type per read: `PlayerStatus` + (`core/detectors/objective/player-status.ts`), per-player special/dead + flags off the icon strip flanking the timer (POV and casted spectator + geometries; D-pad camera badges prove the cast layout, but broadcasts + can hide them while keeping cast geometry, so a badge-less frame scores + both geometries on how decisively the bodies read and sticks with the + established layout unless the other wins clearly — the special-ready + wash also pulses, so its dim trough is told apart from a splat by its + pale body, and a cast-layout ready read must also see a washed + (ink-poor) body: pale backdrop or the lead banner leaking past an icon + edge fakes the shoulder glow on the overhead map view's badge-less + strip), with + the same `time` value so the two reads pair downstream; its fixtures + live under `tests/fixtures/player-status/`. Within a side the strip's + slot order is the lobby seating, while the results scoreboard re-sorts + each team per game (attested in the sendou-triton VoD: strip [Planetz, + .52, Neo Splash, Snipewriter] vs rows [.52, Neo Splash, Snipewriter, + Planetz], and the orders differ per game while the seating holds) — so + every 5th counter read also samples a `StripWeapons` evidence event: a + ranked weapon-icon match per alive slot (the squid plate's team ink is + hue-knocked-out to flat grey first; splatted slots grey the render out + and are skipped). Single reads rank the true weapon top-1 only about + half the time; the builder aggregates them across the match — plus the + minimap cards' parsed weapons, whose column order mirrors the strip + seating (attested for the enemy column) — and takes the best-scoring of + the 24 slot→row assignments against the scoreboard's weapons + (`core/slot-row-assignment.ts`), falling back to as-drawn order on thin + or tied evidence. The POV overlay's teammate diamond follows neither + order and maps by card name instead. Strip-weapon fixtures live under + `tests/fixtures/strip-weapons/`. The builder additionally + flips sub-2s dead-flag runs flanked by dense opposite reads — a splat + outlasts the respawn wait, so those are misread blips (background ink + bleeding through a crossed-out icon) — and bridges sub-10s not-ready + gaps between ready reads when no death inside the gap explains them (no + special regains that fast, so the gap is the wash's dim pulse trough). Objective reads land on `ScannerMatch` as + progress samples anchored to the game clock; broadcast replay wipes re-run + an earlier moment with the counter intact, so the builder keeps only the + dominant cluster of clock-zero projections (`t + time`) and drops replay + reads outright (timerless reads follow their preceding anchored + neighbor). A displayed count only ever + ticks down, so the builder keeps each side's longest non-increasing score + run and voids reads off it (surviving OCR blips chart as gaps, not dips). Each read also carries a + per-side team ink color (`core/ink-color.ts` — the plate fill in + control, the digit ink otherwise): casted footage keeps the specced + player's team on the left plate, so the builder orients samples by ink + hue and anchors them to `teams` order via the minimap sub-tile colors + (casts never show a results screen). 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 + reveals a non-SZ mode). PlayerStatus reads follow the objective pipeline + wholesale: same replay-wipe anchor, cast orientation inherited from the + nearest counter read, nulled together on non-SZ matches, and rendered as + per-player splat/special bands (`~/components/PlayerStatusTimeline.tsx`, + shared with the match page) above the objective chart. Minimap reads + feed the same samples: every card/row carries `dead` (respawn + cross-out) and `specialReady` (special camo) flags, merged in timerless + on the shared replay anchor — and mode-agnostic, so a known non-SZ + match keeps its minimap-sourced samples while its counter/status + misreads are voided. 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. @@ -105,8 +160,10 @@ sequenceDiagram 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 + `core/detectors/telemetry.ts` — but only when the VoD tab is opened with + `?telemetry=true` (nothing links there); otherwise the workers skip + collection and the panel stays hidden. 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 diff --git a/app/features/scanner/components/AbilityGrid.module.css b/app/features/scanner/components/AbilityGrid.module.css new file mode 100644 index 000000000..d8de40d18 --- /dev/null +++ b/app/features/scanner/components/AbilityGrid.module.css @@ -0,0 +1,13 @@ +button.abilityTrigger { + height: auto; + padding: var(--s-0-5); + background: var(--color-bg-higher); + border: 1px solid var(--color-border-high); + border-radius: var(--radius-selector); + + & img { + display: block; + width: var(--field-size-icon); + height: var(--field-size-icon); + } +} diff --git a/app/features/scanner/components/AbilityGrid.tsx b/app/features/scanner/components/AbilityGrid.tsx index 5134145e3..b669a2a39 100644 --- a/app/features/scanner/components/AbilityGrid.tsx +++ b/app/features/scanner/components/AbilityGrid.tsx @@ -7,6 +7,8 @@ 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"; +import styles from "./AbilityGrid.module.css"; +import eventCardStyles from "./EventCard.module.css"; const ROW_LABELS = ["head", "clothes", "shoes"] as const; @@ -16,7 +18,7 @@ export function AbilityGrid({ abilities: AbilityWithUnknown[][]; }) { return ( - +
{abilities.map((row, i) => ( @@ -48,7 +50,7 @@ export function AbilityPopover({ diff --git a/app/features/scanner/components/DeathCard.module.css b/app/features/scanner/components/DeathCard.module.css new file mode 100644 index 000000000..907f881f0 --- /dev/null +++ b/app/features/scanner/components/DeathCard.module.css @@ -0,0 +1,40 @@ +.body { + display: flex; + align-items: center; + gap: var(--s-3); + padding-block: var(--s-0-5); +} + +.info { + display: flex; + flex-direction: column; + gap: var(--s-0-5); + min-width: 0; +} + +.name { + font-size: var(--font-xs); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.weapon { + font-size: var(--font-2xs); + font-weight: var(--weight-semi); + color: var(--color-text-high); +} + +.abilities { + display: flex; + align-items: center; + gap: var(--s-2-5); + margin-inline-start: auto; + flex-wrap: wrap; +} + +.gear { + display: flex; + align-items: center; + gap: var(--s-0-5); +} diff --git a/app/features/scanner/components/DeathCard.tsx b/app/features/scanner/components/DeathCard.tsx index 8570ccc9c..58cd4539a 100644 --- a/app/features/scanner/components/DeathCard.tsx +++ b/app/features/scanner/components/DeathCard.tsx @@ -1,3 +1,4 @@ +import clsx from "clsx"; import { Ability } from "~/components/Ability"; import { WeaponImage } from "~/components/Image"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; @@ -5,6 +6,8 @@ import { DEATH_EVENT_TYPE, type DeathData, } from "../core/detectors/death/index"; +import styles from "./DeathCard.module.css"; +import eventCardStyles from "./EventCard.module.css"; import { FrameThumb } from "./FrameThumb"; import { useEventTimeFormatter } from "./format"; import { weaponLabel } from "./labels"; @@ -25,8 +28,8 @@ export function DeathCard(props: { const weaponName = weaponLabel(data.weaponType, data.weaponId); const formatDetectedAt = useEventTimeFormatter(); return ( -
-
+
+
-
-
-
+
+
+
{data.weaponId !== null && data.weaponType === "MAIN" ? ( ) : null} -
- +
+ splatted by {data.name ?? "?"} - {weaponName ?? "?"} + {weaponName ?? "?"}
-
+
{data.abilities.map((row, i) => ( -
+
{row.map((id, j) => ( Promise; @@ -84,7 +95,7 @@ export function EventCard(props: { const card = renderCard(type, data, shared, props.abilities); if (!props.send && !props.onSend) return card; return ( -
+
{card}
@@ -109,12 +120,14 @@ function SendStrip({ const state = send?.state; const formatSentAt = useEventTimeFormatter(); return ( -
+
sendou.ink: {state ? SEND_LABELS[state] : "not sent"} {state === "sent" && send ? ` ${formatSentAt(send.at)}` : null} - {send?.error ? {send.error} : null} + {send?.error ? ( + {send.error} + ) : null} {onSend && state !== "sent" && state !== "sending" ? (
diff --git a/app/features/scanner/components/FrameThumb.module.css b/app/features/scanner/components/FrameThumb.module.css new file mode 100644 index 000000000..0b8a86b61 --- /dev/null +++ b/app/features/scanner/components/FrameThumb.module.css @@ -0,0 +1,39 @@ +/* an unstyled button so the image itself is the target */ +button.thumbButton { + height: auto; + padding: 0; + border: none; + background: none; + margin-inline-start: auto; + border-radius: var(--radius-field); + + &:hover { + filter: brightness(1.2); + } +} + +.thumb { + display: block; + height: 40px; + width: auto; + border-radius: var(--radius-field); +} + +.dialog { + width: max-content; + max-width: min(96vw, 1400px); +} + +.frameFull { + display: block; + max-width: 100%; + max-height: calc(80dvh - 9rem); + border-radius: var(--radius-field); +} + +.frameActions { + display: flex; + justify-content: center; + gap: var(--s-3); + margin-block-start: var(--s-3); +} diff --git a/app/features/scanner/components/FrameThumb.tsx b/app/features/scanner/components/FrameThumb.tsx index 5b8364c5a..6d1e3baac 100644 --- a/app/features/scanner/components/FrameThumb.tsx +++ b/app/features/scanner/components/FrameThumb.tsx @@ -9,6 +9,7 @@ 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 styles from "./FrameThumb.module.css"; import { type FixtureData, saveFixtureFromEvent } from "./fixture-export"; export function FrameThumb({ @@ -60,26 +61,26 @@ export function FrameThumb({ <> {open ? ( setOpen(false)} > analyzed frame {onInspect || onSaveFixture ? ( -
+
{onInspect ? ( []) { latestParseRef.current = { type: event.type, data: event.data }; if ( - event.type === OBJECTIVE_EVENT_TYPE && + (event.type === OBJECTIVE_EVENT_TYPE || + event.type === PLAYER_STATUS_EVENT_TYPE) && objectiveBlockedRef.current ) { continue; @@ -280,7 +283,10 @@ export function LivePage({ 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)); + // strip weapon evidence is assignment input, not a detection worth a card + const ungroupedFeed = feed.filter( + (e) => !groupedEvents.has(e) && e.type !== STRIP_WEAPONS_EVENT_TYPE, + ); const abilityMap = connectAbilities(feed); const stop = () => { @@ -304,7 +310,7 @@ export function LivePage({ return (
-
+
{!running ? ( <> )} - setDeviceId(e.target.value)} + > {devices.map((d) => (
- {error ?

{error}

: null} - {sendouError ?

{sendouError}

: null} -
-
+
{players.map((p, i) => ( - - + @@ -82,8 +85,8 @@ export function ScoreboardCard(props: { const isScoreboardBattleLog = eventType === SCOREBOARD_BATTLE_LOG_EVENT_TYPE; const formatDetectedAt = useEventTimeFormatter(); return ( -
-
+
+
{data.timestamp} : null} {data.replayCode ? ( - {data.replayCode} + {data.replayCode} ) : null} {detectedAt ? {formatDetectedAt(detectedAt)} : null}
-
-
+
+

{teamHeading("Victory", data, 0)}

-
+

{teamHeading("Defeat", data, 1)}

-
+
+
-
-
+
+
{data.weaponId !== null ? ( ) : null} diff --git a/app/features/scanner/components/ScreenshotPage.module.css b/app/features/scanner/components/ScreenshotPage.module.css new file mode 100644 index 000000000..8ef78af63 --- /dev/null +++ b/app/features/scanner/components/ScreenshotPage.module.css @@ -0,0 +1,233 @@ +.frame { + position: relative; + margin-bottom: var(--s-4); + + & canvas { + width: 100%; + border-radius: var(--radius-box); + border: var(--border-width) solid var(--color-bg-high); + } +} + +/* ---- per-detector gate results ---- */ + +.gateList { + display: grid; + grid-template-columns: repeat(3, max-content) 1fr; + column-gap: var(--s-4); + row-gap: var(--s-1-5); + align-items: baseline; + font-size: var(--font-xs); + margin-block: var(--s-4); +} + +.gateRow { + display: contents; +} + +.gateBadge { + justify-self: start; + align-self: center; + padding: 0 var(--s-1-5); + border-radius: var(--radius-selector); + background: var(--color-bg-high); + color: var(--color-text-high); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); + text-transform: uppercase; + white-space: nowrap; +} + +.gateRow:not(.fired) .gateName { + color: var(--color-text-high); +} + +.gateRow.fired { + & .gateBadge { + background: var(--color-success-low); + color: var(--color-success-high); + } + + & .gateName { + font-weight: var(--weight-bold); + } +} + +.gateScore { + font-variant-numeric: tabular-nums; + color: var(--color-text-high); + font-size: var(--font-2xs); +} + +.gateNote { + color: var(--color-text-high); + font-size: var(--font-2xs); +} + +/* ---- fired detector's parse detail ---- */ + +.detail { + display: flex; + flex-direction: column; + gap: var(--s-3); + margin-block: var(--s-4); +} + +.detailStats { + display: flex; + flex-wrap: wrap; + gap: var(--s-1-5); +} + +.stat { + display: inline-flex; + align-items: baseline; + gap: var(--s-1-5); + padding: var(--s-1) var(--s-2); + border-radius: var(--radius-field); + background: var(--color-bg-high); + font-size: var(--font-xs); +} + +.statLabel { + color: var(--color-text-high); + font-size: var(--font-2xs); + white-space: nowrap; +} + +.statValue { + font-weight: var(--weight-bold); +} + +.statRaw { + color: var(--color-text-high); + font-size: var(--font-2xs); +} + +.detailCrops { + display: flex; + flex-wrap: wrap; + gap: var(--s-3); + align-items: end; + + & figure { + margin: 0; + display: flex; + flex-direction: column; + gap: var(--s-1); + min-width: 0; + } + + & canvas { + display: block; + max-width: 100%; + background: #000; + border-radius: var(--radius-selector); + border: var(--border-width) solid var(--color-bg-high); + } + + & figcaption { + color: var(--color-text-high); + font-size: var(--font-2xs); + } +} + +/* one pill per player slot */ +.statusSlots { + display: inline-flex; + gap: var(--s-2); + align-self: center; +} + +.statusSide { + display: inline-flex; + gap: var(--s-0-5); +} + +.statusSlot { + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: 1.25rem; + block-size: 1.25rem; + border-radius: var(--radius-selector); + background: var(--color-bg-higher); + color: var(--color-text-high); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); + + &.dead { + background: var(--color-error-low); + color: var(--color-error-high); + } + + &.special { + background: var(--color-warning-low); + color: var(--color-warning-high); + } +} + +/* ---- per-row parse inspector ---- */ + +.inspector { + width: 100%; + border-collapse: collapse; + font-size: var(--font-xs); + + & th { + text-align: left; + color: var(--color-text-high); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); + padding: var(--s-1-5); + border-bottom: var(--border-style); + } + + & td { + padding: var(--s-1-5); + border-bottom: 1px solid var(--color-bg-high); + vertical-align: middle; + } + + & canvas { + display: block; + background: #000; + border-radius: var(--radius-selector); + } +} + +.candidates { + display: flex; + gap: var(--s-2); + align-items: center; +} + +.candidate { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-0-5); + font-size: var(--font-2xs); + color: var(--color-text-high); +} + +.weaponCandidates .candidate:first-child { + color: var(--color-success); +} + +.weaponIcon { + width: 28px; + height: 28px; + vertical-align: middle; + background: var(--color-bg-badge); + border-radius: var(--radius-full); + padding: var(--s-0-5); +} + +/* the ROI crops set the width; scroll them instead of the whole page */ +@container scanner (width < 700px) { + .inspector { + display: block; + overflow-x: auto; + } +} diff --git a/app/features/scanner/components/ScreenshotPage.tsx b/app/features/scanner/components/ScreenshotPage.tsx index b5866277d..87c6fa6e8 100644 --- a/app/features/scanner/components/ScreenshotPage.tsx +++ b/app/features/scanner/components/ScreenshotPage.tsx @@ -1,5 +1,11 @@ import clsx from "clsx"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { + type ReactNode, + 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"; @@ -10,6 +16,13 @@ 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 { ObjectiveData } from "../core/detectors/objective/index"; +import { + PLAYER_STATUS_EVENT_TYPE, + type PlayerStatusData, + type PlayerStatusLayout, +} from "../core/detectors/objective/player-status"; +import * as objective from "../core/detectors/objective/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"; @@ -17,6 +30,7 @@ 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 scannerStyles from "../scanner.module.css"; import { scannerSearchParams } from "../scanner-search-params"; import { claimInspectFrame } from "../store/inspect"; import { AnalyzerClient } from "../worker/client"; @@ -30,6 +44,7 @@ import { stageLabel, weaponLabel, } from "./labels"; +import styles from "./ScreenshotPage.module.css"; type Result = Extract; @@ -62,6 +77,58 @@ function RoiCrop(props: { return ; } +function Stat(props: { label: string; raw?: unknown; children: ReactNode }) { + return ( + + {props.label} + {props.children} + {props.raw != null && props.raw !== "" ? ( + raw: {String(props.raw)} + ) : null} + + ); +} + +function LabeledCrop(props: { + label: string; + frame: HTMLCanvasElement; + roi: Roi; + scale?: number; +}) { + return ( +
+ +
{props.label}
+
+ ); +} + +/** One pill per player slot: number = alive, ★ = special held, ✗ = splatted. */ +function StatusSlots(props: { data: PlayerStatusData }) { + return ( + + {([0, 1] as const).map((side) => ( + + {props.data.dead[side].map((dead, slot) => { + const special = !dead && props.data.special[side][slot]; + return ( + + {dead ? "✗" : special ? "★" : slot + 1} + + ); + })} + + ))} + + ); +} + /** Per-row parse ROIs, in the same order as the event's players array. */ interface RowRois { weapon: Roi; @@ -117,6 +184,65 @@ function replayRows(winnerSide: string): RowRois[] { ); } +/** Same marker language as the objective status pills: ✗ dead, ★ special. */ +function playerFlags(p: { dead: boolean; specialReady: boolean }) { + return `${p.dead ? " ✗" : ""}${p.specialReady ? " ★" : ""}`; +} + +function formatTimer(time: number | null) { + if (time === null) return "?:??"; + return `${Math.floor(time / 60)}:${String(time % 60).padStart(2, "0")}`; +} + +/** One-line recap of what a fired detector read, for the gate list. */ +function gateSummary(result: Result): string | null { + const event = result.events[0]; + if (!event) return null; + const confidence = `${((event.confidence ?? 0) * 100).toFixed(1)}% conf`; + switch (result.detector) { + case "death": { + const data = event.data as DeathData; + return `${confidence} · splatted by ${weaponLabel(data.weaponType, data.weaponId) ?? "?"} (${data.name ?? "?"})`; + } + case "map-start": { + const data = event.data as MapStartData; + return `${confidence} · ${modeLabel(data.mode) ?? "?"} · ${stageLabel(data.stage) ?? "?"}`; + } + case "scoreboard-own": { + const data = event.data as ScoreboardOwnData; + return `${confidence} · ${[lobbyLabel(data.lobby), modeLabel(data.mode), stageLabel(data.stage)].map((v) => v ?? "?").join(" · ")} · ${mainWeaponLabel(data.weaponId) ?? "?"}`; + } + case "minimap": { + const data = event.data as MinimapData; + const players = data.teammates + .map((p) => `${p.name ?? "?"}${playerFlags(p)}`) + .join(", "); + return `${confidence} · ${data.stage ?? "?"} · ${players}`; + } + case "objective": { + const data = event.data as unknown as ObjectiveData; + return `${confidence} · ${formatTimer(data.time)} · score ${data.score[0] ?? "?"}–${data.score[1] ?? "?"}`; + } + default: { + const data = event.data as CardData; + return `${confidence} · scores ${JSON.stringify(data.matchScores)} · ${[lobbyLabel(data.lobby), modeLabel(data.mode), stageLabel(data.stage)].map((v) => v ?? "?").join(" · ")}`; + } + } +} + +/** Band covering one side's player-status icon strip, for the crop view. */ +function statusStripRoi(layout: PlayerStatusLayout, side: 0 | 1): Roi { + const centers = + layout === "pov" + ? objective.STATUS_SLOT_CENTERS_POV[side] + : layout === "cast" + ? objective.STATUS_SLOT_CENTERS_CAST[side] + : objective.STATUS_SLOT_CENTERS_CAST_MIRROR[side]; + const first = centers[0]!; + const last = centers[centers.length - 1]!; + return { x: first - 55, y: 25, w: last - first + 110, h: 115 }; +} + function drawOverlay(ctx: CanvasRenderingContext2D, detector: string) { const rect = (roi: Roi, color: string) => { ctx.strokeStyle = color; @@ -140,6 +266,31 @@ function drawOverlay(ctx: CanvasRenderingContext2D, detector: string) { } return; } + if (detector === "objective") { + rect(objective.TIMER_DIGIT_ROI, "#34d399"); + for (const side of [0, 1] as const) { + rect(objective.SCORE_ROIS[side], "#f87171"); + rect(objective.PENALTY_ROIS[side], "#fb923c"); + rect(objective.PLATE_PROBE_ROIS[side], "#facc15"); + } + for (const [centers, box, color] of [ + [ + objective.STATUS_SLOT_CENTERS_POV, + objective.STATUS_BODY_BOX_POV, + "#60a5fa", + ], + [ + objective.STATUS_SLOT_CENTERS_CAST, + objective.STATUS_BODY_BOX_CAST, + "#e879f9", + ], + ] as const) { + for (const cx of centers.flat()) { + rect({ x: cx + box.dx, y: box.y, w: box.w, h: box.h }, color); + } + } + return; + } if (detector === "map-start") { rect(mapStart.MODE_LABEL_ROI, "#34d399"); rect(mapStart.MODE_BLOCK_ROI, "#f87171"); @@ -355,6 +506,7 @@ export function ScreenshotPage() { const isMapStart = activeDetector === "map-start"; const isOwn = activeDetector === "scoreboard-own"; const isMinimap = activeDetector === "minimap"; + const isObjective = activeDetector === "objective"; const winnerSide = String(event?.debug?.winnerSide ?? "left"); const rowRois = isReplay ? replayRows(winnerSide) @@ -366,7 +518,9 @@ export function ScreenshotPage() {
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */}
{ e.preventDefault(); setOver(true); @@ -395,10 +549,10 @@ export function ScreenshotPage() { {busy ? " — analyzing…" : null}
- {error ?

{error}

: null} + {error ?

{error}

: null}
@@ -429,237 +583,310 @@ export function ScreenshotPage() {

) : null} - {Object.values(results).map((result) => ( -

- {result.detector} gate:{" "} - {result.gate.pass ? "fired" : "no fire"} (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} -

- ))} + {Object.keys(results).length > 0 ? ( +
+ {Object.values(results).map((result) => ( +
+ + {result.gate.pass ? "fired" : "no fire"} + + {result.detector} + + {result.gate.score.toFixed(3)} + + {gateSummary(result)} +
+ ))} +
+ ) : null} {frame && event && isReplay ? ( -

- timestamp {event.data.timestamp ?? "?"} - {" · "}code {event.data.replayCode ?? "?"}{" "} - - (raw: {String(event.debug?.codeRaw ?? "")}) - - {" · "}match scores {JSON.stringify(event.data.matchScores)} - {" · "}winner panel {winnerSide} -
- {" "} - -

+
+
+ {event.data.timestamp ?? "?"} + + {event.data.replayCode ?? "?"} + + + {JSON.stringify(event.data.matchScores)} + + {winnerSide} +
+
+ + +
+
) : null} {frame && event && isScoreboardBattleLog ? ( -

- timestamp {event.data.timestamp ?? "?"} - {" · "}match scores {JSON.stringify(event.data.matchScores)} - {" · "}winner panel {winnerSide} -
- {" "} - -

+
+
+ {event.data.timestamp ?? "?"} + + {JSON.stringify(event.data.matchScores)} + + {winnerSide} +
+
+ + +
+
) : null} {frame && event && isDeath ? ( -

+

{(() => { const data = event.data as unknown as DeathData; return ( <> - weapon{" "} - {weaponLabel(data.weaponType, data.weaponId) ?? "?"}{" "} - - (raw: {String(event.debug?.weaponRaw ?? "")}) - - {" · "}name {data.name ?? "?"}{" "} - - (raw: {String(event.debug?.nameRaw ?? "")}) - - {" · "}abilities{" "} - {data.abilities.map((row) => row.join(" ")).join(" | ")} -
- {" "} - +
+ + {weaponLabel(data.weaponType, data.weaponId) ?? "?"} + + + {data.name ?? "?"} + + + {data.abilities.map((row) => row.join(" ")).join(" | ")} + +
+
+ + +
); })()} -

+
) : null} {frame && event && isMapStart ? ( -

+

{(() => { const data = event.data as unknown as MapStartData; return ( <> - mode {modeLabel(data.mode) ?? "?"}{" "} - - (raw: {String(event.debug?.modeReading ?? "")}) - - {" · "}stage {stageLabel(data.stage) ?? "?"}{" "} - - (raw: {String(event.debug?.stageReading ?? "")}) - -
- {" "} - +
+ + {modeLabel(data.mode) ?? "?"} + + + {stageLabel(data.stage) ?? "?"} + +
+
+ + +
); })()} -

+
) : null} {frame && event && isOwn ? ( -

+

{(() => { const data = event.data as unknown as ScoreboardOwnData; return ( <> - weapon {mainWeaponLabel(data.weaponId) ?? "?"}{" "} - - (raw: {String(event.debug?.weaponReading ?? "")}) - - {" · "}abilities{" "} - {data.abilities.map((row) => row.join(" ")).join(" | ")} -
- {" "} - {[0, 1, 2].map((row) => ( - + + {mainWeaponLabel(data.weaponId) ?? "?"} + + + {data.abilities.map((row) => row.join(" ")).join(" | ")} + +
+
+ - ))} + {[0, 1, 2].map((row) => ( + + ))} +
); })()} -

+
) : null} {frame && event && isMinimap ? ( -

+

{(() => { const data = event.data as unknown as MinimapData; return ( <> - stage {stageLabel(data.stage) ?? "?"} - {" · "} - {data.spectator ? "spectator map" : "POV overlay"} - {" · "}team{" "} - - {data.teammates - .map( - (p) => - `${p.slot}: ${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})`, - ) - .join(", ") || "—"} - - {data.enemies.length > 0 ? ( - <> - {" · "}enemies{" "} - +
+ {stageLabel(data.stage) ?? "?"} + + {data.spectator ? "spectator map" : "POV overlay"} + + + {data.teammates + .map( + (p) => + `${p.slot}: ${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})${playerFlags(p)}`, + ) + .join(", ") || "—"} + + {data.enemies.length > 0 ? ( + {data.enemies .map( (p) => - `${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})`, + `${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})${playerFlags(p)}`, ) .join(", ")} - - - ) : null} + + ) : null} +
{!data.spectator ? ( - <> -
+
{minimap.CARD_LAYOUTS.map((card) => ( - + ))} - +
) : null} ); })()} -

+
) : null} - {frame && event && !isDeath && !isMapStart && !isOwn && !isMinimap ? ( -
- + {p.weaponId !== null ? ( ) : null} {abilities?.has(offset + i) ? ( @@ -44,8 +47,8 @@ function PlayerRows({ {p.name || "?"}{p.paint ?? "?"}p + {p.paint ?? "?"}p {p.ka ?? "?"}/{p.d ?? "?"}/{p.s ?? "?"}
+ {frame && event && isObjective ? ( +
+ {(() => { + const data = event.data as unknown as ObjectiveData; + const status = active?.events.find( + (e) => e.type === PLAYER_STATUS_EVENT_TYPE, + ) as DetectedEvent | undefined; + return ( + <> +
+ {formatTimer(data.time)} + + {data.score[0] ?? "?"}–{data.score[1] ?? "?"} + + + {data.penalty[0] ?? "—"} / {data.penalty[1] ?? "—"} + + + {data.control[0] + ? "left" + : data.control[1] + ? "right" + : "none"} + + {status ? ( + <> + {status.data.layout} + + + + + ) : null} +
+
+ + + + {status + ? ([0, 1] as const).map((side) => ( + + )) + : null} +
+ + ); + })()} +
+ ) : null} + + {frame && + event && + !isDeath && + !isMapStart && + !isOwn && + !isMinimap && + !isObjective ? ( +
@@ -681,16 +908,23 @@ export function ScreenshotPage() {
row -
+
{dbg?.weapon?.top.map((c) => ( - + {c.id} {c.id} - {c.score.toFixed(3)} + + {c.score.toFixed(3)} + ))}
@@ -698,20 +932,24 @@ export function ScreenshotPage() {
{player?.name || "—"}{" "} - {dbg?.nameScore.toFixed(3)} + + {dbg?.nameScore.toFixed(3)} + {player?.paint ?? "—"}{" "} - {dbg?.paintScore.toFixed(3)} + + {dbg?.paintScore.toFixed(3)} + -
+
{([0, 1, 2] as const).map((s) => ( - + {[player?.ka, player?.d, player?.s][s] ?? "—"} - + {dbg?.statScores[s].toFixed(2)} diff --git a/app/features/scanner/components/StripWeaponsCard.tsx b/app/features/scanner/components/StripWeaponsCard.tsx new file mode 100644 index 000000000..a489ab977 --- /dev/null +++ b/app/features/scanner/components/StripWeaponsCard.tsx @@ -0,0 +1,55 @@ +import { + STRIP_WEAPONS_EVENT_TYPE, + type StripWeaponsData, +} from "../core/detectors/objective/strip-weapons"; +import styles from "./EventCard.module.css"; +import { FrameThumb } from "./FrameThumb"; +import { formatClock, useEventTimeFormatter } from "./format"; +import { mainWeaponLabel } from "./labels"; +import { MetaPills } from "./MetaChips"; + +export function StripWeaponsCard(props: { + t: number; + confidence: number; + data: StripWeaponsData; + thumbnail?: string; + detectedAt?: number; + /** lazy loader for the exact analyzed frame — enables fixture export */ + getFrame?: () => Promise; + onInspect?: () => void; +}) { + const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } = + props; + const side = (index: 0 | 1) => + data.slots[index] + .map((candidates) => + candidates === null + ? "✕" + : (mainWeaponLabel(candidates[0]?.weaponId ?? null) ?? "?"), + ) + .join(" | "); + const formatDetectedAt = useEventTimeFormatter(); + return ( +
+
+ + + {data.time !== null ? `${formatClock(data.time)} · ` : null} + {side(0)} vs {side(1)} + + {detectedAt ? {formatDetectedAt(detectedAt)} : null} + +
+
+ ); +} diff --git a/app/features/scanner/components/VodPage.module.css b/app/features/scanner/components/VodPage.module.css new file mode 100644 index 000000000..226c0a7a3 --- /dev/null +++ b/app/features/scanner/components/VodPage.module.css @@ -0,0 +1,99 @@ +/* internal navigation styled like the sibling action buttons */ +.linkButton { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--s-1-5); + padding: 0 var(--field-padding); + height: var(--field-size-sm); + border: var(--border-style-accent); + border-radius: var(--radius-field); + background: var(--color-text-accent); + color: var(--color-text-inverse); + font-size: var(--font-xs); + font-weight: var(--weight-bold); + white-space: nowrap; + text-decoration: none; + + & > svg { + width: 16px; + height: 16px; + } + + &:active { + transform: translateY(1px); + } + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: 1px; + } +} + +.vodList { + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.vodItem { + display: flex; + align-items: center; + gap: var(--s-3); + background: var(--color-bg-high); + border-radius: var(--radius-box); + padding: var(--s-3) var(--s-4); +} + +.vodName { + font-size: var(--font-sm); + font-weight: var(--weight-bold); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vodMeta { + flex: 1; +} + +/* the shared destructive variant loses to the scanner's own button look */ +button.vodDelete { + width: var(--field-size-sm); + padding: 0; + border-color: var(--color-error); + background: var(--color-error); + outline-color: var(--color-error); +} + +.telemetry { + margin: var(--s-2) 0; + font-size: var(--font-2xs); + color: var(--color-text-high); + + & summary { + cursor: pointer; + } + + & table { + margin-top: var(--s-1-5); + border-collapse: collapse; + + & th, + & td { + padding: var(--s-0-5) var(--s-2-5) var(--s-0-5) 0; + text-align: right; + font-variant-numeric: tabular-nums; + + &:first-child { + text-align: left; + } + } + } +} + +@container scanner (width < 700px) { + .vodItem { + flex-wrap: wrap; + } +} diff --git a/app/features/scanner/components/VodPage.tsx b/app/features/scanner/components/VodPage.tsx index 3172c753d..9bec20eaf 100644 --- a/app/features/scanner/components/VodPage.tsx +++ b/app/features/scanner/components/VodPage.tsx @@ -21,13 +21,13 @@ 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 { GameTimeline } from "~/components/GameTimeline"; +import { useSearchParam } from "~/modules/search-params/hooks"; 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 { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index"; +import { PLAYER_STATUS_EVENT_TYPE } from "../core/detectors/objective/player-status"; +import { STRIP_WEAPONS_EVENT_TYPE } from "../core/detectors/objective/strip-weapons"; import { mergeScanTelemetry, type ScanTelemetry, @@ -39,6 +39,8 @@ import { invalidObjectiveEvents, } from "../core/match-builder"; import { TimelineBuilder } from "../core/timeline/index"; +import scannerStyles from "../scanner.module.css"; +import { scannerSearchParams } from "../scanner-search-params"; import type { SendStatus } from "../store/events"; import { deleteVod, @@ -63,6 +65,7 @@ import type { FixtureData } from "./fixture-export"; import { formatTime, useEventDateTimeFormatter } from "./format"; import { MatchCard } from "./MatchCard"; import { MatchLobbyTabs } from "./MatchLobbyTabs"; +import { playerStatusTeams } from "./player-status-view"; import { countIngestableMatches, type SendouUser, @@ -70,6 +73,7 @@ import { } from "./sendou-ingest"; import { sendouUpload } from "./sendou-upload"; import { thumbnailFromBlob } from "./thumbnail"; +import styles from "./VodPage.module.css"; /** The scan knows the on-screen sides only, not who is playing. */ const SCANNER_TEAM_LABELS = ["Alpha", "Bravo"] as const; @@ -136,6 +140,9 @@ export function VodPage({ // in-flight thumbnail work; awaited before persisting a finished scan const sideWorkRef = useRef[]>([]); const nextMatchKeyRef = useRef(0); + // telemetry collection is baked into the workers at init, so a change of + // the search param needs a fresh pool + const clientsCollectTelemetryRef = useRef(false); const [fileName, setFileName] = useState(null); /** live scan vs. reopened saved VoD (no video element for the latter) */ @@ -152,6 +159,9 @@ export function VodPage({ const [eventsOpen, setEventsOpen] = useState(false); const [resultsSend, setResultsSend] = useState(null); + // opt-in via ?telemetry=true only; nothing in the UI links to it + const [collectTelemetry] = useSearchParam(scannerSearchParams, "telemetry"); + const formatSavedAt = useEventDateTimeFormatter(); const abilityMap = connectAbilities(matches.map((m) => m.event)); @@ -168,7 +178,11 @@ export function VodPage({ ); 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)); + // strip weapon evidence is assignment input, not a detection worth a card + const ungroupedMatches = matches.filter( + (m) => + !groupedEvents.has(m.event) && m.event.type !== STRIP_WEAPONS_EVENT_TYPE, + ); // "Send results" sends the whole scan in one go, so its outcome maps // onto every ingestable card; a partial failure (some chunks sent, some @@ -262,6 +276,11 @@ export function VodPage({ urlRef.current = URL.createObjectURL(file); video.src = urlRef.current; + if (clientsCollectTelemetryRef.current !== collectTelemetry) { + for (const client of clientsRef.current) client.dispose(); + clientsRef.current = []; + clientsCollectTelemetryRef.current = collectTelemetry; + } if (clientsRef.current.length === 0) { clientsRef.current = Array.from( { length: defaultScanWorkerCount() }, @@ -313,6 +332,7 @@ export function VodPage({ frameDoneRef.current?.(); frameDoneRef.current = null; }, + { collectTelemetry }, ), ); } @@ -368,10 +388,12 @@ export function VodPage({ abortScanRef.current = () => { for (const client of clients) client.abortChunk(); }; - const mergedTelemetry = () => - mergeScanTelemetry( - chunks.flatMap((c) => (c.telemetry ? [c.telemetry] : [])), + const mergedTelemetry = () => { + const parts = chunks.flatMap((c) => + c.telemetry ? [c.telemetry] : [], ); + return parts.length > 0 ? mergeScanTelemetry(parts) : null; + }; let lastUiUpdate = Number.NEGATIVE_INFINITY; const pushUiUpdate = () => { const now = performance.now(); @@ -535,7 +557,9 @@ export function VodPage({
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */}
{ e.preventDefault(); setOver(true); @@ -563,17 +587,18 @@ export function VodPage({ />
-
+
{showVodView ? ( <> {source === "stored" ? "saved" : status} @@ -584,7 +609,7 @@ export function VodPage({ : null} {progress ? ( - + {formatTime(progress.t)} / {formatTime(progress.duration)} {progress.duration > 0 ? ` (${Math.round((progress.t / progress.duration) * 100)}%)` @@ -595,13 +620,13 @@ export function VodPage({ ) : null} {upload?.url ? ( - +
- {error ?

{error}

: null} + {error ?

{error}

: null} {showVodView && telemetry ? ( ) : null} {!showVodView ? ( -
+
{vods.length === 0 ? ( -

+

No saved VoDs yet — scan one and it will show up here.

) : null} {vods.map((vod) => ( -
- {vod.name} - +
+ {vod.name} + {vod.eventCount} event{vod.eventCount === 1 ? "" : "s"} ·{" "} {formatTime(vod.duration)} · {formatSavedAt(vod.savedAt)} @@ -668,7 +693,7 @@ export function VodPage({ variant="destructive" size="small" shape="square" - className="vod-delete" + className={styles.vodDelete} icon={} aria-label="Delete" /> @@ -678,7 +703,7 @@ export function VodPage({
) : null}
-
+
{matches.length === 0 ? ( -

+

{status === "scanning" ? "Scanning — matches appear here as scoreboards are detected." : "No matches found in this VoD."} @@ -719,15 +744,19 @@ export function VodPage({ 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 })) - : []; + // counter reads render as one timeline chart, not a card each -- + // from the builder's samples, whose sides are team-stable (raw + // reads follow the specced player on casts); a non-SZ match's + // reads (objective null) are never shown + const objectiveEvents = ( + built.match.objective?.samples ?? [] + ).map((sample) => ({ t: sample.t, data: sample })); + const statusSamples = built.match.playerStatus?.samples ?? []; const cardEvents = withoutRepeatEvents(built.sources).filter( - (e) => e.type !== OBJECTIVE_EVENT_TYPE, + (e) => + e.type !== OBJECTIVE_EVENT_TYPE && + e.type !== PLAYER_STATUS_EVENT_TYPE && + e.type !== STRIP_WEAPONS_EVENT_TYPE, ); return ( - {objectiveEvents.length > 0 ? ( - - ) : null} + {cardEvents.map((e) => { const vodMatch = vodMatchByEvent.get(e); return ( @@ -811,7 +839,7 @@ function ExportMenu({ trigger={ } - className="icon-menu" + className={scannerStyles.iconMenu} aria-label="Export" /> } @@ -855,7 +883,7 @@ function TelemetryPanel({ telemetry }: { telemetry: ScanTelemetry }) { ); const coveredS = telemetry.activeVideoS + telemetry.skimVideoS; return ( -

+
telemetry · analyzed {telemetry.analyzedFrames}/ {telemetry.decodedFrames} decoded frames diff --git a/app/features/scanner/components/events-csv.ts b/app/features/scanner/components/events-csv.ts index bcc8cc556..fb1921eac 100644 --- a/app/features/scanner/components/events-csv.ts +++ b/app/features/scanner/components/events-csv.ts @@ -1,9 +1,9 @@ /** * 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, + * the 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. + * minimap's teammates+enemies, or the objective HUD's icon strip — are packed + * into one cell, matching the compact per-event view of the live feed. */ import type { MainWeaponId } from "~/modules/in-game-lists/types"; @@ -23,8 +23,23 @@ 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 { + PLAYER_STATUS_EVENT_TYPE, + type PlayerStatusData, +} from "../core/detectors/objective/player-status"; +import { + STRIP_WEAPONS_EVENT_TYPE, + type StripWeaponsData, +} from "../core/detectors/objective/strip-weapons"; +import { + SCOREBOARD_EVENT_TYPE, + type ScoreboardData, +} 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, + type ScoreboardBattleLogReplayData, +} from "../core/detectors/scoreboard-battle-log-replay/index"; import { SCOREBOARD_OWN_EVENT_TYPE, type ScoreboardOwnData, @@ -93,15 +108,36 @@ function formatMinimapPlayers(data: MinimapData): string { name: string | null; weaponId: number | null; abilities: (string | null)[]; + dead: boolean; + specialReady: boolean; }, ) => - `${label} ${p.name ?? "?"} · ${mainWeaponLabel(p.weaponId as MainWeaponId | null) ?? "?"} · ${formatMinimapAbilities(p.abilities)}`; + `${label} ${p.name ?? "?"} · ${mainWeaponLabel(p.weaponId as MainWeaponId | null) ?? "?"} · ${formatMinimapAbilities(p.abilities)}` + + `${p.dead ? " · splatted" : ""}${p.specialReady ? " · special" : ""}`; return [ ...data.teammates.map((p) => fmt(p.slot, p)), ...data.enemies.map((p, i) => fmt(`enemy${i + 1}`, p)), ].join("; "); } +/** one team's four slots as top-candidate weapon names, ✕ = splatted */ +function formatStripWeaponsSide(data: StripWeaponsData, side: 0 | 1): string { + return data.slots[side] + .map((candidates) => + candidates === null + ? "✕" + : (mainWeaponLabel(candidates[0]?.weaponId ?? null) ?? "?"), + ) + .join(" | "); +} + +/** one team's four icons as ✕ splatted / ★ special ready / · alive */ +function formatPlayerStatusSide(data: PlayerStatusData, side: 0 | 1): string { + return data.dead[side] + .map((dead, slot) => (dead ? "✕" : data.special[side][slot] ? "★" : "·")) + .join(""); +} + function formatPlayers(data: ScoreboardData): string { return data.players .map( @@ -217,9 +253,48 @@ function eventCells(event: CsvEvent): Cell[] { "", ]; } - default: { - // Scoreboard, ScoreboardBattleLogReplay and ScoreboardBattleLog share - // the base shape + case PLAYER_STATUS_EVENT_TYPE: { + const d = event.data as PlayerStatusData; + const clock = d.time === null ? "" : `${formatClock(d.time)} · `; + return [ + ...base, + "", + "", + "", + "", + "", + "", + "", + "", + "", + `${clock}${formatPlayerStatusSide(d, 0)} vs ${formatPlayerStatusSide(d, 1)} (${d.layout})`, + "", + "", + ]; + } + case STRIP_WEAPONS_EVENT_TYPE: { + const d = event.data as StripWeaponsData; + const clock = d.time === null ? "" : `${formatClock(d.time)} · `; + return [ + ...base, + "", + "", + "", + "", + "", + "", + "", + "", + "", + `${clock}${formatStripWeaponsSide(d, 0)} vs ${formatStripWeaponsSide(d, 1)} (${d.layout})`, + "", + "", + ]; + } + case SCOREBOARD_EVENT_TYPE: + case SCOREBOARD_BATTLE_LOG_EVENT_TYPE: + case SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE: { + // the three scoreboard reads share the base shape const d = event.data as ScoreboardData & Partial; return [ @@ -238,6 +313,8 @@ function eventCells(event: CsvEvent): Cell[] { d.timestamp ?? "", ]; } + default: + return [...base, ...Array(HEADER.length - base.length).fill("")]; } } diff --git a/app/features/scanner/components/fixture-export.ts b/app/features/scanner/components/fixture-export.ts index 7451567a2..62dc60c87 100644 --- a/app/features/scanner/components/fixture-export.ts +++ b/app/features/scanner/components/fixture-export.ts @@ -20,6 +20,14 @@ import { OBJECTIVE_EVENT_TYPE, type ObjectiveData, } from "../core/detectors/objective/index"; +import { + PLAYER_STATUS_EVENT_TYPE, + type PlayerStatusData, +} from "../core/detectors/objective/player-status"; +import { + STRIP_WEAPONS_EVENT_TYPE, + type StripWeaponsData, +} from "../core/detectors/objective/strip-weapons"; import type { ScoreboardData } from "../core/detectors/scoreboard/index"; import type { ScoreboardBattleLogReplayData } from "../core/detectors/scoreboard-battle-log-replay/index"; import { @@ -39,7 +47,9 @@ export type FixtureData = | MapStartData | ScoreboardOwnData | MinimapData - | ObjectiveData; + | ObjectiveData + | PlayerStatusData + | StripWeaponsData; function isDeath(_data: FixtureData, eventType: string): _data is DeathData { return eventType === DEATH_EVENT_TYPE; @@ -118,12 +128,16 @@ function buildExpectedJson( weaponLabel: mainWeaponLabel(p.weaponId), weaponId: p.weaponId, abilities: p.abilities, + dead: p.dead, + specialReady: p.specialReady, })), enemies: minimap.enemies.map((p) => ({ ...(minimap.spectator && { name: p.name }), weaponLabel: mainWeaponLabel(p.weaponId), weaponId: p.weaponId, abilities: p.abilities, + dead: p.dead, + specialReady: p.specialReady, })), }, }, @@ -148,6 +162,45 @@ function buildExpectedJson( 2, )}\n`; } + if (eventType === PLAYER_STATUS_EVENT_TYPE) { + const status = data as PlayerStatusData; + return `${JSON.stringify( + { + event: eventType, + data: { + layout: status.layout, + time: status.time, + special: status.special, + dead: status.dead, + }, + }, + null, + 2, + )}\n`; + } + if (eventType === STRIP_WEAPONS_EVENT_TYPE) { + const strip = data as StripWeaponsData; + return `${JSON.stringify( + { + event: eventType, + data: { + layout: strip.layout, + time: strip.time, + // the top candidate per slot; hand-correct to the true weapons + weapons: strip.slots.map((side) => + side.map((candidates) => candidates?.[0]?.weaponId ?? null), + ), + weaponLabels: strip.slots.map((side) => + side.map((candidates) => + mainWeaponLabel(candidates?.[0]?.weaponId ?? null), + ), + ), + }, + }, + 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) { diff --git a/app/features/scanner/components/player-status-view.ts b/app/features/scanner/components/player-status-view.ts new file mode 100644 index 000000000..70ecfa390 --- /dev/null +++ b/app/features/scanner/components/player-status-view.ts @@ -0,0 +1,19 @@ +/** + * Prop derivation for rendering a ScannerMatch's status samples with the + * shared , used by the Live and VoD tabs. + */ +import type { PlayerStatusTimelineTeam } from "~/components/PlayerStatusTimeline"; +import type { ScannerMatch } from "../core/scanner-match"; + +/** Row weapons per team from the match's known players, slots by index. */ +export function playerStatusTeams( + match: ScannerMatch, + labels: readonly [string, string], +): [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam] { + return [0, 1].map((side) => ({ + label: labels[side]!, + weapons: [0, 1, 2, 3].map( + (slot) => match.teams[side as 0 | 1].players[slot]?.weaponId ?? null, + ), + })) as [PlayerStatusTimelineTeam, PlayerStatusTimelineTeam]; +} diff --git a/app/features/scanner/components/styles.css b/app/features/scanner/components/styles.css deleted file mode 100644 index ac8070c8c..000000000 --- a/app/features/scanner/components/styles.css +++ /dev/null @@ -1,1218 +0,0 @@ -/* -Scanner feature styles, ported from emberz. Scoped under .scanner-app so nothing -leaks into the host app; design tokens come from sendou.ink's own -app/styles/vars.css (the emberz copies of those tokens were dropped). -*/ -.scanner-app { - container: scanner / inline-size; -} - -.scanner-app .topbar { - display: flex; - align-items: center; - gap: var(--s-6); - padding-bottom: var(--s-3); - border-bottom: var(--border-width) solid var(--color-bg-high); - margin-bottom: var(--s-4); -} - -.scanner-app .topbar nav { - display: flex; - gap: var(--s-2); -} - -.scanner-app .topbar nav a { - color: var(--color-text-high); - font-size: var(--font-xs); - font-weight: var(--weight-bold); - text-decoration: none; - padding: var(--s-1) var(--s-2-5); - border-radius: var(--radius-field); -} - -.scanner-app .topbar nav a:hover { - color: var(--color-text); -} - -.scanner-app .topbar nav a:focus-visible { - outline: var(--focus-ring); - outline-offset: 1px; -} - -.scanner-app .topbar nav a.active { - color: var(--color-text-accent); - background: var(--color-bg-high); -} - -.scanner-app button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--s-1-5); - border: var(--border-style-accent); - border-radius: var(--radius-field); - appearance: none; - background: var(--color-text-accent); - color: var(--color-text-inverse); - cursor: pointer; - font-family: inherit; - font-size: var(--font-xs); - font-weight: var(--weight-bold); - padding: 0 var(--field-padding); - height: var(--field-size-sm); - white-space: nowrap; - user-select: none; - - & > svg { - width: 16px; - height: 16px; - } -} - -.scanner-app button.outlined { - background: transparent; - color: var(--color-text-accent); -} - -.scanner-app button:focus-visible { - outline: var(--focus-ring); - outline-offset: 1px; -} - -.scanner-app button:active { - transform: translateY(1px); -} - -.scanner-app button:disabled { - cursor: not-allowed; - opacity: 0.5; - transform: initial; -} - -/* the global select is full width, which would break the controls row */ -.scanner-app select { - appearance: none; - width: auto; - max-width: 20rem; - border: var(--border-style); - border-radius: var(--radius-field); - background-color: var(--color-bg); - background-image: var(--field-icon-chevron); - background-position: center right var(--field-padding); - background-size: var(--field-size-icon) auto; - background-repeat: no-repeat; - color: var(--color-text); - cursor: pointer; - font-family: inherit; - font-size: var(--font-xs); - height: var(--field-size-sm); - padding: 0 calc(var(--field-size-icon) + var(--field-padding) + var(--s-2)) 0 - var(--field-padding); - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - outline: none; -} - -.scanner-app select:focus-within { - outline: var(--focus-ring); - outline-offset: 1px; -} - -.scanner-app select:disabled { - cursor: not-allowed; - opacity: 0.5; -} - -.scanner-app .controls { - display: flex; - gap: var(--s-2); - align-items: center; - margin-bottom: var(--s-3); - flex-wrap: wrap; -} - -.scanner-app .status { - padding: var(--s-0-5) var(--s-3); - border-radius: var(--radius-full); - font-size: var(--font-2xs); - font-weight: var(--weight-bold); - border: var(--border-style); -} - -.scanner-app .status.idle { - color: var(--color-text-high); -} - -.scanner-app .status.watching { - color: var(--color-info-high); - border-color: var(--color-info-low); - background: var(--color-info-low); -} - -.scanner-app .status.detected { - display: inline-flex; - align-items: center; - gap: 5px; - color: var(--color-success-high); - border-color: var(--color-success-low); - background: var(--color-success-low); -} - -.scanner-app .live-layout { - display: grid; - grid-template-columns: minmax(320px, 640px) minmax(0, 1fr); - gap: var(--s-4); - align-items: start; -} - -.scanner-app video.preview, -.scanner-app canvas.preview { - width: 100%; - background: #000; - border-radius: var(--radius-box); - border: var(--border-width) solid var(--color-bg-high); -} - -.scanner-app .feed { - display: flex; - flex-direction: column; - gap: var(--s-3); -} - -.scanner-app .match-list { - display: flex; - flex-direction: column; - gap: var(--s-3); -} - -.scanner-app .card { - background: var(--color-bg-high); - border-radius: var(--radius-box); - padding: var(--s-4); -} - -.scanner-app .card .meta { - display: flex; - flex-wrap: wrap; - gap: var(--s-2) var(--s-4); - color: var(--color-text-high); - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - font-variant-numeric: tabular-nums; - margin-bottom: var(--s-2); - align-items: center; - - /* time + confidence + event type read as one unit, the detail text after it - sits further away */ - & .meta-pills { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--s-1); - } - - /* event-type chip: neutral like the match chips, the icon carries the accent */ - & .status.detected { - gap: 5px; - padding: var(--s-0-5) var(--s-2-5); - color: var(--color-text); - border-color: var(--color-border); - background: var(--color-bg); - - & .event-type-icon { - color: var(--color-text-accent); - } - } - - /* time + confidence chips share the event-type chip look; icon documents - the value, full label on hover via title */ - & .meta-chip { - display: inline-flex; - align-items: center; - gap: 5px; - padding: var(--s-0-5) var(--s-2-5); - border-radius: var(--radius-full); - border: var(--border-style); - background: var(--color-bg); - color: var(--color-text); - font-weight: var(--weight-bold); - - & .meta-chip-icon { - color: var(--color-text-accent); - } - } -} - -.scanner-app .card img.thumb { - display: block; - height: 40px; - width: auto; - border-radius: var(--radius-field); -} - -.scanner-app .teams { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); - gap: var(--s-2); - - /* single hug-content box (own-results cards) */ - &.solo { - grid-template-columns: max-content; - } - - /* single full-width box (death cards) */ - &.death { - grid-template-columns: 1fr; - } -} - -/* sendou.ink send status strip under a feed card */ -.scanner-app .send-wrap { - display: flex; - flex-direction: column; - gap: var(--s-0-5); -} - -.scanner-app .send-strip { - display: flex; - gap: var(--s-3); - align-items: center; - padding: var(--s-1) var(--s-4); - border-radius: var(--radius-box); - background: var(--color-bg-high); - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - color: var(--color-text-high); -} - -.scanner-app .send-strip.sent { - color: var(--color-success-high); - background: var(--color-success-low); -} - -.scanner-app .send-strip.failed { - color: var(--color-error-high); - background: var(--color-error-low); -} - -.scanner-app .send-strip.queued, -.scanner-app .send-strip.sending { - color: var(--color-info-high); - background: var(--color-info-low); -} - -.scanner-app .score { - color: var(--color-text-high); - font-size: var(--font-2xs); -} - -.scanner-app .error { - color: var(--color-error); -} - -.scanner-app .send-strip .error { - color: inherit; - font-weight: var(--weight-body); -} - -/* lighter than a solid slab: translucent fill + hairline so the boxes read - as grouping, not chrome */ -.scanner-app .team { - border-radius: var(--radius-field); - padding: var(--s-2) var(--s-2-5); - background: color-mix(in oklab, var(--color-bg) 45%, transparent); - border: 1px solid color-mix(in oklab, var(--color-border) 55%, transparent); - min-width: 0; - overflow-x: auto; -} - -.scanner-app .minimap-player { - display: flex; - align-items: center; - gap: var(--s-2); - padding-block: 3px; - font-size: var(--font-xs); - - & .weapon-missing { - flex-shrink: 0; - width: 24px; - text-align: center; - color: var(--color-text-high); - } - - & .name { - flex: 1 1 0; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - & .abilities { - display: flex; - align-items: center; - gap: var(--s-0-5); - flex-shrink: 0; - } -} - -/* d-pad chevron / face-button pucks, same round-solid language as weapon icons */ -.scanner-app .slot-marker { - flex-shrink: 0; - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: var(--radius-full); - background: var(--color-bg-badge); - color: #fff; - font-size: var(--font-2xs); - font-weight: var(--weight-bold); - line-height: 1; - - & svg { - width: 13px; - height: 13px; - } - - &.right svg { - transform: rotate(90deg); - } - - &.down svg { - transform: rotate(180deg); - } - - &.left svg { - transform: rotate(-90deg); - } -} - -.scanner-app .death-body { - display: flex; - align-items: center; - gap: var(--s-3); - padding-block: var(--s-0-5); - - & .death-info { - display: flex; - flex-direction: column; - gap: var(--s-0-5); - min-width: 0; - - & .death-name { - font-size: var(--font-xs); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - & .death-weapon { - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - color: var(--color-text-high); - } - } - - & .death-abilities { - display: flex; - align-items: center; - gap: var(--s-2-5); - margin-inline-start: auto; - flex-wrap: wrap; - - & .gear { - display: flex; - align-items: center; - gap: var(--s-0-5); - } - } -} - -.scanner-app .team h3 { - margin: 0 0 var(--s-1-5); - font-size: var(--font-2xs); - font-weight: var(--weight-bold); - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--color-text-high); -} - -.scanner-app .team.win h3 { - color: var(--color-error-high); -} - -.scanner-app .team.lose h3 { - color: var(--color-info-high); -} - -.scanner-app table.players { - width: 100%; - border-collapse: collapse; - font-size: var(--font-xs); -} - -.scanner-app table.players td { - padding: var(--s-0-5) var(--s-1-5); - white-space: nowrap; -} - -.scanner-app table.players td.num { - text-align: right; - font-variant-numeric: tabular-nums; -} - -.scanner-app .teams.solo table.players { - width: auto; -} - -/* round black pucks, same language as the match header weapon row */ -.scanner-app img.weapon-icon { - width: 28px; - height: 28px; - vertical-align: middle; - background: var(--color-bg-badge); - border-radius: var(--radius-full); - padding: var(--s-0-5); -} - -.scanner-app .minimap-player img.weapon-icon { - flex-shrink: 0; - width: 24px; - height: 24px; -} - -.scanner-app .weapon-cell { - display: inline-flex; - align-items: center; - gap: var(--s-1); -} - -.scanner-app button.ability-trigger { - height: auto; - padding: var(--s-0-5); - background: var(--color-bg-higher); - border: 1px solid var(--color-border-high); - border-radius: var(--radius-selector); -} - -.scanner-app button.ability-trigger img { - display: block; - width: var(--field-size-icon); - height: var(--field-size-icon); -} - -.scanner-app .vod-list { - display: flex; - flex-direction: column; - gap: var(--s-2); -} - -.scanner-app .vod-item { - display: flex; - align-items: center; - gap: var(--s-3); - background: var(--color-bg-high); - border-radius: var(--radius-box); - padding: var(--s-3) var(--s-4); -} - -.scanner-app .vod-item .name { - font-size: var(--font-sm); - font-weight: var(--weight-bold); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.scanner-app .vod-item .score { - flex: 1; -} - -/* the shared destructive variant loses to `.scanner-app button` on specificity */ -.scanner-app button.vod-delete { - width: var(--field-size-sm); - padding: 0; - border-color: var(--color-error); - background: var(--color-error); - outline-color: var(--color-error); -} - -.scanner-app .dropzone { - border: var(--border-width) dashed var(--color-border-high); - border-radius: var(--radius-box); - padding: var(--s-8); - text-align: center; - color: var(--color-text-high); - font-size: var(--font-sm); - font-weight: var(--weight-semi); - margin-bottom: var(--s-4); -} - -.scanner-app .dropzone label { - display: inline; - font-size: inherit; - font-weight: inherit; - margin-block-end: 0; - text-decoration: underline; - cursor: pointer; -} - -.scanner-app .dropzone.over { - border-color: var(--color-text-accent); - color: var(--color-text-accent); -} - -.scanner-app .screenshot-frame { - position: relative; - margin-bottom: var(--s-4); -} - -.scanner-app .screenshot-frame canvas { - width: 100%; - border-radius: var(--radius-box); - border: var(--border-width) solid var(--color-bg-high); -} - -.scanner-app table.inspector { - width: 100%; - border-collapse: collapse; - font-size: var(--font-xs); -} - -.scanner-app table.inspector th { - text-align: left; - color: var(--color-text-high); - font-size: var(--font-2xs); - font-weight: var(--weight-bold); - padding: var(--s-1-5); - border-bottom: var(--border-style); -} - -.scanner-app table.inspector td { - padding: var(--s-1-5); - border-bottom: 1px solid var(--color-bg-high); - vertical-align: middle; -} - -.scanner-app table.inspector canvas { - display: block; - background: #000; - border-radius: var(--radius-selector); -} - -.scanner-app .candidates { - display: flex; - gap: var(--s-2); - align-items: center; -} - -.scanner-app .candidates .cand { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--s-0-5); - font-size: var(--font-2xs); - color: var(--color-text-high); -} - -.scanner-app .weapon-candidates .cand:first-child { - color: var(--color-success); -} - -/* internal navigation styled like the sibling action buttons */ -.scanner-app .link-button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--s-1-5); - padding: 0 var(--field-padding); - height: var(--field-size-sm); - border: var(--border-style-accent); - border-radius: var(--radius-field); - background: var(--color-text-accent); - color: var(--color-text-inverse); - font-size: var(--font-xs); - font-weight: var(--weight-bold); - white-space: nowrap; - text-decoration: none; - - & > svg { - width: 16px; - height: 16px; - } - - &:active { - transform: translateY(1px); - } - - &:focus-visible { - outline: var(--focus-ring); - outline-offset: 1px; - } -} - -/* secondary actions live behind this icon-only trigger, pushed to the row's end */ -.scanner-app button.icon-menu { - margin-inline-start: auto; - width: var(--field-size-sm); - padding: 0; - border: var(--border-style); - background: var(--color-bg-higher); - color: var(--color-text); - - &:hover { - color: var(--color-text-accent); - } - - & > svg { - width: var(--field-size-icon); - height: var(--field-size-icon); - margin: 0; - } -} - -/* ── ingested matches feed ─────────────────────────────────────────── */ - -.scanner-app .set-divider { - display: flex; - align-items: center; - gap: var(--s-2-5); - margin-top: var(--s-1-5); - color: var(--color-text-high); - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - text-transform: uppercase; - letter-spacing: 0.08em; - - &::before, - &::after { - content: ""; - flex: 1; - height: var(--border-width); - background-color: var(--color-border); - } -} - -.scanner-app .match-card { - container: match-card / inline-size; - position: sticky; - top: var(--layout-sticky-top); - z-index: 1; - overflow: hidden; - border-radius: var(--radius-box); - background-color: var(--color-bg-high); - - /* only cards the scan just formed animate in — see MatchCard's `enter` */ - &.enter { - animation: scanner-card-in 0.4s cubic-bezier(0.16, 1, 0.3, 1) both; - } - - /* one overlay element all send-state effects draw on */ - &::after { - content: ""; - position: absolute; - inset: 0; - border-radius: inherit; - pointer-events: none; - } - - &.sending { - box-shadow: inset 0 0 0 1px var(--color-info-low); - } - - &.sending::after { - background-image: linear-gradient( - 105deg, - transparent 40%, - rgb(255 255 255 / 0.09) 50%, - transparent 60% - ); - background-size: 250% 100%; - animation: scanner-sheen 1.4s linear infinite; - } - - &.flash-sent::after { - animation: scanner-sent-pop 0.8s ease-out both; - } - - &.live { - box-shadow: inset 0 0 0 1px var(--color-info-low); - } -} - -@keyframes scanner-card-in { - from { - opacity: 0; - transform: translateY(10px) scale(0.98); - } -} - -@keyframes scanner-sheen { - from { - background-position: 130% 0; - } - to { - background-position: -130% 0; - } -} - -@keyframes scanner-sent-pop { - 0% { - opacity: 1; - box-shadow: - inset 0 0 0 2px var(--color-success), - inset 0 0 32px var(--color-success-low); - } - 100% { - opacity: 0; - box-shadow: - inset 0 0 0 2px var(--color-success), - inset 0 0 32px var(--color-success-low); - } -} - -.scanner-app .match-card-main { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--s-2) var(--s-3); - padding: var(--s-3) var(--s-4); - min-height: 66px; -} - -.scanner-app .match-card.flash-failed .match-card-main { - animation: scanner-shake 0.35s ease; -} - -@keyframes scanner-shake { - 20% { - transform: translateX(-4px); - } - 50% { - transform: translateX(4px); - } - 80% { - transform: translateX(-2px); - } -} - -.scanner-app .match-mode { - flex-shrink: 0; - filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.5)); -} - -.scanner-app .match-headline { - display: flex; - flex-direction: column; - gap: var(--s-1); - /* claims row space so the score/chips can never crush the stage name - out of view in a narrow feed column; wraps instead */ - flex: 1 1 0; - min-width: 150px; -} - -.scanner-app .match-title { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--s-1) var(--s-2); -} - -.scanner-app .match-stage { - font-size: var(--font-sm); - font-weight: var(--weight-extra); - line-height: 1.1; -} - -.scanner-app .match-meta { - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - color: var(--color-text-high); -} - -.scanner-app .match-weapons { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--s-0-5); - - /* a team is one wrapping unit: the eight never break up mid-team */ - & .weapon-row { - display: flex; - align-items: center; - gap: var(--s-0-5); - } - - & .vs { - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - color: var(--color-text-high); - margin: 0 5px; - } - - & img.weapon-icon { - flex-shrink: 0; - width: 32px; - height: 32px; - border-radius: var(--radius-full); - padding: var(--s-0-5); - - &.pov { - outline: var(--border-style-high); - background-color: var(--color-bg-high); - outline-offset: 1px; - } - } -} - -.scanner-app .match-side { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: var(--s-1-5); - margin-inline-start: auto; - align-self: stretch; - flex-shrink: 0; -} - -.scanner-app .match-score { - font-weight: var(--weight-extra); - font-variant-numeric: tabular-nums; - line-height: 1; - padding: 5px var(--s-3); - border-radius: var(--radius-full); - background: color-mix(in oklab, var(--color-bg) 72%, transparent); - backdrop-filter: blur(8px); - - & .win { - color: var(--color-error-high); - } - - & .lose { - color: var(--color-info-high); - } -} - -.scanner-app .match-chip { - display: inline-flex; - align-items: center; - gap: var(--s-1-5); - padding: var(--s-0-5) var(--s-2-5); - border-radius: var(--radius-full); - font-size: var(--font-2xs); - font-weight: var(--weight-bold); - border: var(--border-style); - background-color: var(--color-bg); - color: var(--color-text-high); - white-space: nowrap; - - & .dot { - width: 7px; - height: 7px; - border-radius: var(--radius-full); - background: currentColor; - } - - &.live { - color: var(--color-error); - border-color: var(--color-error-low); - text-transform: uppercase; - letter-spacing: 0.08em; - - & .dot { - animation: scanner-pulse 1.4s ease-in-out infinite; - } - } - - &.in-progress .dot { - animation: scanner-pulse 1.4s ease-in-out infinite; - } - - &.queued, - &.sending { - color: var(--color-info-high); - border-color: var(--color-info-low); - background-color: var(--color-info-low); - - & .dot { - animation: scanner-pulse 1.4s ease-in-out infinite; - } - } - - &.sent { - color: var(--color-success-high); - border-color: var(--color-success-low); - background-color: var(--color-success-low); - - & a { - color: inherit; - text-decoration: underline; - text-underline-offset: 2px; - - &:hover { - color: var(--color-text-high); - } - } - } - - &.unlinked { - color: var(--color-warning-high); - border-color: var(--color-warning-low); - background-color: var(--color-warning-low); - } - - &.failed { - color: var(--color-error-high); - border-color: var(--color-error-low); - background-color: var(--color-error-low); - } -} - -@keyframes scanner-pulse { - 50% { - opacity: 0.25; - transform: scale(0.8); - } -} - -/* undo the scanner-wide solid button look for the banner icon button */ -.scanner-app button.match-expand { - flex-shrink: 0; - margin-block-start: auto; - width: 28px; - height: 28px; - padding: 0; - border: none; - border-radius: var(--radius-full); - background: color-mix(in oklab, var(--color-bg) 55%, transparent); - backdrop-filter: blur(8px); - color: var(--color-text-high); - transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1); - - & svg { - width: var(--field-size-icon); - height: var(--field-size-icon); - } - - &:hover { - color: var(--color-text); - background: color-mix(in oklab, var(--color-bg) 78%, transparent); - } - - &.expanded { - transform: rotate(180deg); - } -} - -/* the frame preview: an unstyled button so the image itself is the target */ -.scanner-app .card .meta button.thumb-button { - height: auto; - padding: 0; - border: none; - background: none; - margin-inline-start: auto; - border-radius: var(--radius-field); - - &:hover { - filter: brightness(1.2); - } -} - -.scanner-app .match-error { - padding: 0 var(--s-4) var(--s-2-5); - font-size: var(--font-2xs); - color: var(--color-error); -} - -.scanner-app .match-card-group { - display: flex; - flex-direction: column; - gap: var(--s-2); -} - -/* source event cards, nested under their match card via an indent rail */ -.scanner-app .match-events { - display: flex; - flex-direction: column; - gap: var(--s-2); - margin-inline-start: var(--s-2-5); - padding-inline-start: var(--s-3); - border-inline-start: var(--border-width) solid var(--color-border); - animation: scanner-detail-in 0.25s ease both; - - & .card { - padding: var(--s-2-5) 14px; - min-width: 0; - } -} - -@keyframes scanner-detail-in { - from { - opacity: 0; - transform: translateY(-4px); - } -} - -.scanner-app .events-summary { - display: flex; - align-items: center; - gap: var(--s-2-5); - flex-wrap: wrap; - padding: var(--s-0-5) var(--s-1); - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - color: var(--color-text-high); -} - -.scanner-app .events-summary-type { - display: inline-flex; - align-items: center; - gap: 5px; - font-variant-numeric: tabular-nums; -} - -.scanner-app .events-summary-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - border-radius: var(--radius-full); - background: var(--color-bg-higher); - color: var(--color-text-high); -} - -.scanner-app button.events-toggle { - border: none; - background: transparent; - color: var(--color-text-accent); - height: auto; - padding: 0; - font-size: var(--font-2xs); -} - -.scanner-app .status.watching::before { - content: ""; - display: inline-block; - width: 7px; - height: 7px; - border-radius: var(--radius-full); - background: currentColor; - margin-inline-end: var(--s-1-5); - animation: scanner-pulse 1.6s ease-in-out infinite; -} - -/* the frame dialog portals outside .scanner-app, so these stay unscoped */ -.scanner-frame-dialog { - width: max-content; - max-width: min(96vw, 1400px); -} - -.scanner-frame-dialog img.frame-full { - display: block; - max-width: 100%; - max-height: calc(80dvh - 9rem); - border-radius: var(--radius-field); -} - -.scanner-frame-dialog .frame-actions { - display: flex; - justify-content: center; - gap: var(--s-3); - margin-block-start: var(--s-3); -} - -.scanner-app .telemetry { - margin: var(--s-2) 0; - font-size: var(--font-2xs); - color: var(--color-text-high); -} - -.scanner-app .telemetry summary { - cursor: pointer; -} - -.scanner-app .telemetry table { - margin-top: var(--s-1-5); - border-collapse: collapse; -} - -.scanner-app .telemetry table th, -.scanner-app .telemetry table td { - padding: var(--s-0-5) var(--s-2-5) var(--s-0-5) 0; - text-align: right; - font-variant-numeric: tabular-nums; -} - -.scanner-app .telemetry table th:first-child, -.scanner-app .telemetry table td:first-child { - text-align: left; -} - -/* ── narrow viewports ──────────────────────────────────────────────── */ - -/* a 640px wide preview would starve the feed next to it: split evenly instead */ -@container scanner (width < 1000px) { - .scanner-app .live-layout { - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - } - - /* the controls wrap here, where a trigger pushed to the end reads as stray */ - .scanner-app button.icon-menu { - margin-inline-start: 0; - } -} - -/* the two teams' weapons no longer fit beside each other: one row each */ -@container match-card (width < 460px) { - .scanner-app .match-weapons { - flex-direction: column; - align-items: flex-start; - row-gap: var(--s-1); - - & .vs { - display: none; - } - } -} - -@container scanner (width < 700px) { - .scanner-app .live-layout { - grid-template-columns: minmax(0, 1fr); - } - - /* stacked, the preview would otherwise push the feed off the screen */ - .scanner-app video.preview, - .scanner-app canvas.preview { - max-height: 45vh; - object-fit: contain; - } - - .scanner-app .vod-item { - flex-wrap: wrap; - } - - /* the ROI crops set the width; scroll them instead of the whole page */ - .scanner-app table.inspector { - display: block; - overflow-x: auto; - } -} - -@media (prefers-reduced-motion: reduce) { - .scanner-app .match-card, - .scanner-app .match-card.sending::after, - .scanner-app .match-card.flash-sent::after, - .scanner-app .match-card.flash-failed .match-card-main, - .scanner-app .match-events, - .scanner-app .match-chip.live .dot, - .scanner-app .match-chip.in-progress .dot, - .scanner-app .match-chip.queued .dot, - .scanner-app .match-chip.sending .dot, - .scanner-app .status.watching::before { - animation: none; - } -} diff --git a/app/features/scanner/core/detectors/minimap/index.ts b/app/features/scanner/core/detectors/minimap/index.ts index 9b2ab6649..4cfa5dc41 100644 --- a/app/features/scanner/core/detectors/minimap/index.ts +++ b/app/features/scanner/core/detectors/minimap/index.ts @@ -3,11 +3,12 @@ * 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. + * The goal is the most complete read of every card/row; map control is + * deliberately not reported. * - * Two screen states still steer the reads without being emitted: + * Two per-player screen states are reported (`dead`, `specialReady` — the + * match builder merges them into the death/special timeline alongside the + * icon-strip PlayerStatus reads) and steer the reads themselves: * - 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 @@ -37,6 +38,7 @@ import { meanBrightness, type Roi, } from "../../image"; +import { type InkRgb, meanInkColor } from "../../ink-color"; import type { ScoreboardResources } from "../scoreboard/index"; import { type ParsedName, parseName } from "../scoreboard/names"; import { @@ -95,6 +97,10 @@ export interface MinimapTeammate { * unreadable badge); empty when a respawn cross-out sits over the badges */ abilities: (AbilityWithUnknown | null)[]; + /** struck through with the respawn cross-out at the read */ + dead: boolean; + /** on the light camo surface of a charged special at the read */ + specialReady: boolean; } export interface MinimapEnemy { @@ -106,6 +112,10 @@ export interface MinimapEnemy { /** readable even on struck rows: the cross-out spares the weapon icon */ weaponId: MainWeaponId | null; abilities: (AbilityWithUnknown | null)[]; + /** struck through with the respawn cross-out at the read */ + dead: boolean; + /** on the light camo surface of a charged special at the read */ + specialReady: boolean; } export interface MinimapData { @@ -127,10 +137,41 @@ export interface MinimapData { teammates: MinimapTeammate[]; /** enemy panel rows, top to bottom */ enemies: MinimapEnemy[]; + /** + * mean team-ink RGB per side sampled from the sub-weapon tiles + * ([teammates/alpha, enemies/bravo]); null when too little saturated + * ink. Anchors the objective counter's color-tracked sides to `teams` + * order on casted footage, which never shows a results screen. + */ + teamColors: [InkRgb | null, InkRgb | null]; } export const MINIMAP_EVENT_TYPE = "Minimap"; +/** + * Timeline content guard: minimap frames inside the merge window collapse + * only while every card/row keeps its dead/special state, so each flip a + * map-open catches (a respawn, a special charged or spent) stays its own + * event. Names, weapons and badges are not compared — OCR wobble on an + * unchanged screen is still the same state. + */ +export function sameMinimapStatusData(a: unknown, b: unknown): boolean { + const da = a as MinimapData; + const db = b as MinimapData; + const sameSide = ( + xs: readonly { dead: boolean; specialReady: boolean }[], + ys: readonly { dead: boolean; specialReady: boolean }[], + ): boolean => + xs.length === ys.length && + xs.every( + (x, i) => + x.dead === ys[i]!.dead && x.specialReady === ys[i]!.specialReady, + ); + return ( + sameSide(da.teammates, db.teammates) && sameSide(da.enemies, db.enemies) + ); +} + /** Badge match below this is reported as null (kept in debug). */ const ABILITY_MIN_SCORE = 0.45; @@ -324,6 +365,7 @@ export function createMinimapDetector( const teammates: MinimapTeammate[] = []; const enemies: MinimapEnemy[] = []; + const sideSubTiles: [Roi[], Roi[]] = [[], []]; const cardDebug: Record[] = []; for (const dx of [0, SPECTATOR_ENEMY_DX]) { for (let row = 0; row < 4; row++) { @@ -333,6 +375,7 @@ export function createMinimapDetector( cardDebug.push({ dx, row, presence, skipped: true }); continue; } + sideSubTiles[dx === 0 ? 0 : 1].push(layout.subTile); const crossFraction = saturatedFraction(hsv, layout.cross); const occluded = crossFraction >= CROSS_MIN_FRACTION; const cornerMin = minTopCornerMean(gray, layout.weapon); @@ -397,6 +440,8 @@ export function createMinimapDetector( name, weaponId: matched ? toMainWeaponId(matched.id) : null, abilities, + dead: occluded, + specialReady: lightSurface, }; if (dx === 0) { teammates.push({ slot: SPECTATOR_SLOTS[row]!, ...fields }); @@ -407,6 +452,11 @@ export function createMinimapDetector( } debug.cards = cardDebug; + const teamColors: [InkRgb | null, InkRgb | null] = [ + meanInkColor(rgb, sideSubTiles[0]), + meanInkColor(rgb, sideSubTiles[1]), + ]; + const stageMatch = detectStage(frame, confidences); debug.stage = stageMatch; @@ -429,6 +479,7 @@ export function createMinimapDetector( spectator: true, teammates, enemies, + teamColors, }, debug, }, @@ -463,6 +514,7 @@ export function createMinimapDetector( // 1. own-team callout cards const teammates: MinimapTeammate[] = []; + const sideSubTiles: [Roi[], Roi[]] = [[], []]; const cardDebug: Record[] = []; for (const layout of CARD_LAYOUTS) { // presence: the card is crisp UI, absent slots show blurred scene @@ -540,11 +592,14 @@ export function createMinimapDetector( matched !== null || abilities.some((a) => a !== null); if (!hasEvidence) continue; + sideSubTiles[0].push(layout.subTile); teammates.push({ slot: layout.slot, name, weaponId: matched ? toMainWeaponId(matched.id) : null, abilities, + dead: occluded, + specialReady: lightSurface, }); } debug.cards = cardDebug; @@ -604,14 +659,22 @@ export function createMinimapDetector( ? SPECIAL_READY_WEAPON_MIN_SCORE : WEAPON_MIN_SCORE; const matched = weapon !== null && weapon.score >= floor ? weapon : null; + sideSubTiles[1].push(enemySubTileRoi(cy)); enemies.push({ name: null, weaponId: matched ? toMainWeaponId(matched.id) : null, abilities, + dead: occluded, + specialReady: lightSurface, }); } debug.enemies = enemyDebug; + const teamColors: [InkRgb | null, InkRgb | null] = [ + meanInkColor(rgb, sideSubTiles[0]), + meanInkColor(rgb, sideSubTiles[1]), + ]; + const stageMatch = detectStage(frame, confidences); debug.stage = stageMatch; @@ -635,6 +698,7 @@ export function createMinimapDetector( spectator: false, teammates, enemies, + teamColors, }, debug, }, diff --git a/app/features/scanner/core/detectors/objective/index.ts b/app/features/scanner/core/detectors/objective/index.ts index e4cd13ef2..82fd5ac64 100644 --- a/app/features/scanner/core/detectors/objective/index.ts +++ b/app/features/scanner/core/detectors/objective/index.ts @@ -15,6 +15,11 @@ * `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. + * + * Every successful counter read additionally emits a PlayerStatus event + * (player-status.ts) parsed off the per-player icon strip of the same + * frame — the counter parse carries the lookalike rejection for both, and + * the shared timer value pairs the two events downstream. */ import { getCV, type Mat, minMaxLoc } from "../../cv"; import { @@ -31,6 +36,7 @@ import { minChannel, type Roi, } from "../../image"; +import { type InkRgb, meanInkColor } from "../../ink-color"; import { type BannerScoreRead, isBetterRead, @@ -38,6 +44,11 @@ import { } from "../scoreboard/banner"; import type { ScoreboardResources } from "../scoreboard/index"; import type { DetectedEvent, Detector, GateResult } from "../types"; +import { + type PlayerStatusData, + type PlayerStatusLayout, + parsePlayerStatus, +} from "./player-status"; import { CONTROL_PLATE_MIN_SATURATION, GATE_PLATE_MAX_STD, @@ -52,15 +63,19 @@ import { PENALTY_TEXT_HEIGHT, PLATE_PROBE_ROIS, SCORE_BIN_THRESHOLDS, + SCORE_EXTEND_MIN_CONF, SCORE_ROIS, SCORE_TEXT_HEIGHTS, + STATUS_LAYOUT_STICKY_MAX_GAP_S, + STRIP_WEAPON_SAMPLE_INTERVAL, TIMER_BIN_THRESHOLD, TIMER_DARK_PROBES, TIMER_DIGIT_MIN_CONF, TIMER_DIGIT_MIN_HEIGHT_RATIO, TIMER_DIGIT_ROI, - TIMER_TEXT_HEIGHT, + TIMER_TEXT_HEIGHTS, } from "./rois"; +import { parseStripWeapons, type StripWeaponsData } from "./strip-weapons"; export type ObjectiveData = SplatZonesObjectiveData; @@ -78,6 +93,14 @@ export interface SplatZonesObjectiveData { penalty: [number | null, number | null]; /** which team currently holds the zone (team-color plate fill) */ control: [boolean, boolean]; + /** + * mean team-ink RGB per side, sampled off the plate (the team-color + * fill while in control, the digit ink otherwise — one of the two is + * always drawn in the team's color); null when too little saturated + * ink was found. The stable team identity for casted footage, where + * the plates follow the specced player's side instead of alpha/bravo. + */ + teamColor: [InkRgb | null, InkRgb | null]; } export const OBJECTIVE_EVENT_TYPE = "Objective"; @@ -89,7 +112,8 @@ 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. + * every second, so comparing it would keep any two reads from ever merging + * — and neither is `teamColor`, whose raw pixel means jitter per frame. */ export function sameObjectiveData(a: unknown, b: unknown): boolean { const da = a as ObjectiveData; @@ -110,12 +134,17 @@ interface SideRead { penalty: BannerScoreRead | null; control: boolean; fill: { mean: number; saturation: number }; + teamColor: InkRgb | null; } export function createObjectiveDetector( resources: ScoreboardResources, -): Detector { +): Detector { const cv = getCV(); + let lastStatus: { layout: PlayerStatusLayout; t: number } | undefined; + // primed so the very first counter read samples — short matches and + // single-frame runs (fixtures) get evidence too + let readsSinceWeaponSample = STRIP_WEAPON_SAMPLE_INTERVAL; const scoreSets: GlyphSet[] = resources.paintDigits ? SCORE_TEXT_HEIGHTS.map((h) => @@ -131,12 +160,14 @@ export function createObjectiveDetector( PENALTY_TEXT_HEIGHT / resources.paintDigits.height, ) : null; - const timerSet: GlyphSet | null = resources.paintDigits - ? scaleGlyphSet( - resources.paintDigits, - TIMER_TEXT_HEIGHT / resources.paintDigits.height, + const timerSets: GlyphSet[] = resources.paintDigits + ? TIMER_TEXT_HEIGHTS.map((h) => + scaleGlyphSet( + resources.paintDigits!, + h / resources.paintDigits!.height, + ), ) - : null; + : []; /** Mean and standard deviation of a grayscale ROI. */ function meanStd(gray: Mat, roi: Roi): { mean: number; std: number } { @@ -205,7 +236,13 @@ export function createObjectiveDetector( spaceGap: Number.POSITIVE_INFINITY, minCharScore: 0.3, }); - const read = trailingDigitRun(raw, set); + // the band holds nothing but the count, so a leading digit + // blurred below the extension floor voids the read instead of + // truncating it (see SCORE_EXTEND_MIN_CONF) + const read = trailingDigitRun(raw, set, { + extendMinScore: SCORE_EXTEND_MIN_CONF, + rejectTruncated: true, + }); if (isBetterRead(read, best)) best = read; } } @@ -218,34 +255,47 @@ export function createObjectiveDetector( * 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. + * full-height digits — the minute, then the two second digits. Each glyph + * size is tried (the digits render bigger on upscaled 720p footage) and + * the valid read with the most confident digits wins. */ 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, + let best: { value: number | null; reading: string; score: number } = { + value: null, + reading: "", + score: 0, }; + for (const timerSet of timerSets) { + const raw = recognizeText(band, timerSet, { + binThreshold: TIMER_BIN_THRESHOLD, + spaceGap: Number.POSITIVE_INFINITY, + minCharScore: 0.3, + }); + if (!best.reading) best = { ...best, reading: raw.text }; + const isTimerDigit = (c: RecognizedChar) => + c.score >= TIMER_DIGIT_MIN_CONF && + c.y1 - c.y0 >= timerSet.height * TIMER_DIGIT_MIN_HEIGHT_RATIO; + const chars = raw.chars.filter(isTimerDigit); + const digits = chars.map((c) => Number(c.char)); + if (digits.length !== 3 || digits.some(Number.isNaN)) continue; + const [minutes, secondsTens, secondsOnes] = digits as [ + number, + number, + number, + ]; + if (secondsTens >= 6) continue; + const score = chars.reduce((sum, c) => sum + c.score, 0) / chars.length; + if (score > best.score) { + best = { + value: minutes * 60 + secondsTens * 10 + secondsOnes, + reading: raw.text, + score, + }; + } + } + band.delete(); + return { value: best.value, reading: best.reading }; } /** Penalty pill: presence probes first, then the white "+N" digits. */ @@ -297,7 +347,10 @@ export function createObjectiveDetector( return { mean: sum / count, saturation: satSum / count }; } - function parse(frame: Mat, t: number): DetectedEvent[] { + function parse( + frame: Mat, + t: number, + ): DetectedEvent[] { const gray = new cv.Mat(); cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); @@ -312,6 +365,12 @@ export function createObjectiveDetector( score.value !== null && fill.saturation >= CONTROL_PLATE_MIN_SATURATION, fill, + // the fill while in control, the digit ink otherwise — both + // ROIs together always cover whichever carries the team color + teamColor: meanInkColor(frame, [ + SCORE_ROIS[side], + PLATE_PROBE_ROIS[side], + ]), }; }) as [SideRead, SideRead]; const timer = readTimer(gray); @@ -320,11 +379,42 @@ export function createObjectiveDetector( // no readable count on either side = the gate hit a lookalike if (sides.every((side) => side.score.value === null)) return []; + const playerStatus = parsePlayerStatus( + frame, + t, + timer.value, + lastStatus && t - lastStatus.t <= STATUS_LAYOUT_STICKY_MAX_GAP_S + ? lastStatus.layout + : undefined, + ); + lastStatus = { layout: playerStatus.data.layout, t }; + + // sampled slot-identity evidence for the strip → scoreboard-row + // assignment; every read would re-measure fixed identities at full + // template-sweep cost + let stripWeapons: DetectedEvent | null = null; + readsSinceWeaponSample++; + if ( + resources.stripWeapons && + readsSinceWeaponSample >= STRIP_WEAPON_SAMPLE_INTERVAL + ) { + readsSinceWeaponSample = 0; + stripWeapons = parseStripWeapons( + frame, + t, + playerStatus.data, + resources.stripWeapons, + ); + } + const confidences = sides.flatMap((side) => [ ...(side.score.value !== null ? [side.score.confidence] : []), ...(side.penalty?.value != null ? [side.penalty.confidence] : []), ]); return [ + // the icon-strip statuses ride along with every counter read: the + // count confirmation above is the shared lookalike rejection, and + // the shared timer value is what pairs the two events downstream { type: OBJECTIVE_EVENT_TYPE, t, @@ -338,6 +428,7 @@ export function createObjectiveDetector( sides[1].penalty?.value ?? null, ], control: [sides[0].control, sides[1].control], + teamColor: [sides[0].teamColor, sides[1].teamColor], }, debug: { timerReading: timer.reading, @@ -347,6 +438,8 @@ export function createObjectiveDetector( plateFills: sides.map((side) => side.fill), }, }, + playerStatus, + ...(stripWeapons ? [stripWeapons] : []), ]; } diff --git a/app/features/scanner/core/detectors/objective/player-status.ts b/app/features/scanner/core/detectors/objective/player-status.ts new file mode 100644 index 000000000..5a83f501e --- /dev/null +++ b/app/features/scanner/core/detectors/objective/player-status.ts @@ -0,0 +1,354 @@ +/** + * PlayerStatus: the per-player state read off the eight squid/octo icons + * flanking the match timer, emitted by the ObjectiveDetector alongside each + * Objective read (same frame, same `time`, so callers can pair the two). + * + * Per slot, three pixel-class fractions decide the state (rois.ts documents + * the calibration): an alive icon's body is saturated team ink; holding + * special washes the body into a bright pale glow that PULSES — bright + * frames light the shoulder probe past the glow floor, trough frames only + * read as pale — and a splatted icon is an unsaturated grey/dark X with + * none of the three. The casted spectator HUD draws the same strip at its + * own geometry — two of them, in fact: the pitches mirror when the specced + * POV sits on the other team ("cast-mirror", whose right column nearly + * coincides with POV's). White camera badges under the right team prove + * either cast arrangement, but broadcasts can hide them while keeping cast + * geometry, so a badge-less frame picks whichever geometry reads more + * decisively (bodies far from the dead threshold on either side) instead + * of assuming POV. + */ +import type { Mat } from "../../cv"; +import { copyRoi, type Roi } from "../../image"; +import type { DetectedEvent } from "../types"; +import { + STATUS_BODY_BOX_CAST, + STATUS_BODY_BOX_POV, + STATUS_CAST_MIN_DPAD_WHITE, + STATUS_DEAD_MAX_BODY_INK, + STATUS_DEAD_MAX_BODY_PALE, + STATUS_DEAD_MAX_SHOULDER_GLOW, + STATUS_DPAD_PROBES, + STATUS_DPAD_PROBES_MIRROR, + STATUS_GLOW_MIN_VALUE, + STATUS_INK_MIN_SPREAD, + STATUS_INK_MIN_VALUE, + STATUS_LAYOUT_SCORE_CAP, + STATUS_LAYOUT_STICKY_MARGIN, + STATUS_PALE_MAX_SPREAD, + STATUS_PALE_MIN_VALUE, + STATUS_READY_MIN_BODY_PALE, + STATUS_READY_MIN_SHOULDER_GLOW, + STATUS_READY_WASH_MAX_BODY_INK, + STATUS_SHOULDER_BOX_CAST, + STATUS_SHOULDER_BOX_POV, + STATUS_SLOT_CENTERS_CAST, + STATUS_SLOT_CENTERS_CAST_MIRROR, + STATUS_SLOT_CENTERS_POV, + STATUS_WHITE_MAX_SPREAD, + STATUS_WHITE_MIN_VALUE, +} from "./rois"; + +export const PLAYER_STATUS_EVENT_TYPE = "PlayerStatus"; + +export type PlayerStatusFlags = [boolean, boolean, boolean, boolean]; + +export type PlayerStatusLayout = "pov" | "cast" | "cast-mirror"; + +export interface PlayerStatusData { + /** + * seconds shown on the match timer at the read, same value as the + * Objective event from the same frame — the key for pairing the two + */ + time: number | null; + /** special held per slot, [left team, right team], slots left-to-right */ + special: [PlayerStatusFlags, PlayerStatusFlags]; + /** splatted per slot, same arrangement */ + dead: [PlayerStatusFlags, PlayerStatusFlags]; + /** which icon-strip geometry the frame showed */ + layout: PlayerStatusLayout; +} + +/** + * Timeline content guard: reads merge only while every slot state matches, + * so each death/respawn/special flip becomes its own event. `time` is not + * compared (it ticks every second) and neither is `layout` (a camera-style + * change with identical states is the same state). + */ +export function samePlayerStatusData(a: unknown, b: unknown): boolean { + const da = a as PlayerStatusData; + const db = b as PlayerStatusData; + for (const side of [0, 1] as const) { + for (let slot = 0; slot < 4; slot++) { + if (da.special[side][slot] !== db.special[side][slot]) return false; + if (da.dead[side][slot] !== db.dead[side][slot]) return false; + } + } + return true; +} + +interface SlotRead { + dead: boolean; + special: boolean; + confidence: number; + bodyInk: number; + bodyPale: number; + shoulderGlow: number; +} + +/** + * Parse the icon strip of a frame the objective gate already anchored as + * the in-match counter HUD. Callers emit the event only alongside a + * successful Objective read — the counter parse carries the lookalike + * rejection for both. `prevLayout` is the layout of the caller's previous + * read (sticky: a badge-less frame only switches geometry when the other + * layout wins the decisiveness score by a clear margin — the footage type + * does not flip frame to frame, but a busy scene can nudge the score). + */ +export function parsePlayerStatus( + frame: Mat, + t: number, + time: number | null, + prevLayout?: PlayerStatusLayout, +): DetectedEvent { + const { layout, scores } = pickLayout(frame, prevLayout); + const sides = readSlots(frame, layout); + + const reads = sides.flat(); + return { + type: PLAYER_STATUS_EVENT_TYPE, + t, + confidence: + reads.reduce((sum, read) => sum + read.confidence, 0) / reads.length, + data: { + time, + special: sides.map((side) => + side.map((read) => read.special), + ) as PlayerStatusData["special"], + dead: sides.map((side) => + side.map((read) => read.dead), + ) as PlayerStatusData["dead"], + layout, + }, + debug: { + layout, + layoutScores: scores + ? Object.fromEntries( + Object.entries(scores).map(([name, score]) => [ + name, + Number(score.toFixed(3)), + ]), + ) + : "badges", + bodyInk: reads.map((read) => Number(read.bodyInk.toFixed(2))), + bodyPale: reads.map((read) => Number(read.bodyPale.toFixed(2))), + shoulderGlow: reads.map((read) => Number(read.shoulderGlow.toFixed(2))), + }, + }; +} + +function readSlots( + frame: Mat, + layout: PlayerStatusLayout, +): [SlotRead[], SlotRead[]] { + const centers = + layout === "pov" + ? STATUS_SLOT_CENTERS_POV + : layout === "cast" + ? STATUS_SLOT_CENTERS_CAST + : STATUS_SLOT_CENTERS_CAST_MIRROR; + const shoulderBox = + layout === "pov" ? STATUS_SHOULDER_BOX_POV : STATUS_SHOULDER_BOX_CAST; + const bodyBox = layout === "pov" ? STATUS_BODY_BOX_POV : STATUS_BODY_BOX_CAST; + + return centers.map((sideCenters) => + sideCenters.map((cx): SlotRead => { + const shoulder = classFractions(frame, { + x: cx + shoulderBox.dx, + y: shoulderBox.y, + w: shoulderBox.w, + h: shoulderBox.h, + }); + const body = classFractions(frame, { + x: cx + bodyBox.dx, + y: bodyBox.y, + w: bodyBox.w, + h: bodyBox.h, + }); + return classifySlot(body.ink, body.pale, shoulder.glow, layout); + }), + ) as [SlotRead[], SlotRead[]]; +} + +const ALL_LAYOUTS: readonly PlayerStatusLayout[] = [ + "pov", + "cast", + "cast-mirror", +]; + +/** + * Which layouts a badge-less frame may flip to from an established one on + * score alone. The cast-mirror right column nearly coincides with POV's, so + * decisiveness cannot tell those two apart — and a wrong POV pick on cast + * footage self-heals (the next badge frame proves the arrangement) while a + * wrong mirror pick on POV footage never would (POV shows no badges). So + * the mirror is only reachable via badges or from an established cast + * layout (the specced POV switching teams mid-game, attested in the AREA + * CUP VoD's badge-less overhead stretches). + */ +const SCORED_FLIPS: Record = + { + pov: ["cast"], + cast: ["pov", "cast-mirror"], + "cast-mirror": ["cast"], + }; + +/** + * Camera badges prove a cast arrangement outright (each arrangement has its + * own badge columns). Badge-less frames are NOT proven POV — broadcasts can + * hide the badges while keeping cast icon geometry — so the candidate + * geometries are scored and the one whose body reads land decisively on + * either side of the dead threshold wins. A mispicked geometry puts + * outer-slot boxes between icons or on backdrop, which reads mid-range ink — + * exactly what the score punishes; boxes on featureless dark backdrop still + * read "decisively dead" though, so a busy scene can nudge a frame's score + * across — the sticky margin keeps a single noisy frame from flipping an + * established layout, and SCORED_FLIPS keeps the POV/mirror false friends + * from ever trading places without badge proof. + */ +function pickLayout( + frame: Mat, + prevLayout: PlayerStatusLayout | undefined, +): { + layout: PlayerStatusLayout; + scores: Record | null; +} { + if (badgesVisible(frame, STATUS_DPAD_PROBES)) + return { layout: "cast", scores: null }; + if (badgesVisible(frame, STATUS_DPAD_PROBES_MIRROR)) + return { layout: "cast-mirror", scores: null }; + const scores = Object.fromEntries( + ALL_LAYOUTS.map((layout) => [layout, layoutDecisiveness(frame, layout)]), + ) as Record; + if (prevLayout) { + const challenger = SCORED_FLIPS[prevLayout].reduce((a, b) => + scores[b] > scores[a] ? b : a, + ); + return { + layout: + scores[challenger] > scores[prevLayout] + STATUS_LAYOUT_STICKY_MARGIN + ? challenger + : prevLayout, + scores, + }; + } + return { layout: scores.pov >= scores.cast ? "pov" : "cast", scores }; +} + +function layoutDecisiveness(frame: Mat, layout: PlayerStatusLayout): number { + const reads = readSlots(frame, layout).flat(); + return ( + reads.reduce( + (sum, read) => + sum + + Math.min( + Math.abs(read.bodyInk - STATUS_DEAD_MAX_BODY_INK), + STATUS_LAYOUT_SCORE_CAP, + ), + 0, + ) / reads.length + ); +} + +/** + * State from the three fractions, with a confidence scaled by the distance + * to the nearest decision boundary (1 at twice the threshold / at zero). + * On the cast layout a ready icon is always the wash, which replaces the + * body's team ink — an ink-heavy body there means the bright read is + * backdrop leaking past the icon edge, not a held special (see + * STATUS_READY_WASH_MAX_BODY_INK). + */ +function classifySlot( + bodyInk: number, + bodyPale: number, + shoulderGlow: number, + layout: PlayerStatusLayout, +): SlotRead { + const dead = + bodyInk <= STATUS_DEAD_MAX_BODY_INK && + shoulderGlow <= STATUS_DEAD_MAX_SHOULDER_GLOW && + bodyPale <= STATUS_DEAD_MAX_BODY_PALE; + const special = + !dead && + (shoulderGlow >= STATUS_READY_MIN_SHOULDER_GLOW || + bodyPale >= STATUS_READY_MIN_BODY_PALE) && + (layout === "pov" || bodyInk <= STATUS_READY_WASH_MAX_BODY_INK); + const confidence = dead + ? Math.min( + 1, + (STATUS_DEAD_MAX_BODY_INK - bodyInk) / STATUS_DEAD_MAX_BODY_INK, + ) + : special + ? Math.min( + 1, + Math.max( + shoulderGlow / (STATUS_READY_MIN_SHOULDER_GLOW * 2), + bodyPale / (STATUS_READY_MIN_BODY_PALE * 2), + ), + ) + : Math.min(1, bodyInk / (STATUS_DEAD_MAX_BODY_INK * 2)); + return { dead, special, confidence, bodyInk, bodyPale, shoulderGlow }; +} + +/** Ink, glow, and pale pixel fractions of a ROI (see rois.ts for the classes). */ +function classFractions( + frame: Mat, + roi: Roi, +): { ink: number; glow: number; pale: number } { + const crop = copyRoi(frame, roi); + const { data } = crop; + const channels = crop.channels(); + let ink = 0; + let glow = 0; + let pale = 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]!; + const value = Math.max(r, g, b); + const spread = value - Math.min(r, g, b); + if (spread >= STATUS_INK_MIN_SPREAD && value >= STATUS_INK_MIN_VALUE) ink++; + if (value >= STATUS_GLOW_MIN_VALUE) glow++; + if (value >= STATUS_PALE_MIN_VALUE && spread <= STATUS_PALE_MAX_SPREAD) + pale++; + count++; + } + crop.delete(); + return { ink: ink / count, glow: glow / count, pale: pale / count }; +} + +/** All four badge probes reading white = that casted spectator arrangement. */ +function badgesVisible(frame: Mat, probes: readonly Roi[]): boolean { + return probes.every((roi) => { + const crop = copyRoi(frame, roi); + const { data } = crop; + const channels = crop.channels(); + let white = 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]!; + const value = Math.max(r, g, b); + if ( + value >= STATUS_WHITE_MIN_VALUE && + value - Math.min(r, g, b) <= STATUS_WHITE_MAX_SPREAD + ) { + white++; + } + count++; + } + crop.delete(); + return white / count >= STATUS_CAST_MIN_DPAD_WHITE; + }); +} diff --git a/app/features/scanner/core/detectors/objective/rois.ts b/app/features/scanner/core/detectors/objective/rois.ts index 1f12bbe8e..40a57d2ed 100644 --- a/app/features/scanner/core/detectors/objective/rois.ts +++ b/app/features/scanner/core/detectors/objective/rois.ts @@ -83,10 +83,13 @@ 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. + * Timer's white M:SS digits measure 34px on native 1080p footage; upscaled + * 720p captures draw them at ~40px. Every height is tried and the best + * valid read wins — the wrong-scale set scores well under a clean read's + * confidence. The colon's dots stack under the digit height floor at + * either size, so a plain height filter drops the colon. */ -export const TIMER_TEXT_HEIGHT = 34; +export const TIMER_TEXT_HEIGHTS = [34, 40] as const; export const TIMER_BIN_THRESHOLD = 160; export const TIMER_DIGIT_MIN_CONF = 0.75; export const TIMER_DIGIT_MIN_HEIGHT_RATIO = 0.82; @@ -104,6 +107,16 @@ export const PENALTY_TEXT_HEIGHT = 29; */ export const SCORE_BIN_THRESHOLDS = [160, 190] as const; +/** + * Extension floor for count digits joining a run anchored by a confident + * one: white digits on a bright control fill blur into the plate on + * compressed cast footage, eroding a leading digit to 0.64-0.76 while its + * neighbor still clears the main floor ("50" read as trailing "0" — the + * Splat World Series lime plates). Noise chars on the same plates matched + * digit templates at up to 0.66 but never alongside an anchor digit. + */ +export const SCORE_EXTEND_MIN_CONF = 0.6; + /** Penalty pill: white digits on the translucent dark fill (~100 gray). */ export const PENALTY_BIN_THRESHOLD = 170; export const PENALTY_PROBE_MAX_MEAN = 165; @@ -116,3 +129,226 @@ export const PENALTY_PROBE_MAX_STD = 30; * (attested fills >=112 vs <=19). */ export const CONTROL_PLATE_MIN_SATURATION = 60; + +// ---- player-status icon strips (the PlayerStatus event) ---- +// +// Eight per-player squid/octo icons flank the timer. An alive icon's body +// is drawn in team ink; holding special washes the upper body out into a +// bright pale glow; a splatted player's icon turns an unsaturated grey/dark +// X'd shape. Two layouts share the band: POV (small icons) and the casted +// spectator HUD (bigger icons, gauge digits hanging over each icon's +// top-RIGHT and white camera-button badges below) — slot centers are +// measured per side off the fixtures; neither layout is mirror-symmetric +// (POV inner icons sit 108px left / 130px right of screen center) and the +// cast sides don't even share a pitch (~98 left vs ~76 right). Cast icons +// ride their badge columns ~20px left of each badge center; the outer-right +// center sits a few px past the measured icon (~1313) because splat X's +// there lean into inked backdrop on their left while alive bodies extend +// right (attested dead <=0.16 vs alive >=0.33 at 1320). +// +// The cast strip additionally MIRRORS its pitches when the broadcast specs +// a player on the other team (attested mid-game in the AREA CUP VoD): the +// narrow ~76 pitch swaps to the left column and the wide ~97 pitch to the +// right, badges and all — the "cast-mirror" layout. Its right column lands +// within a few px of the POV right column, so a mirrored frame scores +// deceptively well as POV; the mirror's own geometry must be a scored +// candidate (and badge-probed) or the left column misreads ready/dead. +// +// Broadcasts can hide the camera badges while keeping the cast icon +// geometry (attested in the AREA CUP VoD), so badge absence alone cannot +// pick the POV layout — player-status.ts scores both geometries and keeps +// the one whose body reads sit decisively on either side of the dead +// threshold; a mispicked layout puts outer-slot boxes between icons and +// flickers phantom deaths on the outermost players. + +/** Per-side slot center x's, slots left-to-right. */ +export const STATUS_SLOT_CENTERS_POV: readonly [ + readonly number[], + readonly number[], +] = [ + [554, 653, 752, 852], + [1090, 1190, 1288, 1388], +]; +export const STATUS_SLOT_CENTERS_CAST: readonly [ + readonly number[], + readonly number[], +] = [ + [543, 642, 741, 837], + [1085, 1161, 1237, 1320], +]; +export const STATUS_SLOT_CENTERS_CAST_MIRROR: readonly [ + readonly number[], + readonly number[], +] = [ + [605, 681, 757, 833], + [1090, 1187, 1283, 1381], +]; + +/** + * Shoulder probe: the icon's upper-left body, clear of the weapon + * silhouette (drawn center/lower), the cast gauge digits (hanging top-right) + * and the POV sub/special trinkets (bottom). The special-ready glow is + * detected here. Boxes are relative to a slot center. + */ +export const STATUS_SHOULDER_BOX_POV = { dx: -30, y: 38, w: 24, h: 20 }; +export const STATUS_SHOULDER_BOX_CAST = { dx: -30, y: 35, w: 24, h: 30 }; + +/** + * Body probe: the widest band of the icon that dodges the cast camera + * badges below (y>=100) and the POV coin trinkets (y>=95). Team ink + * presence here separates alive icons from the grey/dark splatted ones. + * Sized to cover most of the icon: a slimmer box left alive icons whose + * body is largely weapon silhouette/badges reading ink 0.24 while stage + * ink bleeding around a translucent dead icon read 0.23 (AREA CUP VoD) — + * this footprint separates them at 0.26 vs 0.20. + */ +export const STATUS_BODY_BOX_POV = { dx: -40, y: 40, w: 80, h: 50 }; +export const STATUS_BODY_BOX_CAST = { dx: -40, y: 45, w: 80, h: 50 }; + +/** + * An ink pixel: saturated and bright enough to be team color. The value + * floor keeps dark saturated stage backdrops (deep blue arena walls behind + * the translucent dead icons measure v<=90) from counting as ink. + */ +export const STATUS_INK_MIN_SPREAD = 70; +export const STATUS_INK_MIN_VALUE = 105; + +/** + * A glow pixel of the special-ready wash. 225 splits the attested ready + * shoulders (fractions >=0.40) from the brightest alive team color — POV + * lime peaks between 215 and 225 (glow fraction 0.97 at 215, 0.00 at 225). + */ +export const STATUS_GLOW_MIN_VALUE = 225; + +/** + * A pale pixel: bright but unsaturated, the ready wash across its whole + * pulse cycle. The wash PULSES — its trough dims below the glow floor + * (~190-220) while staying pale, so a trough frame reads no ink and no + * glow; without the pale class it is indistinguishable from a splat. + */ +export const STATUS_PALE_MIN_VALUE = 185; +export const STATUS_PALE_MAX_SPREAD = 70; + +/** + * Splatted: body ink under the floor (attested dead <=0.20 vs alive + * >=0.26 with the current body box) with two guards: shoulder glow keeps + * the bright ready wash out (glow >=0.40 vs dead <=0.03), body pale keeps + * the wash's dim pulse trough out (pale >=0.27 vs dead <=0.07). + */ +export const STATUS_DEAD_MAX_BODY_INK = 0.23; +export const STATUS_DEAD_MAX_SHOULDER_GLOW = 0.2; +export const STATUS_DEAD_MAX_BODY_PALE = 0.15; + +/** Special ready: shoulder glow past this (attested >=0.40 vs <=0.06). */ +export const STATUS_READY_MIN_SHOULDER_GLOW = 0.25; + +/** + * Special ready off the body when the shoulder misses the wash (pulse + * trough, or wash dimmed on compressed footage): pale-dominant body + * (attested ready >=0.40 vs alive <=0.25 — plain alive bodies show some + * pale from weapon-silhouette whites). + */ +export const STATUS_READY_MIN_BODY_PALE = 0.32; + +/** + * Cast-layout ready guard: the cast wash REPLACES the body's team ink + * (attested washed bodies <=0.36 — the top end is stage ink bleeding + * around the washed icon), so an ink-heavy body proves a bright + * shoulder/pale read is backdrop leaking past the icon edge — the + * spectator overhead view draws a badge-less strip whose left column sits + * ~12px off the cast centers, sliding the probes onto pale buildings and + * the team-color lead banner (attested leaks read body ink >=0.44). POV + * ready icons instead light up IN team color (attested ink up to 0.68), + * so the guard is cast-only. + */ +export const STATUS_READY_WASH_MAX_BODY_INK = 0.4; + +/** + * Layout scoring (see player-status.ts): per-slot decisiveness is the + * body-ink distance from the dead threshold, capped so one saturated slot + * cannot carry a misaligned geometry. The sticky margin is what the + * non-established layout must win the score by before a badge-less frame + * switches an established geometry. + */ +export const STATUS_LAYOUT_SCORE_CAP = 0.3; +export const STATUS_LAYOUT_STICKY_MARGIN = 0.04; + +/** + * An established layout only carries forward across reads this close in + * time — in-match reads land ~1s apart, while a longer silence means a new + * match (possibly new footage type) and the next frame picks fresh. + */ +export const STATUS_LAYOUT_STICKY_MAX_GAP_S = 30; + +// ---- strip weapon-icon evidence (the StripWeapons event) ---- +// +// Each slot draws the player's weapon render over its squid plate; the +// match builder aggregates sampled per-slot candidate rankings across a +// match to solve the strip-slot → scoreboard-row assignment +// (strip-weapons.ts). Calibrated on the sendou-triton VoD (cast geometry +// on 720p footage upscaled to canonical space). + +/** + * Weapon search window relative to a slot center: generous enough to hold + * the render at either strip geometry (the render measures ~65px on the + * cast strip, smaller on POV) without swallowing a neighbor slot's art. + */ +export const STRIP_WEAPON_BOX = { dx: -50, y: 20, w: 100, h: 100 }; + +/** + * Template render heights to try inside the window; the attested cast + * strip draws renders at ~55-70px depending on the weapon's aspect. + */ +export const STRIP_WEAPON_TEMPLATE_SIZES = [44, 52, 60, 68, 76] as const; + +/** + * The flat grey the knocked-out plate pixels become and templates are + * composited over — mid-grey, so both dark barrels and white bodies keep + * contrast against it. + */ +export const STRIP_WEAPON_TEMPLATE_BACKGROUND = 90; + +/** Ink floor for the NCC coverage penalty over that background. */ +export const STRIP_WEAPON_INK_THRESHOLD = 140; + +/** + * A plate pixel: saturated and bright (the plate is drawn in team ink), + * within the hue band of the region's modal saturated hue. The spread and + * value floors sit under the modal-vote floors (+15 in strip-weapons.ts) + * so the knockout reaches the plate's dimmer edge pixels the vote skips. + */ +export const STRIP_WEAPON_KNOCKOUT_MIN_SPREAD = 55; +export const STRIP_WEAPON_KNOCKOUT_MIN_VALUE = 90; +export const STRIP_WEAPON_MAX_PLATE_HUE_DIST = 30; + +/** + * Candidates kept per slot: single reads only rank the true weapon top-1 + * about half the time on attested footage, but it lands in the top 8 in + * enough reads for the cross-match aggregate to decide. + */ +export const STRIP_WEAPON_TOP_K = 8; + +/** + * Every Nth successful counter read samples the strip weapons: identities + * are fixed per match, ~1 read/s makes ~20 samples over a short match — + * attested to assign correctly — and the full-atlas NCC sweep is too + * heavy to run on every read. + */ +export const STRIP_WEAPON_SAMPLE_INTERVAL = 5; + +/** + * Cast-layout discriminator: the spectator HUD always draws white camera + * badges under the right team's icons; nothing fixed sits there on POV + * footage. All four probes must read white (bright AND unsaturated — + * bright sky is saturated cyan) to call the frame cast. The mirror set + * covers the cast-mirror arrangement's wide right badge pitch. + */ +export const STATUS_DPAD_PROBES: readonly Roi[] = [1105, 1180, 1256, 1332].map( + (cx) => ({ x: cx - 8, y: 102, w: 16, h: 16 }), +); +export const STATUS_DPAD_PROBES_MIRROR: readonly Roi[] = [ + 1110, 1207, 1303, 1401, +].map((cx) => ({ x: cx - 8, y: 102, w: 16, h: 16 })); +export const STATUS_WHITE_MIN_VALUE = 215; +export const STATUS_WHITE_MAX_SPREAD = 40; +export const STATUS_CAST_MIN_DPAD_WHITE = 0.35; diff --git a/app/features/scanner/core/detectors/objective/strip-weapons.ts b/app/features/scanner/core/detectors/objective/strip-weapons.ts new file mode 100644 index 000000000..b02209139 --- /dev/null +++ b/app/features/scanner/core/detectors/objective/strip-weapons.ts @@ -0,0 +1,178 @@ +/** + * StripWeapons: per-slot weapon-icon evidence off the same icon strip the + * PlayerStatus read classifies, emitted by the ObjectiveDetector on a + * sampled cadence (identities are fixed for a match, so every read would be + * waste). The results scoreboard re-sorts each team per game while the + * strip keeps the lobby seating (attested in the sendou-triton VoD: strip + * [Planetz, .52, Neo Splash, Snipewriter] vs scoreboard rows + * [.52, Neo Splash, Snipewriter, Planetz]), so status samples cannot be + * paired with scoreboard rows by position alone — the match builder + * aggregates these candidate lists across the match and solves the + * slot→row assignment against the scoreboard's weapons + * (slot-row-assignment.ts). + * + * A slot's icon is the weapon render over a team-ink squid plate; the + * plate (and scene bleeding through it — the plates are translucent) is + * what drowns template matching, so saturated pixels near the plate's + * modal hue are flattened to the template background before the NCC + * ranking. One read's top-1 is only right about half the time on attested + * footage — the value is in the aggregate, so the event carries a ranked + * candidate list per slot. Splatted slots grey the render out and are + * skipped rather than guessed. + */ +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { getCV, type Mat } from "../../cv"; +import { copyRoi } from "../../image"; +import { hueDistance, hueOf } from "../../ink-color"; +import { matchWeapon, type WeaponTemplate } from "../scoreboard/weapons"; +import type { DetectedEvent } from "../types"; +import type { PlayerStatusData, PlayerStatusLayout } from "./player-status"; +import { + STATUS_SLOT_CENTERS_CAST, + STATUS_SLOT_CENTERS_CAST_MIRROR, + STATUS_SLOT_CENTERS_POV, + STRIP_WEAPON_BOX, + STRIP_WEAPON_INK_THRESHOLD, + STRIP_WEAPON_KNOCKOUT_MIN_SPREAD, + STRIP_WEAPON_KNOCKOUT_MIN_VALUE, + STRIP_WEAPON_MAX_PLATE_HUE_DIST, + STRIP_WEAPON_TEMPLATE_BACKGROUND, + STRIP_WEAPON_TOP_K, +} from "./rois"; + +export const STRIP_WEAPONS_EVENT_TYPE = "StripWeapons"; + +export interface StripWeaponCandidate { + weaponId: MainWeaponId; + score: number; +} + +export interface StripWeaponsData { + /** match timer at the read, pairing it with the Objective/PlayerStatus events */ + time: number | null; + /** the icon-strip geometry the paired PlayerStatus read picked */ + layout: PlayerStatusLayout; + /** + * ranked weapon candidates per slot, [left team, right team], slots + * left-to-right as drawn; null = slot skipped (splatted icons grey the + * weapon render out) + */ + slots: [(StripWeaponCandidate[] | null)[], (StripWeaponCandidate[] | null)[]]; +} + +/** + * Match every alive slot's icon against the strip weapon templates. + * `status` is the PlayerStatus read off the same frame — its layout picks + * the slot centers and its dead flags pick which slots are worth reading. + */ +export function parseStripWeapons( + frame: Mat, + t: number, + status: PlayerStatusData, + templates: WeaponTemplate[], +): DetectedEvent { + const centers = slotCenters(status.layout); + const scores: number[] = []; + const slots = centers.map((sideCenters, side) => + sideCenters.map((cx, slot): StripWeaponCandidate[] | null => { + if (status.dead[side as 0 | 1][slot]) return null; + const candidates = matchSlot(frame, cx, templates); + if (candidates.length > 0) scores.push(candidates[0]!.score); + return candidates; + }), + ) as StripWeaponsData["slots"]; + + return { + type: STRIP_WEAPONS_EVENT_TYPE, + t, + // raw NCC peaks on attested footage sit ~0.4-0.6 even for correct + // reads; the aggregate assignment carries the reliability, so the + // event's own confidence only reflects that something matched at all + confidence: scores.length > 0 ? Math.max(...scores) : 0, + data: { + time: status.time, + layout: status.layout, + slots, + }, + }; +} + +function slotCenters( + layout: PlayerStatusLayout, +): readonly [readonly number[], readonly number[]] { + return layout === "pov" + ? STATUS_SLOT_CENTERS_POV + : layout === "cast" + ? STATUS_SLOT_CENTERS_CAST + : STATUS_SLOT_CENTERS_CAST_MIRROR; +} + +function matchSlot( + frame: Mat, + cx: number, + templates: WeaponTemplate[], +): StripWeaponCandidate[] { + const cv = getCV(); + const crop = copyRoi(frame, { + x: cx + STRIP_WEAPON_BOX.dx, + y: STRIP_WEAPON_BOX.y, + w: STRIP_WEAPON_BOX.w, + h: STRIP_WEAPON_BOX.h, + }); + const search = new cv.Mat(); + cv.cvtColor(crop, search, cv.COLOR_RGBA2RGB); + crop.delete(); + knockoutPlate(search); + const match = matchWeapon(search, templates, { + inkThreshold: STRIP_WEAPON_INK_THRESHOLD, + topN: STRIP_WEAPON_TOP_K, + }); + search.delete(); + return match.top.map((candidate) => ({ + weaponId: Number(candidate.id) as MainWeaponId, + score: candidate.score, + })); +} + +/** + * Flatten the squid plate out of the search region: the modal hue of the + * region's saturated pixels is the plate's team ink, and every pixel near + * that hue is replaced with the flat template background, leaving the + * weapon render (grey/white bodies and off-hue accents) to carry the NCC. + */ +function knockoutPlate(search: Mat): void { + const { data } = search; + const n = search.rows * search.cols; + const bins = new Array(36).fill(0); + for (let i = 0; i < n; i++) { + const r = data[i * 3]!; + const g = data[i * 3 + 1]!; + const b = data[i * 3 + 2]!; + const value = Math.max(r, g, b); + const spread = value - Math.min(r, g, b); + if ( + spread >= STRIP_WEAPON_KNOCKOUT_MIN_SPREAD + 15 && + value >= STRIP_WEAPON_KNOCKOUT_MIN_VALUE + 15 + ) { + bins[Math.floor(hueOf({ r, g, b }) / 10)]!++; + } + } + const plateHue = bins.indexOf(Math.max(...bins)) * 10 + 5; + for (let i = 0; i < n; i++) { + const r = data[i * 3]!; + const g = data[i * 3 + 1]!; + const b = data[i * 3 + 2]!; + const value = Math.max(r, g, b); + const spread = value - Math.min(r, g, b); + if ( + spread >= STRIP_WEAPON_KNOCKOUT_MIN_SPREAD && + value >= STRIP_WEAPON_KNOCKOUT_MIN_VALUE && + hueDistance(hueOf({ r, g, b }), plateHue) <= + STRIP_WEAPON_MAX_PLATE_HUE_DIST + ) { + data[i * 3] = STRIP_WEAPON_TEMPLATE_BACKGROUND; + data[i * 3 + 1] = STRIP_WEAPON_TEMPLATE_BACKGROUND; + data[i * 3 + 2] = STRIP_WEAPON_TEMPLATE_BACKGROUND; + } + } +} diff --git a/app/features/scanner/core/detectors/scoreboard/banner.ts b/app/features/scanner/core/detectors/scoreboard/banner.ts index 13f2fd718..c2715095a 100644 --- a/app/features/scanner/core/detectors/scoreboard/banner.ts +++ b/app/features/scanner/core/detectors/scoreboard/banner.ts @@ -231,10 +231,26 @@ export function isBetterRead( export interface TrailingDigitOptions { /** char floor a glyph must clear to count as a digit of the number */ minCharScore?: number; + /** + * lower floor for digits joining a run that another digit anchors at + * `minCharScore` — motion blur / compression can erode one digit of a + * genuine number below the main floor while its neighbor stays crisp. + * Defaults to `minCharScore` (no two-tier extension). + */ + extendMinScore?: number; /** min ink height as a fraction of the set height (drops labels, '+') */ minHeightRatio?: number; /** values above this are rejected as misreads */ maxValue?: number; + /** + * reject the read (null) when the char immediately left of the run sat + * within digit-gap distance but failed the floors — on a band that holds + * nothing but the number (objective counter plates), that char is a + * blur-mangled leading digit and the run is a truncated misread ("50" + * returning 0). Off for banner bands, where an adjacent label/burst + * letter legitimately borders the digits. + */ + rejectTruncated?: boolean; } /** @@ -250,30 +266,41 @@ export function trailingDigitRun( ): BannerScoreRead { const { minCharScore = DIGIT_MIN_CONF, + extendMinScore = minCharScore, minHeightRatio = DIGIT_MIN_HEIGHT_RATIO, maxValue = KO_MATCH_SCORE, + rejectTruncated = false, } = 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 fullHeight = (c: RecognizedChar) => + c.y1 - c.y0 >= set.height * minHeightRatio; + const isRunDigit = (c: RecognizedChar) => + c.score >= extendMinScore && fullHeight(c); const run: RecognizedChar[] = []; let i = raw.chars.length - 1; for (; i >= 0; i--) { const c = raw.chars[i]!; - if (!isScoreDigit(c)) break; + if (!isRunDigit(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 }; + if (!run.some((c) => c.score >= minCharScore)) { + 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]!)) { + const c = raw.chars[k]!; + if (c.score >= minCharScore && fullHeight(c)) { return { ...EMPTY_READ, reading: raw.text }; } } + if (rejectTruncated && i >= 0 && run[0]!.x0 - raw.chars[i]!.x1 <= maxGap) { + 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 }; diff --git a/app/features/scanner/core/detectors/scoreboard/index.ts b/app/features/scanner/core/detectors/scoreboard/index.ts index a7720def2..0e9ae892c 100644 --- a/app/features/scanner/core/detectors/scoreboard/index.ts +++ b/app/features/scanner/core/detectors/scoreboard/index.ts @@ -87,6 +87,12 @@ export interface ScoreboardRowDebug { export interface ScoreboardResources { weapons: WeaponTemplate[]; + /** + * Weapon renders prepared for the in-match icon strip (objective's + * StripWeapons evidence). Optional: without them the strip slot → + * scoreboard row assignment falls back to as-drawn order. + */ + stripWeapons?: WeaponTemplate[] | null; /** * Special-weapon silhouettes (assets/cv/specials). Optional: without * them, near-tied weapon icons (Splash- vs Sploosh-o-matic) stay decided diff --git a/app/features/scanner/core/detectors/scoreboard/weapons.ts b/app/features/scanner/core/detectors/scoreboard/weapons.ts index e4ee26767..59f8f1b4a 100644 --- a/app/features/scanner/core/detectors/scoreboard/weapons.ts +++ b/app/features/scanner/core/detectors/scoreboard/weapons.ts @@ -64,6 +64,7 @@ export interface WeaponTemplate { export interface WeaponMatch { id: string; score: number; + /** best candidates, most likely first (3 unless options.topN says more) */ top: { id: string; score: number }[]; /** true when a scoped/unscoped twin tie was resolved by the unscoped prior */ twinAmbiguous?: boolean; @@ -308,10 +309,11 @@ function coarseShortlist( export function matchWeapon( searchRgb: Mat, templates: WeaponTemplate[], - options: { inkThreshold?: number } = {}, + options: { inkThreshold?: number; topN?: number } = {}, ): WeaponMatch { const cv = getCV(); const inkThreshold = options.inkThreshold ?? INK_THRESHOLD; + const topN = options.topN ?? 3; // icon ink present in the search region (pixel access needs a copy) const cont = new cv.Mat(); @@ -348,7 +350,7 @@ export function matchWeapon( const ranked = [...best.entries()] .map(([id, score]) => ({ id, score })) .sort((a, b) => b.score - a.score); - const top = ranked.slice(0, 3); + const top = ranked.slice(0, topN); let first = top[0] ?? { id: "unknown", score: -1 }; let twinAmbiguous = false; const unscopedId = SCOPED_TWINS.get(first.id); @@ -360,7 +362,7 @@ export function matchWeapon( const i = top.findIndex((t) => t.id === twin.id); if (i >= 0) top.splice(i, 1); top.unshift(twin); - top.length = Math.min(top.length, 3); + top.length = Math.min(top.length, topN); } } return { id: first.id, score: first.score, top, twinAmbiguous }; diff --git a/app/features/scanner/core/ink-color.ts b/app/features/scanner/core/ink-color.ts new file mode 100644 index 000000000..ebe516717 --- /dev/null +++ b/app/features/scanner/core/ink-color.ts @@ -0,0 +1,85 @@ +/** + * Team ink color sampling and comparison. Splatoon renders each team's UI + * accents — counter plate fills and digits, minimap sub tiles — in the + * team's ink color, fixed for the whole game and picked to contrast with + * the opponent's. That makes ink hue a per-frame team identity signal + * where screen position is not one: casted footage reorders HUD sides to + * follow the currently specced player. + */ +import type { Mat } from "./cv"; +import { copyRoi, type Roi } from "./image"; + +export interface InkRgb { + r: number; + g: number; + b: number; +} + +/** Channel spread (max-min) a pixel needs to count as ink, not chrome. */ +const INK_MIN_SATURATION = 60; + +/** Fewer qualifying pixels than this = no reliable ink in the ROIs. */ +const MIN_INK_PIXELS = 30; + +/** + * Mean RGB of the ink-saturated pixels across the ROIs (channel spread at + * least `minSaturation`); null when fewer than MIN_INK_PIXELS qualify. + * Averaging only saturated pixels keeps dark/white surroundings (plate + * fills, digit cores, tile backgrounds) from washing the hue out. + */ +export function meanInkColor( + frame: Mat, + rois: readonly Roi[], + minSaturation: number = INK_MIN_SATURATION, +): InkRgb | null { + let r = 0; + let g = 0; + let b = 0; + let count = 0; + for (const roi of rois) { + const crop = copyRoi(frame, roi); + const { data } = crop; + const channels = crop.channels(); + for (let i = 0; i < data.length; i += channels) { + const pr = data[i]!; + const pg = data[i + 1]!; + const pb = data[i + 2]!; + const spread = Math.max(pr, pg, pb) - Math.min(pr, pg, pb); + if (spread < minSaturation) continue; + r += pr; + g += pg; + b += pb; + count++; + } + crop.delete(); + } + if (count < MIN_INK_PIXELS) return null; + return { + r: Math.round(r / count), + g: Math.round(g / count), + b: Math.round(b / count), + }; +} + +/** Hue angle of an ink color, degrees on the 0-360 color wheel. */ +export function hueOf(color: InkRgb): number { + const max = Math.max(color.r, color.g, color.b); + const min = Math.min(color.r, color.g, color.b); + if (max === min) return 0; + const d = max - min; + let h: number; + if (max === color.r) { + h = ((color.g - color.b) / d) % 6; + } else if (max === color.g) { + h = (color.b - color.r) / d + 2; + } else { + h = (color.r - color.g) / d + 4; + } + return (h * 60 + 360) % 360; +} + +/** Shortest angular distance between two hues, 0-180 degrees. */ +export function hueDistance(a: number, b: number): number { + const d = Math.abs(a - b) % 360; + return d > 180 ? 360 - d : d; +} diff --git a/app/features/scanner/core/match-builder.ts b/app/features/scanner/core/match-builder.ts index 10f4f1776..d4af8e88e 100644 --- a/app/features/scanner/core/match-builder.ts +++ b/app/features/scanner/core/match-builder.ts @@ -25,6 +25,15 @@ import { OBJECTIVE_EVENT_TYPE, type ObjectiveData, } from "./detectors/objective/index"; +import { + PLAYER_STATUS_EVENT_TYPE, + type PlayerStatusData, + type PlayerStatusFlags, +} from "./detectors/objective/player-status"; +import { + STRIP_WEAPONS_EVENT_TYPE, + type StripWeaponsData, +} from "./detectors/objective/strip-weapons"; import { SCOREBOARD_EVENT_TYPES } from "./detectors/registry"; import type { ScoreboardData } from "./detectors/scoreboard/index"; import { @@ -36,14 +45,24 @@ import { type ScoreboardBattleLogReplayData, } from "./detectors/scoreboard-battle-log-replay/index"; import type { DetectedEvent } from "./detectors/types"; +import { hueDistance, hueOf, type InkRgb } from "./ink-color"; import { parseReplayTimestamp } from "./replay-time"; import type { ScannerMatch, ScannerMatchObjective, ScannerMatchObjectiveSample, ScannerMatchPlayer, + ScannerMatchPlayerStatus, + ScannerMatchPlayerStatusSample, ScannerMatchTeam, } from "./scanner-match"; +import { + applyPermutation, + IDENTITY_PERMUTATION, + nameSlotRowPermutation, + type SlotRowPermutation, + weaponSlotRowPermutation, +} from "./slot-row-assignment"; /** The lobby header value private battles (tournament games) carry. */ const TOURNAMENT_LOBBY = "PRIVATE"; @@ -70,6 +89,47 @@ const PLAYERS_PER_TEAM = 4; */ const EARLY_END_MARGIN_SECONDS = 10; +/** + * The two team-ink hues must be at least this far apart before color is + * trusted to orient counter reads: a game's color pair is picked to + * contrast (attested pairs measure >130° apart), so a closer seed pair is + * a misread, and orientation falls back to the as-read arrangement. + */ +const MIN_TEAM_HUE_SEPARATION = 30; + +/** + * A counter read whose projected clock zero (`t + time`) sits further than + * this from the match's dominant projection was taken off a broadcast + * replay of another moment, not the live game. Live projections jitter by + * a couple of seconds (wall clock and match timer both round to whole + * seconds); attested replay wipes land a minute or more away. + */ +const REPLAY_ANCHOR_TOLERANCE_SECONDS = 10; + +/** + * Dead-flag runs that fit between their flanking opposite-state reads in + * less than these spans are physically impossible and get flipped to their + * surroundings: the fastest respawn in Splatoon is 3.5s, so no true dead + * stretch is shorter — while a respawned player CAN be re-splatted quickly + * (spawncamps are real), so the alive floor stays a conservative 2s and + * only clears blips like background ink bleeding through a crossed-out + * icon. Judging by the flank-to-flank span (the longest the state could + * truly have held) keeps sparse sampling honest: a lone dead read between + * far-apart reads spans wide and is left alone. + */ +const DEAD_RUN_MIN_SECONDS = 3.5; +const ALIVE_RUN_MIN_SECONDS = 2; + +/** + * A held special only goes away by being used (or by dying), and regaining + * one takes at least this long — no special charges off ~10s of painting + * even with max Special Charge Up. So an interior not-ready run flanked by + * ready reads closer together than this, with no death inside the run, is + * a misread gap (the ready wash pulses through a dim trough; overlays + * clip the icons) and is bridged to ready. + */ +const SPECIAL_REGAIN_MIN_SECONDS = 10; + export interface BuiltMatch { match: ScannerMatch; /** @@ -93,9 +153,11 @@ export function buildScannerMatches( const nextStage = buildNextStageMap(sorted); let open: OpenMatch | null = null; - // deaths/objective reads seen with no match open to anchor them yet + // deaths/objective/status reads seen with no match open to anchor them yet let orphanDeaths: E[] = []; let orphanObjectives: E[] = []; + let orphanPlayerStatuses: E[] = []; + let orphanStripWeapons: E[] = []; const finalize = (): void => { if (!open) return; if (open.scoreboard || open.minimaps.length > 0) { @@ -113,6 +175,8 @@ export function buildScannerMatches( vote(open.stageVotes, (event.data as MapStartData).stage); orphanDeaths = []; orphanObjectives = []; + orphanPlayerStatuses = []; + orphanStripWeapons = []; } else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) { if (!open) { open = startMatch(); @@ -122,12 +186,20 @@ export function buildScannerMatches( open.objectives = orphanObjectives.filter( (objective) => event.t - objective.t <= FALLBACK_WINDOW_SECONDS, ); + open.playerStatuses = orphanPlayerStatuses.filter( + (status) => event.t - status.t <= FALLBACK_WINDOW_SECONDS, + ); + open.stripWeapons = orphanStripWeapons.filter( + (read) => event.t - read.t <= FALLBACK_WINDOW_SECONDS, + ); } open.scoreboard = event; vote(open.stageVotes, (event.data as ScoreboardData).stage); finalize(); orphanDeaths = []; orphanObjectives = []; + orphanPlayerStatuses = []; + orphanStripWeapons = []; } else if (event.type === MINIMAP_EVENT_TYPE) { const stage = (event.data as MinimapData).stage; if (open) { @@ -153,6 +225,10 @@ export function buildScannerMatches( (open?.deaths ?? orphanDeaths).push(event); } else if (event.type === OBJECTIVE_EVENT_TYPE) { (open?.objectives ?? orphanObjectives).push(event); + } else if (event.type === PLAYER_STATUS_EVENT_TYPE) { + (open?.playerStatuses ?? orphanPlayerStatuses).push(event); + } else if (event.type === STRIP_WEAPONS_EVENT_TYPE) { + (open?.stripWeapons ?? orphanStripWeapons).push(event); } } finalize(); @@ -193,10 +269,12 @@ export function ingestSkipReasons( } /** - * Objective-counter reads that landed on a match whose detected mode is not - * Splat Zones — the SZ parser (the only one so far) misreading another - * mode's counter overlay. The builder already leaves such a match's - * `objective` null; callers should delete these events from their stores. + * Objective-counter and player-status reads that landed on a match whose + * detected mode is not Splat Zones — the SZ parser (the only one so far) + * misreading another mode's counter overlay, and the statuses that rode + * along with those misreads. The builder already leaves such a match's + * `objective`/`playerStatus` null; callers should delete these events from + * their stores. */ export function invalidObjectiveEvents( built: readonly BuiltMatch[], @@ -204,7 +282,12 @@ export function invalidObjectiveEvents( return built .filter((b) => b.match.mode !== null && b.match.mode !== "SZ") .flatMap((b) => - b.sources.filter((event) => event.type === OBJECTIVE_EVENT_TYPE), + b.sources.filter( + (event) => + event.type === OBJECTIVE_EVENT_TYPE || + event.type === PLAYER_STATUS_EVENT_TYPE || + event.type === STRIP_WEAPONS_EVENT_TYPE, + ), ); } @@ -273,6 +356,10 @@ interface OpenMatch { deaths: E[]; /** objective-counter reads; become the match's `objective` samples */ objectives: E[]; + /** icon-strip reads; become the match's `playerStatus` samples */ + playerStatuses: E[]; + /** sampled per-slot weapon evidence for the slot→row assignment */ + stripWeapons: E[]; scoreboard: E | null; /** * per-stage read counts (a MapStart's stage seeds it); the plurality @@ -290,6 +377,8 @@ function startMatch(): OpenMatch { minimaps: [], deaths: [], objectives: [], + playerStatuses: [], + stripWeapons: [], scoreboard: null, stageVotes: new Map(), lastMinimapT: null, @@ -339,6 +428,8 @@ function toBuiltMatch( ...open.minimaps, ...open.deaths, ...open.objectives, + ...open.playerStatuses, + ...open.stripWeapons, ...(open.scoreboard ? [open.scoreboard] : []), ].sort((a, b) => a.t - b.t); @@ -357,8 +448,34 @@ function toBuiltMatch( t: event.t, data: event.data as ObjectiveData, })); + const playerStatuses = open.playerStatuses.map((event) => ({ + t: event.t, + data: event.data as PlayerStatusData, + })); + const stripWeapons = open.stripWeapons.map((event) => ({ + t: event.t, + data: event.data as StripWeaponsData, + })); + const minimapReads = open.minimaps.map((event) => ({ + t: event.t, + data: event.data as MinimapData, + })); + const minimaps = minimapReads.map((read) => read.data); const mode = board?.mode ?? start?.mode ?? null; + // only the SZ counter is parsed — reads on a known other-mode match are + // misreads of a lookalike overlay, not progress data (statuses included: + // they only ever ride along with counter reads). Minimap card states are + // mode-agnostic, so they feed the status samples regardless + const counterModeValid = mode === null || mode === "SZ"; + const progress = buildProgress( + counterModeValid ? objectives : [], + counterModeValid ? playerStatuses : [], + counterModeValid ? stripWeapons : [], + minimapReads, + board, + minimapTeamColors(minimaps), + ); const match: ScannerMatch = { startsAt: @@ -372,17 +489,14 @@ function toBuiltMatch( ? board.matchScores : null, replayCode: timestamped?.replayCode ?? null, - cast: open.minimaps.some((event) => (event.data as MinimapData).spectator), - // only the SZ counter is parsed — reads on a known other-mode match - // are misreads of a lookalike overlay, not progress data - objective: - mode === null || mode === "SZ" ? buildObjective(objectives, board) : null, + cast: + open.minimaps.some((event) => (event.data as MinimapData).spectator) || + playerStatuses.some((read) => read.data.layout !== "pov"), + objective: progress.objective, + playerStatus: progress.playerStatus, teams: board ? teamsFromScoreboard(board, deaths) - : teamsFromMinimaps( - open.minimaps.map((event) => event.data as MinimapData), - deaths, - ), + : teamsFromMinimaps(minimaps, deaths), winner: board ? 0 : null, pov: board && board.povIndex !== null @@ -401,46 +515,702 @@ function floorOrNull(t: number | undefined): number | null { } /** - * The counter reads as `objective` samples in `teams` order. The on-screen - * plates put the POV/alpha side left, which already is teams[0] for a - * minimap-grouped match; a scoreboard-closed match's teams are winner-first, - * so the sides swap when the POV seat sat on the losing team — or, with no - * POV arrow read, when the right plate's count got lower (in SZ the winner - * is the team whose remaining count went furthest down; ties keep the order - * as read). + * The counter reads as `objective` samples and the icon-strip reads as + * `playerStatus` samples, both in `teams` order. On POV footage the left + * plate is the POV/alpha side for the whole game, but casted footage + * reorders the plates to follow the specced player — so each counter read + * is first oriented by its sides' team ink hues (clustered against the + * first read that saw both), making the series side-stable; a status read + * carries no ink of its own and inherits the orientation of the counter + * read nearest in time (they are emitted off the same frames). Broadcast + * replay wipes re-run an earlier moment with the whole HUD intact, so both + * series are anchored by their projected clock zero (`t + time`) against + * one shared dominant projection — the live game — and reads off it are + * dropped; timerless reads follow their preceding anchored neighbor. A + * displayed count never increases (it shows the team's best remaining), so + * each side's counter series then keeps only its longest non-increasing + * run of scores — surviving OCR blips are voided rather than charted. Both + * series then go into `teams` order: a scoreboard-closed match is + * winner-first (POV seat when read; else in SZ the winner is the side + * whose remaining count went furthest down), a minimap-grouped match + * anchors on the minimap's own/alpha-vs-enemy/bravo ink colors, and with + * no signal the first read's arrangement stands. A side's four slots keep + * their on-screen left-to-right order through a side swap — whether the + * game mirrors slot order across sides is unattested so far. + * + * Minimap reads contribute status samples too (their card cross-out and + * special-camo states), interleaved with the icon-strip reads on the same + * replay-wipe anchor (timerless, so each follows its anchored neighbor). + * Their sides are own/alpha-vs-enemy/bravo — camera-stable, unlike the + * plates — so they skip the per-read cluster orientation and map to + * `teams` through the same minimap ink anchor the whole match uses + * (identity on a minimap-grouped match by construction). + * + * Within a side, the strip's slot order is the lobby seating while a + * results scoreboard re-sorts its rows per game (attested in the + * sendou-triton VoD) — so on a scoreboard-closed match each side's slots + * are reordered into row order via the slot→row assignment + * (slot-row-assignment.ts): weapon votes from the sampled StripWeapons + * evidence plus the minimap's card columns, which mirror the strip's + * seating (attested for the enemy column; the spectator screen's own + * column is assumed symmetric). The POV overlay's teammate diamond + * follows neither order, so diamond-sourced flags map by card name + * instead, and keep their as-drawn order when too few names resolve. A + * minimap-grouped match's `teams` come from the cards themselves, so its + * samples stay as drawn by construction. */ -function buildObjective( +function buildProgress( objectives: readonly { t: number; data: ObjectiveData }[], + playerStatuses: readonly { t: number; data: PlayerStatusData }[], + stripWeapons: readonly { t: number; data: StripWeaponsData }[], + minimapReads: readonly { t: number; data: MinimapData }[], board: ScoreboardData | undefined, -): ScannerMatchObjective | null { - if (objectives.length === 0) return null; + minimapColors: [InkRgb | null, InkRgb | null] | null, +): { + objective: ScannerMatchObjective | null; + playerStatus: ScannerMatchPlayerStatus | null; +} { + const dominant = dominantAnchorOf([...objectives, ...playerStatuses]); + const live = withoutReplayReads(objectives, dominant); + const statusReads = [ + ...playerStatuses.map( + (read): StatusRead => ({ + t: read.t, + fromMinimap: false, + data: read.data, + }), + ), + ...minimapStatusReads(minimapReads), + ].sort((a, b) => a.t - b.t); + const liveStatuses = withoutReplayReads(statusReads, dominant); + + const clusterHues = seedClusterHues(live); + const swapFlags = readSwapFlags(live, clusterHues); + const oriented = withMonotonicScores(orientObjectives(live, swapFlags)); + const swap = board ? board.povIndex !== null ? board.povIndex >= PLAYERS_PER_TEAM - : bestCount(objectives, 1) < bestCount(objectives, 0) - : false; - const samples = objectives.map(({ t, data }): ScannerMatchObjectiveSample => { - const [a, b] = swap ? ([1, 0] as const) : ([0, 1] as const); + : bestCount(oriented, 1) < bestCount(oriented, 0) + : minimapAnchorSwap(clusterHues, minimapColors); + const minimapSwapped = swap !== minimapAnchorSwap(clusterHues, minimapColors); + + const perms = board + ? slotRowPermutations( + board, + stripWeapons, + minimapReads, + live, + swapFlags, + swap, + minimapSwapped, + ) + : null; + + const objective = + oriented.length === 0 + ? null + : { + mode: "SZ" as const, + samples: oriented.map((read): ScannerMatchObjectiveSample => { + const [a, b] = swap ? ([1, 0] as const) : ([0, 1] as const); + return { + t: Math.max(0, Math.floor(read.t)), + time: read.time, + score: [read.score[a], read.score[b]], + penalty: [read.penalty[a], read.penalty[b]], + control: [read.control[a], read.control[b]], + }; + }), + }; + + const playerStatus = + liveStatuses.length === 0 + ? null + : { + samples: withShortSpecialGapsBridged( + withImpossibleDeadRunsFlipped( + liveStatuses.map((read): ScannerMatchPlayerStatusSample => { + const swapped = read.fromMinimap + ? minimapSwapped + : nearestSwapFlag(live, swapFlags, read.t) !== swap; + const [a, b] = swapped ? ([1, 0] as const) : ([0, 1] as const); + const arrange = ( + flags: readonly [PlayerStatusFlags, PlayerStatusFlags], + ): [PlayerStatusFlags, PlayerStatusFlags] => + [a, b].map((source, side) => + applyPermutation( + flags[source]!, + readPermutation(perms, read, source, side as 0 | 1), + ), + ) as [PlayerStatusFlags, PlayerStatusFlags]; + return { + t: Math.max(0, Math.floor(read.t)), + time: read.data.time, + special: arrange(read.data.special), + dead: arrange(read.data.dead), + }; + }), + ), + ), + }; + + return { objective, playerStatus }; +} + +/** The slot→row permutations of a scoreboard-closed match, per source. */ +interface SlotRowPerms { + /** per teams side, for strip-seated slots (the strip and card columns) */ + strip: [SlotRowPermutation, SlotRowPermutation]; + /** for the POV diamond's teammate flags; null = keep as drawn */ + diamond: SlotRowPermutation | null; +} + +/** + * How much one minimap card's parsed weapon counts next to the strip + * evidence's raw NCC scores (~0.3-0.6 per candidate per read): the card + * parser is gated on a clean read, so one card outweighs a single strip + * sample without being able to drown a match's worth of them. + */ +const MINIMAP_CARD_VOTE = 1; + +/** + * Accumulate the match's weapon votes (sampled strip evidence oriented + * read-by-read like the status samples; minimap cards through the match's + * minimap anchor) and solve each side's slot→row assignment against the + * scoreboard's weapons, plus the diamond's name-based assignment for POV + * teammate cards. + */ +function slotRowPermutations( + board: ScoreboardData, + stripWeapons: readonly { t: number; data: StripWeaponsData }[], + minimapReads: readonly { t: number; data: MinimapData }[], + live: readonly { t: number; data: ObjectiveData }[], + swapFlags: readonly boolean[], + swap: boolean, + minimapSwapped: boolean, +): SlotRowPerms { + const votes: Map[][] = [0, 1].map(() => + [0, 1, 2, 3].map(() => new Map()), + ); + const addVote = ( + side: 0 | 1, + slot: number, + weaponId: MainWeaponId, + score: number, + ): void => { + const slotVotes = votes[side]![slot]!; + slotVotes.set(weaponId, (slotVotes.get(weaponId) ?? 0) + score); + }; + + for (const read of stripWeapons) { + const swapped = nearestSwapFlag(live, swapFlags, read.t) !== swap; + for (const side of [0, 1] as const) { + const source = swapped ? ((1 - side) as 0 | 1) : side; + for (const [slot, candidates] of read.data.slots[source].entries()) { + for (const candidate of candidates ?? []) { + addVote(side, slot, candidate.weaponId, candidate.score); + } + } + } + } + + // enemy cards mirror the strip seating (attested); the spectator + // screen's own column is assumed symmetric. The POV diamond is not + // strip-seated and votes for nothing. + const enemySide = minimapSwapped ? 0 : 1; + for (const read of minimapReads) { + for (const [slot, enemy] of read.data.enemies.entries()) { + if (enemy.weaponId !== null) { + addVote(enemySide, slot, enemy.weaponId, MINIMAP_CARD_VOTE); + } + } + if (!read.data.spectator) continue; + for (const [slot, mate] of read.data.teammates.entries()) { + if (mate.weaponId !== null) { + addVote( + (1 - enemySide) as 0 | 1, + slot, + mate.weaponId, + MINIMAP_CARD_VOTE, + ); + } + } + } + + const rowWeapons = (side: 0 | 1) => + board.players + .slice(side * PLAYERS_PER_TEAM, (side + 1) * PLAYERS_PER_TEAM) + .map((player) => player.weaponId); + const strip = [0, 1].map((side) => + weaponSlotRowPermutation(votes[side]!, rowWeapons(side as 0 | 1)), + ) as [SlotRowPermutation, SlotRowPermutation]; + + const friendlySide = minimapSwapped ? 1 : 0; + const cardNames: (string | null)[] = [null, null, null, null]; + for (const read of minimapReads) { + if (read.data.spectator) continue; + for (const [slot, mate] of read.data.teammates.entries()) { + cardNames[slot] ??= mate.name?.trim() || null; + } + } + const diamond = cardNames.some((name) => name !== null) + ? nameSlotRowPermutation( + cardNames, + board.players + .slice( + friendlySide * PLAYERS_PER_TEAM, + (friendlySide + 1) * PLAYERS_PER_TEAM, + ) + .map((player) => player.name.trim() || null), + ) + : null; + + return { strip, diamond }; +} + +/** + * Which permutation a status read's `sourceSide` flags go through on their + * way to teams side `side`: strip-seated sources (the strip itself, card + * columns) take the weapon-vote assignment, the POV diamond its name + * assignment; a minimap-grouped match (no perms) keeps everything as drawn. + */ +function readPermutation( + perms: SlotRowPerms | null, + read: StatusRead, + sourceSide: 0 | 1, + side: 0 | 1, +): SlotRowPermutation { + if (!perms) return IDENTITY_PERMUTATION; + if (read.fromMinimap && sourceSide === 0 && !read.spectator) { + return perms.diamond ?? IDENTITY_PERMUTATION; + } + return perms.strip[side]; +} + +/** + * Debounce per-slot dead flags across a match's samples: an interior run + * whose flanking opposite-state reads sit closer together than the state + * could truly have held (DEAD/ALIVE_RUN_MIN_SECONDS) is a misread blip + * (background ink bleeding through a translucent splatted icon, or a box + * over a mid-animation icon) and takes the flanking state instead. Edge + * runs stay — nothing attests what came before or after the match window. + */ +function withImpossibleDeadRunsFlipped( + samples: ScannerMatchPlayerStatusSample[], +): ScannerMatchPlayerStatusSample[] { + let smoothed = samples; + for (const side of [0, 1] as const) { + for (let slot = 0; slot < PLAYERS_PER_TEAM; slot++) { + // flipping one blip can expose the next (alternating flicker), so + // each slot's series is re-swept until it settles + let changed = true; + while (changed) { + changed = false; + const series = smoothed.map((sample) => sample.dead[side][slot]); + let runStart = 0; + for (let i = 1; i <= series.length; i++) { + if (i < series.length && series[i] === series[runStart]) continue; + const interior = runStart > 0 && i < series.length; + const impossiblyShort = + interior && + smoothed[i]!.t - smoothed[runStart - 1]!.t <= + (series[runStart] ? DEAD_RUN_MIN_SECONDS : ALIVE_RUN_MIN_SECONDS); + if (impossiblyShort) { + if (smoothed === samples) { + smoothed = samples.map((sample) => ({ + ...sample, + dead: [[...sample.dead[0]], [...sample.dead[1]]] as [ + PlayerStatusFlags, + PlayerStatusFlags, + ], + })); + } + for (let j = runStart; j < i; j++) { + smoothed[j]!.dead[side][slot] = !series[runStart]; + } + changed = true; + break; + } + runStart = i; + } + } + } + } + return smoothed; +} + +/** + * Bridge per-slot special-ready gaps: an interior not-ready run whose + * flanking ready reads sit closer together than a special could truly be + * regained (SPECIAL_REGAIN_MIN_SECONDS), with no death inside the run to + * explain the loss, is a misread gap (the ready wash pulses through a dim + * trough) and reads ready throughout. Short READY runs are left alone — a + * just-charged special really can be spent within a read or two. Edge + * runs stay too: nothing attests the state beyond the match window. + */ +function withShortSpecialGapsBridged( + samples: ScannerMatchPlayerStatusSample[], +): ScannerMatchPlayerStatusSample[] { + let bridged = samples; + for (const side of [0, 1] as const) { + for (let slot = 0; slot < PLAYERS_PER_TEAM; slot++) { + const series = samples.map((sample) => sample.special[side][slot]); + let runStart = 0; + for (let i = 1; i <= series.length; i++) { + if (i < series.length && series[i] === series[runStart]) continue; + const interiorGap = + !series[runStart] && runStart > 0 && i < series.length; + const impossiblyShort = + interiorGap && + samples[i]!.t - samples[runStart - 1]!.t < SPECIAL_REGAIN_MIN_SECONDS; + const diedInside = + interiorGap && + samples.slice(runStart, i).some((sample) => sample.dead[side][slot]); + if (impossiblyShort && !diedInside) { + if (bridged === samples) { + bridged = samples.map((sample) => ({ + ...sample, + special: [[...sample.special[0]], [...sample.special[1]]] as [ + PlayerStatusFlags, + PlayerStatusFlags, + ], + })); + } + for (let j = runStart; j < i; j++) { + bridged[j]!.special[side][slot] = true; + } + } + runStart = i; + } + } + } + return bridged; +} + +/** A status read from either source, sides as read (pre-orientation). */ +interface StatusRead { + t: number; + /** minimap sides are own/enemy — camera-stable, unlike the HUD plates */ + fromMinimap: boolean; + /** + * minimap reads only: the 8-card spectator screen, whose own-side + * column is card-seated like the enemy one — the POV overlay's + * teammate diamond is not (see readPermutation) + */ + spectator?: boolean; + data: { + time: number | null; + special: [PlayerStatusFlags, PlayerStatusFlags]; + dead: [PlayerStatusFlags, PlayerStatusFlags]; + }; +} + +/** + * The minimap reads' card/row states as status reads: own/alpha side + * first, slots in card order — the same order `teamsFromMinimaps` seats + * players — with absent cards padded false (nothing to chart, never a + * fabricated state). A read that saw no cards at all identifies nobody + * and contributes nothing. + */ +function minimapStatusReads( + minimapReads: readonly { t: number; data: MinimapData }[], +): StatusRead[] { + return minimapReads.flatMap((read): StatusRead[] => { + const { teammates, enemies } = read.data; + if (teammates.length === 0 && enemies.length === 0) return []; + return [ + { + t: read.t, + fromMinimap: true, + spectator: read.data.spectator, + data: { + time: null, + special: [ + sideFlags(teammates, "specialReady"), + sideFlags(enemies, "specialReady"), + ], + dead: [sideFlags(teammates, "dead"), sideFlags(enemies, "dead")], + }, + }, + ]; + }); +} + +function sideFlags( + players: readonly { dead: boolean; specialReady: boolean }[], + key: "dead" | "specialReady", +): PlayerStatusFlags { + return [0, 1, 2, 3].map( + (slot) => players[slot]?.[key] ?? false, + ) as PlayerStatusFlags; +} + +/** + * The cluster-orientation flag of the counter read nearest in time — + * status reads are emitted off the same frames as counter reads, so the + * nearest one saw the same camera arrangement. False when no counter read + * carried a usable flag (POV footage never swaps anyway). + */ +function nearestSwapFlag( + objectives: readonly { t: number }[], + swapFlags: readonly boolean[], + t: number, +): boolean { + let best = -1; + for (const [i, read] of objectives.entries()) { + if ( + best === -1 || + Math.abs(read.t - t) < Math.abs(objectives[best]!.t - t) + ) { + best = i; + } + } + return best === -1 ? false : (swapFlags[best] ?? false); +} + +/** A counter read with its sides in cluster (first-read) order. */ +interface OrientedObjectiveRead { + t: number; + time: number | null; + score: [number | null, number | null]; + penalty: [number | null, number | null]; + control: [boolean, boolean]; +} + +/** + * The two team-ink cluster hues, seeded from the first read that saw both + * sides' colors far enough apart; null when no read qualifies (color + * orientation then stays at the as-read arrangement). + */ +function seedClusterHues( + objectives: readonly { data: ObjectiveData }[], +): [number, number] | null { + for (const { data } of objectives) { + const [left, right] = data.teamColor; + if (left === null || right === null) continue; + const hues: [number, number] = [hueOf(left), hueOf(right)]; + if (hueDistance(hues[0], hues[1]) >= MIN_TEAM_HUE_SEPARATION) return hues; + } + return null; +} + +/** + * Per-read cluster assignment of the sides: a read whose ink hues sit + * closer to the clusters crosswise is swapped (the cast switched the + * specced side). Reads with no readable color inherit the previous read's + * orientation — plate arrangement only changes with a camera change, which + * leaves the colors readable once the plates are back. + */ +function readSwapFlags( + objectives: readonly { t: number; data: ObjectiveData }[], + clusterHues: [number, number] | null, +): boolean[] { + let previousSwapped = false; + return objectives.map(({ data }) => { + const swapped = clusterHues + ? readSwapped(data, clusterHues, previousSwapped) + : false; + previousSwapped = swapped; + return swapped; + }); +} + +/** The counter reads with their sides in cluster (first-read) order. */ +function orientObjectives( + objectives: readonly { t: number; data: ObjectiveData }[], + swapFlags: readonly boolean[], +): OrientedObjectiveRead[] { + return objectives.map(({ t, data }, i): OrientedObjectiveRead => { + const [a, b] = swapFlags[i] ? ([1, 0] as const) : ([0, 1] as const); return { - t: Math.max(0, Math.floor(t)), + t, time: data.time, score: [data.score[a], data.score[b]], penalty: [data.penalty[a], data.penalty[b]], control: [data.control[a], data.control[b]], }; }); - return { mode: "SZ", samples }; } -/** The lowest count a side's plate ever showed; Infinity when never read. */ +function readSwapped( + data: ObjectiveData, + clusterHues: [number, number], + previousSwapped: boolean, +): boolean { + const [left, right] = data.teamColor; + if (left === null && right === null) return previousSwapped; + const identityCost = + (left ? hueDistance(hueOf(left), clusterHues[0]) : 0) + + (right ? hueDistance(hueOf(right), clusterHues[1]) : 0); + const swappedCost = + (left ? hueDistance(hueOf(left), clusterHues[1]) : 0) + + (right ? hueDistance(hueOf(right), clusterHues[0]) : 0); + if (identityCost === swappedCost) return previousSwapped; + return swappedCost < identityCost; +} + +/** + * Whether the cluster order is bravo-first, judged against the minimap's + * ink colors (own/alpha column, enemy/bravo column) — the `teams` anchor + * for cast matches, which never see a results screen. + */ +function minimapAnchorSwap( + clusterHues: [number, number] | null, + minimapColors: [InkRgb | null, InkRgb | null] | null, +): boolean { + if (!clusterHues || !minimapColors) return false; + const [own, enemy] = minimapColors; + if (own === null && enemy === null) return false; + const identityCost = + (own ? hueDistance(hueOf(own), clusterHues[0]) : 0) + + (enemy ? hueDistance(hueOf(enemy), clusterHues[1]) : 0); + const swappedCost = + (own ? hueDistance(hueOf(own), clusterHues[1]) : 0) + + (enemy ? hueDistance(hueOf(enemy), clusterHues[0]) : 0); + return swappedCost < identityCost; +} + +/** + * Componentwise mean of the minimap reads' per-side ink colors; null when + * no read got a side's color (or there were no minimaps at all). + */ +function minimapTeamColors( + minimaps: readonly MinimapData[], +): [InkRgb | null, InkRgb | null] | null { + if (minimaps.length === 0) return null; + const sides = [0, 1].map((side): InkRgb | null => { + const colors = minimaps + .map((minimap) => minimap.teamColors[side as 0 | 1]) + .filter((color): color is InkRgb => color !== null); + if (colors.length === 0) return null; + return { + r: Math.round(colors.reduce((sum, c) => sum + c.r, 0) / colors.length), + g: Math.round(colors.reduce((sum, c) => sum + c.g, 0) / colors.length), + b: Math.round(colors.reduce((sum, c) => sum + c.b, 0) / colors.length), + }; + }) as [InkRgb | null, InkRgb | null]; + return sides[0] === null && sides[1] === null ? null : sides; +} + +/** + * The dominant clock-zero projection across every read that carried a + * timer; null when none did. Counter and status reads project the same + * live clock, so one shared anchor voids replay wipes from both series. + */ +function dominantAnchorOf( + reads: readonly { t: number; data: { time: number | null } }[], +): number | null { + const anchors = reads.flatMap((read) => + read.data.time !== null ? [read.t + read.data.time] : [], + ); + return anchors.length === 0 ? null : dominantAnchor(anchors); +} + +/** + * Drops reads taken off broadcast replay wipes: `t + time` projects the + * wall-clock moment the match timer reaches zero, which stays constant + * across a live game but lands far away when the broadcast re-runs an + * earlier moment, clock and all. Only reads near the dominant projection + * (the live series always outnumbers ~30s replay clips) are kept; a + * timerless read shares the fate of its preceding anchored neighbor (the + * following one for a timerless head), so an unreadable — or as yet + * unattested overtime — timer display never voids live reads. + */ +function withoutReplayReads< + T extends { t: number; data: { time: number | null } }, +>(reads: readonly T[], dominant: number | null): T[] { + if (dominant === null) return [...reads]; + const anchored = reads.flatMap((read, i) => + read.data.time !== null ? [{ i, anchor: read.t + read.data.time }] : [], + ); + if (anchored.length === 0) return [...reads]; + + const keptAnchored = new Map( + anchored.map(({ i, anchor }) => [ + i, + Math.abs(anchor - dominant) <= REPLAY_ANCHOR_TOLERANCE_SECONDS, + ]), + ); + + let previousKept = keptAnchored.get(anchored[0]!.i)!; + return reads.filter((_, i) => { + previousKept = keptAnchored.get(i) ?? previousKept; + return previousKept; + }); +} + +/** The clock-zero projection supported by the most reads within tolerance. */ +function dominantAnchor(anchors: readonly number[]): number { + const sorted = anchors.toSorted((a, b) => a - b); + let best = sorted[0]!; + let bestCount = 0; + let lo = 0; + for (let hi = 0; hi < sorted.length; hi++) { + while (sorted[hi]! - sorted[lo]! > REPLAY_ANCHOR_TOLERANCE_SECONDS) lo++; + if (hi - lo + 1 > bestCount) { + bestCount = hi - lo + 1; + best = sorted[Math.floor((lo + hi) / 2)]!; + } + } + return best; +} + +/** + * Voids score reads that contradict SZ's countdown: per side, only the + * longest non-increasing subsequence of the readable scores is kept and + * every read off it gets that side's score nulled (its penalty/control + * stand). A misread that slipped past the detector — a truncated "50" + * charted as a 0-dip, a stray 100 — is always the minority against the + * surrounding correct series, so it is what gets dropped. + */ +function withMonotonicScores( + oriented: readonly OrientedObjectiveRead[], +): OrientedObjectiveRead[] { + const smoothed = oriented.map((read) => ({ + ...read, + score: [...read.score] as [number | null, number | null], + })); + for (const side of [0, 1] as const) { + const readIndices = smoothed.flatMap((read, i) => + read.score[side] !== null ? [i] : [], + ); + const kept = longestNonIncreasingRun( + readIndices.map((i) => smoothed[i]!.score[side]!), + ); + for (const [k, i] of readIndices.entries()) { + if (!kept.has(k)) smoothed[i]!.score[side] = null; + } + } + return smoothed; +} + +/** Indices of one longest non-increasing subsequence of `values`. */ +function longestNonIncreasingRun(values: readonly number[]): Set { + const lengths = new Array(values.length).fill(1); + const prev = new Array(values.length).fill(-1); + let bestEnd = values.length > 0 ? 0 : -1; + for (let i = 0; i < values.length; i++) { + for (let j = 0; j < i; j++) { + if (values[j]! >= values[i]! && lengths[j]! + 1 > lengths[i]!) { + lengths[i] = lengths[j]! + 1; + prev[i] = j; + } + } + if (lengths[i]! > lengths[bestEnd]!) bestEnd = i; + } + const kept = new Set(); + for (let i = bestEnd; i !== -1; i = prev[i]!) kept.add(i); + return kept; +} + +/** The lowest count a side ever showed; Infinity when never read. */ function bestCount( - objectives: readonly { data: ObjectiveData }[], + oriented: readonly OrientedObjectiveRead[], side: 0 | 1, ): number { return Math.min( - ...objectives.map( - ({ data }) => data.score[side] ?? Number.POSITIVE_INFINITY, - ), + ...oriented.map((read) => read.score[side] ?? Number.POSITIVE_INFINITY), ); } diff --git a/app/features/scanner/core/resources.ts b/app/features/scanner/core/resources.ts index b94dfe8bc..3e7a9b752 100644 --- a/app/features/scanner/core/resources.ts +++ b/app/features/scanner/core/resources.ts @@ -27,6 +27,11 @@ import { SUB_TILE_TEMPLATE_SIZES, } from "./detectors/minimap/rois"; import type { PlannerStage } from "./detectors/minimap/stage"; +import { + STRIP_WEAPON_INK_THRESHOLD, + STRIP_WEAPON_TEMPLATE_BACKGROUND, + STRIP_WEAPON_TEMPLATE_SIZES, +} from "./detectors/objective/rois"; import type { ScoreboardResources } from "./detectors/scoreboard/index"; import { prepareSpecialTemplates } from "./detectors/scoreboard/specials"; import { prepareWeaponTemplates } from "./detectors/scoreboard/weapons"; @@ -132,6 +137,13 @@ export async function assembleScoreboardResources( cropToArt: true, }), ); + const stripWeapons = lazy(() => + prepareWeaponTemplates(weaponIcons, STRIP_WEAPON_TEMPLATE_SIZES, { + background: STRIP_WEAPON_TEMPLATE_BACKGROUND, + inkThreshold: STRIP_WEAPON_INK_THRESHOLD, + cropToArt: true, + }), + ); const specials = lazy(() => prepareSpecialTemplates(specialIcons)); const minimapSubWeapons = lazy(() => prepareSpecialTemplates(subIcons, SUB_TILE_TEMPLATE_SIZES), @@ -155,6 +167,9 @@ export async function assembleScoreboardResources( get minimapLightWeapons() { return minimapLightWeapons(); }, + get stripWeapons() { + return stripWeapons(); + }, get specials() { return specials(); }, diff --git a/app/features/scanner/core/scanner-match.ts b/app/features/scanner/core/scanner-match.ts index adba07492..697cf017f 100644 --- a/app/features/scanner/core/scanner-match.ts +++ b/app/features/scanner/core/scanner-match.ts @@ -60,6 +60,33 @@ export interface ScannerMatchObjective { samples: ScannerMatchObjectiveSample[]; } +export type ScannerMatchPlayerFlags = [boolean, boolean, boolean, boolean]; + +export interface ScannerMatchPlayerStatusSample { + /** whole seconds into the video/stream the icon strip was read at */ + t: number; + /** + * seconds shown on the match timer at the read — the same key the + * objective samples carry, for charting both on one clock axis + */ + time: number | null; + /** special held per player, teams in `teams` order, slots in row order */ + special: [ScannerMatchPlayerFlags, ScannerMatchPlayerFlags]; + /** splatted per player, same arrangement */ + dead: [ScannerMatchPlayerFlags, ScannerMatchPlayerFlags]; +} + +/** + * Per-player special/death states over the match, read off the icon strip + * next to the objective counter. Chronological and deduped to state + * changes like the objective samples, with an unchanged state re-confirmed + * every ~6s — render longer sample gaps as unknown, not as a continued + * state. + */ +export interface ScannerMatchPlayerStatus { + samples: ScannerMatchPlayerStatusSample[]; +} + export interface ScannerMatch { /** whole seconds into the video/stream the match starts at */ startsAt: number | null; @@ -83,11 +110,18 @@ export interface ScannerMatch { /** spectator/casted footage (the 8-player spectator map screen was seen) */ cast: boolean; /** - * counter progress samples in `teams` order (the on-screen left plate is - * the POV/alpha side; the builder reorients when teams[0] is the other - * side); null when no counter was read + * counter progress samples in `teams` order — the builder tracks each + * side's team ink color, so casted footage's plate swaps (the plates + * follow the specced player) can't scramble the series; null when no + * counter was read */ objective: ScannerMatchObjective | null; + /** + * per-player special/death samples in `teams` order, oriented alongside + * the objective samples (a status read pairs with the counter read of + * its frame); null when the icon strip was never read + */ + playerStatus: ScannerMatchPlayerStatus | null; /** * on-screen order: scoreboard rows 0-3 are teams[0] (the winners), * minimap alpha/own side is teams[0] diff --git a/app/features/scanner/core/slot-row-assignment.ts b/app/features/scanner/core/slot-row-assignment.ts new file mode 100644 index 000000000..a80a1211f --- /dev/null +++ b/app/features/scanner/core/slot-row-assignment.ts @@ -0,0 +1,130 @@ +/** + * Strip-slot → scoreboard-row assignment. The in-match icon strip (and the + * minimap's card columns, which mirror it) keeps the lobby seating for the + * whole set, while the results scoreboard re-sorts each team per game — so + * per-slot status series can only be paired with scoreboard rows through + * identity evidence. Two kinds are combined: + * + * - weapon votes: per-slot candidate scores accumulated across a match's + * sampled StripWeapons reads (strip-weapons.ts) plus the minimap cards' + * parsed weapons. The best-scoring of the 24 possible slot→row + * assignments against the scoreboard's four weapons wins — the global + * constraint corrects slots whose own evidence is wrong or missing + * (attested: a slot with zero readable votes still lands right by + * elimination). + * - card names (the POV minimap's teammate diamond): matched directly + * against scoreboard row names. + * + * Ties resolve toward the fewest moved slots, so two rows sharing a weapon + * keep their as-drawn relative order, and thin evidence degrades to the + * as-drawn arrangement rather than a coin flip. + */ +import type { MainWeaponId } from "~/modules/in-game-lists/types"; + +/** A slot→row permutation: `perm[slot]` is the scoreboard row the slot feeds. */ +export type SlotRowPermutation = readonly [number, number, number, number]; + +export const IDENTITY_PERMUTATION: SlotRowPermutation = [0, 1, 2, 3]; + +/** + * Total accumulated vote score the winning assignment needs before it may + * reorder anything, and the lead it needs over the best differing + * assignment. Calibrated on the sendou-triton VoD, where correct + * assignments scored 10-33 with margins 2.2-5.3 over ~20 sampled reads; + * junk evidence (a strip geometry mispick, non-Splatoon lookalikes) + * spreads flat and fails the margin. + */ +const MIN_ASSIGNMENT_SCORE = 1.5; +const MIN_ASSIGNMENT_MARGIN = 0.75; + +/** All 24 permutations, fewest-moved-slots first (ties resolve to earlier). */ +const PERMUTATIONS: SlotRowPermutation[] = (() => { + const all: SlotRowPermutation[] = []; + for (const a of [0, 1, 2, 3]) + for (const b of [0, 1, 2, 3]) + for (const c of [0, 1, 2, 3]) + for (const d of [0, 1, 2, 3]) { + if (new Set([a, b, c, d]).size === 4) all.push([a, b, c, d]); + } + const displaced = (perm: SlotRowPermutation) => + perm.filter((row, slot) => row !== slot).length; + return all.sort((x, y) => displaced(x) - displaced(y)); +})(); + +/** + * The slot→row assignment best supported by one side's accumulated weapon + * votes, against that side's scoreboard row weapons. Falls back to the + * as-drawn order when the evidence is too thin or too close to call (see + * MIN_ASSIGNMENT_SCORE/MARGIN). + */ +export function weaponSlotRowPermutation( + votes: readonly ReadonlyMap[], + rowWeapons: readonly (MainWeaponId | null)[], +): SlotRowPermutation { + const scored = PERMUTATIONS.map((perm) => ({ + perm, + score: perm.reduce((sum, row, slot) => { + const weapon = rowWeapons[row]; + return sum + (weapon === null ? 0 : (votes[slot]?.get(weapon!) ?? 0)); + }, 0), + })); + let best = scored[0]!; + for (const candidate of scored) { + if (candidate.score > best.score) best = candidate; + } + if (best.score < MIN_ASSIGNMENT_SCORE) return IDENTITY_PERMUTATION; + const runnerUp = Math.max( + ...scored + .filter((candidate) => candidate.score < best.score) + .map((candidate) => candidate.score), + 0, + ); + if (best.score - runnerUp < MIN_ASSIGNMENT_MARGIN) { + return IDENTITY_PERMUTATION; + } + return best.perm; +} + +/** + * A card→row assignment from card names (the POV minimap's teammate + * diamond, whose order matches neither the strip nor the scoreboard): + * unique case-insensitive name matches place their cards, the leftovers + * keep their relative as-drawn order. Null — keep the as-drawn order — + * when fewer than two cards resolve, since a single hit cannot attest the + * arrangement is worth disturbing. + */ +export function nameSlotRowPermutation( + cardNames: readonly (string | null)[], + rowNames: readonly (string | null)[], +): SlotRowPermutation | null { + const normalized = (name: string | null) => + name?.trim().toLowerCase() || null; + const rows = rowNames.map(normalized); + const assignment: (number | null)[] = [null, null, null, null]; + const takenRows = new Set(); + let resolved = 0; + for (const [slot, cardName] of cardNames.map(normalized).entries()) { + if (cardName === null) continue; + const matches = rows.flatMap((row, i) => (row === cardName ? [i] : [])); + if (matches.length !== 1 || takenRows.has(matches[0]!)) continue; + assignment[slot] = matches[0]!; + takenRows.add(matches[0]!); + resolved++; + } + if (resolved < 2) return null; + const freeRows = [0, 1, 2, 3].filter((row) => !takenRows.has(row)); + for (const [slot, row] of assignment.entries()) { + if (row === null) assignment[slot] = freeRows.shift()!; + } + return assignment as unknown as SlotRowPermutation; +} + +/** `flags` rearranged so slot `i`'s value lands at `perm[i]`. */ +export function applyPermutation( + flags: readonly T[], + perm: SlotRowPermutation, +): T[] { + const out = [...flags] as T[]; + for (const [slot, row] of perm.entries()) out[row] = flags[slot]!; + return out; +} diff --git a/app/features/scanner/core/timeline/index.ts b/app/features/scanner/core/timeline/index.ts index c07c5a8cc..3de112f71 100644 --- a/app/features/scanner/core/timeline/index.ts +++ b/app/features/scanner/core/timeline/index.ts @@ -4,10 +4,19 @@ * highest-confidence version; events below a confidence floor are dropped. */ +import { + MINIMAP_EVENT_TYPE, + sameMinimapStatusData, +} from "../detectors/minimap/index"; import { OBJECTIVE_EVENT_TYPE, sameObjectiveData, } from "../detectors/objective/index"; +import { + PLAYER_STATUS_EVENT_TYPE, + samePlayerStatusData, +} from "../detectors/objective/player-status"; +import { STRIP_WEAPONS_EVENT_TYPE } from "../detectors/objective/strip-weapons"; import { SCOREBOARD_EVENT_TYPE } from "../detectors/scoreboard/index"; import { SCOREBOARD_BATTLE_LOG_EVENT_TYPE } from "../detectors/scoreboard-battle-log/index"; import { SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE } from "../detectors/scoreboard-battle-log-replay/index"; @@ -31,6 +40,12 @@ export interface TimelineOptions { sameEventDataByType: Record boolean>; /** events below this confidence are dropped */ minConfidence: number; + /** + * per-type confidence floor overrides: evidence-carrying events whose + * scores live on a different scale than parse confidences (raw NCC + * peaks) opt out of the shared floor + */ + minConfidenceByType: Record; } const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = { @@ -39,18 +54,34 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = { // of one death land within the window while consecutive deaths are outside; // players flick the map open for 1-3s and each open is a fresh sample // (slots read differently across opens), so minimap frames merge only - // within one open + // within one open — and a dead/special flip caught mid-open stays its + // own event via the content guard // objective counter reads repeat every check second; the content guard // below keeps every actual change while the window collapses static - // stretches into one event per state - mergeWindowByType: { Death: 8, Minimap: 5, [OBJECTIVE_EVENT_TYPE]: 10 }, + // stretches into one event per state. Player statuses can revisit an + // exact prior state no sooner than a respawn takes (~9s), so their + // window must stay under that + // strip weapon evidence is sampled every ~5s and consecutive samples are + // distinct evidence — only same-frame re-reads should collapse + mergeWindowByType: { + Death: 8, + [MINIMAP_EVENT_TYPE]: 5, + [OBJECTIVE_EVENT_TYPE]: 10, + [PLAYER_STATUS_EVENT_TYPE]: 5, + [STRIP_WEAPONS_EVENT_TYPE]: 2, + }, sameEventDataByType: { [SCOREBOARD_EVENT_TYPE]: sameScoreboardMatch, [SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE]: sameScoreboardMatch, [SCOREBOARD_BATTLE_LOG_EVENT_TYPE]: sameScoreboardMatch, + [MINIMAP_EVENT_TYPE]: sameMinimapStatusData, [OBJECTIVE_EVENT_TYPE]: sameObjectiveData, + [PLAYER_STATUS_EVENT_TYPE]: samePlayerStatusData, }, minConfidence: 0.6, + minConfidenceByType: { + [STRIP_WEAPONS_EVENT_TYPE]: 0, + }, }; export type TimelineAction = @@ -72,7 +103,10 @@ export class TimelineBuilder { } push(event: DetectedEvent): TimelineAction { - if (event.confidence < this.#options.minConfidence) { + const minConfidence = + this.#options.minConfidenceByType[event.type] ?? + this.#options.minConfidence; + if (event.confidence < minConfidence) { return { action: "dropped", reason: "low-confidence" }; } const window = diff --git a/app/features/scanner/node/fixtures.ts b/app/features/scanner/node/fixtures.ts index 8806eaa50..9c4ad8996 100644 --- a/app/features/scanner/node/fixtures.ts +++ b/app/features/scanner/node/fixtures.ts @@ -36,12 +36,16 @@ interface ExpectedPlayer { } interface ExpectedMinimapTeammate { - slot?: "up" | "left" | "right" | "self"; + slot?: "up" | "left" | "right" | "self" | "down"; name?: string | null; /** informational for the human corrector; tests compare weaponId */ weaponLabel?: string | null; weaponId?: MainWeaponId | null; abilities?: (AbilityWithUnknown | null)[]; + /** struck through with the respawn cross-out */ + dead?: boolean; + /** on the light camo surface of a charged special */ + specialReady?: boolean; } interface ExpectedMinimapEnemy { @@ -51,6 +55,10 @@ interface ExpectedMinimapEnemy { weaponLabel?: string | null; weaponId?: MainWeaponId | null; abilities?: (AbilityWithUnknown | null)[]; + /** struck through with the respawn cross-out */ + dead?: boolean; + /** on the light camo surface of a charged special */ + specialReady?: boolean; } interface ExpectedScoreboard { @@ -63,6 +71,8 @@ interface ExpectedScoreboard { | "MapStart" | "Minimap" | "Objective" + | "PlayerStatus" + | "StripWeapons" | "none"; data?: { lobby?: ScannerLobby; @@ -99,6 +109,19 @@ interface ExpectedScoreboard { penalty?: [number | null, number | null]; /** Objective only: which team currently holds the objective */ control?: [boolean, boolean]; + /** PlayerStatus only: special held per slot, [left team, right team] */ + special?: [boolean[], boolean[]]; + /** PlayerStatus only: splatted per slot, [left team, right team] */ + dead?: [boolean[], boolean[]]; + /** PlayerStatus + StripWeapons: which icon-strip geometry the frame shows */ + layout?: "pov" | "cast" | "cast-mirror"; + /** + * StripWeapons only: the true weapon per slot, [left team, right + * team], null = slot skipped (splatted icon). weaponLabels is + * informational for the human corrector. + */ + weapons?: [(MainWeaponId | null)[], (MainWeaponId | null)[]]; + weaponLabels?: [(string | null)[], (string | null)[]]; /** Minimap only: casted 8-player spectator map screen (not parsed yet) */ spectator?: boolean; /** Minimap only: own-team callout cards in slot order */ diff --git a/app/features/scanner/routes/scanner.tsx b/app/features/scanner/routes/scanner.tsx index 92f88cb3f..eae4db7c9 100644 --- a/app/features/scanner/routes/scanner.tsx +++ b/app/features/scanner/routes/scanner.tsx @@ -29,7 +29,9 @@ export const meta: MetaFunction = (args) => { // may be imported at route-module top level — only from inside this lazily // imported client component tree, after hydration. const ScannerApp = lazy(() => - import("~/features/scanner/components/App").then((m) => ({ default: m.App })), + import("~/features/scanner/components/ScannerApp").then((m) => ({ + default: m.ScannerApp, + })), ); export default function ScannerPage() { diff --git a/app/features/scanner/scanner-schemas.ts b/app/features/scanner/scanner-schemas.ts index 575bdcf01..5b39b9083 100644 --- a/app/features/scanner/scanner-schemas.ts +++ b/app/features/scanner/scanner-schemas.ts @@ -19,6 +19,7 @@ import type { ScannerMatch, ScannerMatchObjective, ScannerMatchPlayer, + ScannerMatchPlayerStatus, ScannerMatchTeam, } from "./core/scanner-match"; import { SCANNER_LOBBIES } from "./scanner-types"; @@ -72,6 +73,26 @@ const scannerMatchObjectiveSchema = z.object({ .max(MAX_OBJECTIVE_SAMPLES), }); +const playerFlagsSchema = z.tuple([ + z.boolean(), + z.boolean(), + z.boolean(), + z.boolean(), +]); + +const scannerMatchPlayerStatusSampleSchema = z.object({ + t: z.number().int().min(0), + time: z.number().int().min(0).nullable(), + special: z.tuple([playerFlagsSchema, playerFlagsSchema]), + dead: z.tuple([playerFlagsSchema, playerFlagsSchema]), +}); + +const scannerMatchPlayerStatusSchema = z.object({ + samples: z + .array(scannerMatchPlayerStatusSampleSchema) + .max(MAX_OBJECTIVE_SAMPLES), +}); + export const scannerMatchSchema = z.object({ startsAt: z.number().int().min(0).nullable(), endsAt: z.number().int().min(0).nullable(), @@ -86,6 +107,7 @@ export const scannerMatchSchema = z.object({ replayCode: detectionText.nullable(), cast: z.boolean(), objective: scannerMatchObjectiveSchema.nullable(), + playerStatus: scannerMatchPlayerStatusSchema.nullable(), teams: z.tuple([scannerMatchTeamSchema, scannerMatchTeamSchema]), winner: teamIndexSchema.nullable(), pov: z @@ -115,6 +137,10 @@ true satisfies MutuallyAssignable< z.infer, ScannerMatchObjective >; +true satisfies MutuallyAssignable< + z.infer, + ScannerMatchPlayerStatus +>; true satisfies MutuallyAssignable< z.infer, ScannerMatch diff --git a/app/features/scanner/scanner-search-params.test.ts b/app/features/scanner/scanner-search-params.test.ts index 56af67ec4..77b9f5456 100644 --- a/app/features/scanner/scanner-search-params.test.ts +++ b/app/features/scanner/scanner-search-params.test.ts @@ -10,6 +10,7 @@ describe("scannerSearchParams", () => { assertRoundTrips(scannerSearchParams, { tab: ["live", "screenshot", "vod"], inspect: ["1723456789012-abc123", null], + telemetry: [true, false], }); }); diff --git a/app/features/scanner/scanner-search-params.ts b/app/features/scanner/scanner-search-params.ts index fcb4a8b0e..1dcf0adc0 100644 --- a/app/features/scanner/scanner-search-params.ts +++ b/app/features/scanner/scanner-search-params.ts @@ -10,4 +10,9 @@ export const scannerSearchParams = SearchParams.define({ tab: SP.param(z.enum(SCANNER_TABS), { default: "live", loader: false }), /** Inspect handoff key: the screenshot tab claims this frame on load */ inspect: SP.param(z.string().max(100).nullable(), { loader: false }), + /** + * Opt-in scan telemetry: counters are accumulated and the panel is shown + * only when this is set by hand in the URL (no link points at it) + */ + telemetry: SP.param(z.boolean(), { default: false, loader: false }), }); diff --git a/app/features/scanner/scanner.module.css b/app/features/scanner/scanner.module.css new file mode 100644 index 000000000..77da46026 --- /dev/null +++ b/app/features/scanner/scanner.module.css @@ -0,0 +1,216 @@ +/* +Chrome shared by the scanner's pages and cards. Everything is scoped under +`.app`, whose container name the pages' container queries key off; design +tokens come from sendou.ink's app/styles/vars.css. +*/ + +.app { + container: scanner / inline-size; + + /* the app's own buttons, kept at :where() specificity so any component + class overrides it without having to win an ordering race */ + & :where(button) { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--s-1-5); + border: var(--border-style-accent); + border-radius: var(--radius-field); + appearance: none; + background: var(--color-text-accent); + color: var(--color-text-inverse); + cursor: pointer; + font-family: inherit; + font-size: var(--font-xs); + font-weight: var(--weight-bold); + padding: 0 var(--field-padding); + height: var(--field-size-sm); + white-space: nowrap; + user-select: none; + + & > svg { + width: 16px; + height: 16px; + } + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: 1px; + } + + &:active { + transform: translateY(1px); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + transform: initial; + } + } +} + +button.outlined { + background: transparent; + color: var(--color-text-accent); +} + +/* secondary actions live behind this icon-only trigger, pushed to the row's end. + Sized explicitly: it is a SendouButton, which brings a competing base look */ +button.iconMenu { + margin-inline-start: auto; + width: var(--field-size-sm); + height: var(--field-size-sm); + border-radius: var(--radius-field); + padding: 0; + border: var(--border-style); + background: var(--color-bg-higher); + color: var(--color-text); + + &:hover { + color: var(--color-text-accent); + } + + & > svg { + width: var(--field-size-icon); + height: var(--field-size-icon); + margin: 0; + } +} + +.controls { + display: flex; + gap: var(--s-2); + align-items: center; + margin-bottom: var(--s-3); + flex-wrap: wrap; +} + +.status { + padding: var(--s-0-5) var(--s-3); + border-radius: var(--radius-full); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); + border: var(--border-style); +} + +.idle { + color: var(--color-text-high); +} + +.watching { + color: var(--color-info-high); + border-color: var(--color-info-low); + background: var(--color-info-low); + + &::before { + content: ""; + display: inline-block; + width: 7px; + height: 7px; + border-radius: var(--radius-full); + background: currentColor; + margin-inline-end: var(--s-1-5); + animation: scanner-pulse 1.6s ease-in-out infinite; + } +} + +.detected { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--color-success-high); + border-color: var(--color-success-low); + background: var(--color-success-low); +} + +@keyframes scanner-pulse { + 50% { + opacity: 0.25; + transform: scale(0.8); + } +} + +.liveLayout { + display: grid; + grid-template-columns: minmax(320px, 640px) minmax(0, 1fr); + gap: var(--s-4); + align-items: start; +} + +.preview { + width: 100%; + background: #000; + border-radius: var(--radius-box); + border: var(--border-width) solid var(--color-bg-high); +} + +.feed { + display: flex; + flex-direction: column; + gap: var(--s-3); +} + +.score { + color: var(--color-text-high); + font-size: var(--font-2xs); +} + +.error { + color: var(--color-error); +} + +.dropzone { + border: var(--border-width) dashed var(--color-border-high); + border-radius: var(--radius-box); + padding: var(--s-8); + text-align: center; + color: var(--color-text-high); + font-size: var(--font-sm); + font-weight: var(--weight-semi); + margin-bottom: var(--s-4); + + & label { + display: inline; + font-size: inherit; + font-weight: inherit; + margin-block-end: 0; + text-decoration: underline; + cursor: pointer; + } +} + +.over { + border-color: var(--color-text-accent); + color: var(--color-text-accent); +} + +/* a 640px wide preview would starve the feed next to it: split evenly instead */ +@container scanner (width < 1000px) { + .liveLayout { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + + /* the controls wrap here, where a trigger pushed to the end reads as stray */ + button.iconMenu { + margin-inline-start: 0; + } +} + +@container scanner (width < 700px) { + .liveLayout { + grid-template-columns: minmax(0, 1fr); + } + + /* stacked, the preview would otherwise push the feed off the screen */ + .preview { + max-height: 45vh; + object-fit: contain; + } +} + +@media (prefers-reduced-motion: reduce) { + .watching::before { + animation: none; + } +} diff --git a/app/features/scanner/tests/dedupe-events.test.ts b/app/features/scanner/tests/dedupe-events.test.ts index 2d1baa665..5d97e84bb 100644 --- a/app/features/scanner/tests/dedupe-events.test.ts +++ b/app/features/scanner/tests/dedupe-events.test.ts @@ -24,16 +24,24 @@ function teammate( { name = null as string | null, abilities = [] as (AbilityWithUnknown | null)[], + dead = false, } = {}, ): MinimapTeammate { - return { slot: SPECTATOR_SLOTS[i]!, name, weaponId, abilities }; + return { + slot: SPECTATOR_SLOTS[i]!, + name, + weaponId, + abilities, + dead, + specialReady: false, + }; } function enemy( weaponId: MainWeaponId | null, { name = null as string | null } = {}, ): MinimapEnemy { - return { name, weaponId, abilities: [] }; + return { name, weaponId, abilities: [], dead: false, specialReady: false }; } function minimap( @@ -44,7 +52,13 @@ function minimap( enemies = BRAVO.map((id) => enemy(id)), } = {}, ): DetectedEvent { - const data: MinimapData = { stage, spectator: true, teammates, enemies }; + const data: MinimapData = { + stage, + spectator: true, + teammates, + enemies, + teamColors: [null, null], + }; return { type: "Minimap", t, confidence: 0.8, data }; } @@ -84,6 +98,16 @@ test("a changed ability read keeps both minimaps", () => { assert.equal(kept.length, 2); }); +test("a changed dead state keeps both minimaps", () => { + const kept = withoutRepeatEvents([ + minimap(70), + minimap(73, { + teammates: ALPHA.map((id, i) => teammate(id, i, { dead: i === 0 })), + }), + ]); + assert.equal(kept.length, 2); +}); + test("dedupes across interleaved events of other types, dropping none of them", () => { const kept = withoutRepeatEvents([minimap(70), death(72), minimap(74)]); assert.deepEqual( diff --git a/app/features/scanner/tests/fixtures/minimap/snix-special-ready/expected.json b/app/features/scanner/tests/fixtures/minimap/snix-special-ready/expected.json index 94c987e72..5103b084b 100644 --- a/app/features/scanner/tests/fixtures/minimap/snix-special-ready/expected.json +++ b/app/features/scanner/tests/fixtures/minimap/snix-special-ready/expected.json @@ -12,7 +12,9 @@ "SJ" ], "weaponLabel": "Splattershot", - "weaponId": 40 + "weaponId": 40, + "dead": false, + "specialReady": false }, { "slot": "left", @@ -23,7 +25,9 @@ "SJ" ], "weaponLabel": "Splatana Stamper", - "weaponId": 8000 + "weaponId": 8000, + "dead": false, + "specialReady": true }, { "slot": "right", @@ -34,7 +38,9 @@ "ISS" ], "weaponLabel": "Splattershot Jr.", - "weaponId": 10 + "weaponId": 10, + "dead": false, + "specialReady": false }, { "slot": "self", @@ -45,7 +51,9 @@ "SJ" ], "weaponLabel": "Custom Blaster", - "weaponId": 211 + "weaponId": 211, + "dead": false, + "specialReady": false } ], "enemies": [ @@ -56,7 +64,9 @@ "SJ" ], "weaponLabel": "Inkbrush Nouveau", - "weaponId": 1101 + "weaponId": 1101, + "dead": false, + "specialReady": true }, { "abilities": [ @@ -65,7 +75,9 @@ "SJ" ], "weaponLabel": ".52 Gal", - "weaponId": 50 + "weaponId": 50, + "dead": false, + "specialReady": false }, { "abilities": [ @@ -74,7 +86,9 @@ "SCU" ], "weaponLabel": "Big Swig Roller", - "weaponId": 1040 + "weaponId": 1040, + "dead": false, + "specialReady": true }, { "abilities": [ @@ -83,7 +97,9 @@ "SSU" ], "weaponLabel": "Luna Blaster Neo", - "weaponId": 201 + "weaponId": 201, + "dead": false, + "specialReady": false } ], "stageLabel": "Undertow Spillway" diff --git a/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-round1/expected.json b/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-round1/expected.json index 130827b3f..822c476a0 100644 --- a/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-round1/expected.json +++ b/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-round1/expected.json @@ -13,7 +13,9 @@ "SJ" ], "weaponLabel": "Splash-o-matic", - "weaponId": 20 + "weaponId": 20, + "dead": false, + "specialReady": false }, { "slot": "right", @@ -24,7 +26,9 @@ "SJ" ], "weaponLabel": "Dread Wringer", - "weaponId": 3050 + "weaponId": 3050, + "dead": false, + "specialReady": false }, { "slot": "down", @@ -35,7 +39,9 @@ "SJ" ], "weaponLabel": "Custom Blaster", - "weaponId": 211 + "weaponId": 211, + "dead": false, + "specialReady": false }, { "slot": "left", @@ -46,7 +52,9 @@ "ISM" ], "weaponLabel": "Snipewriter 5H", - "weaponId": 2070 + "weaponId": 2070, + "dead": false, + "specialReady": false } ], "enemies": [ @@ -58,7 +66,9 @@ "SJ" ], "weaponLabel": "Snipewriter 5H", - "weaponId": 2070 + "weaponId": 2070, + "dead": false, + "specialReady": false }, { "name": "ねこすき", @@ -68,7 +78,9 @@ "SJ" ], "weaponLabel": "S-BLAST '91", - "weaponId": 261 + "weaponId": 261, + "dead": false, + "specialReady": false }, { "name": "そっぴ", @@ -78,7 +90,9 @@ "SJ" ], "weaponLabel": "Zink Mini Splatling", - "weaponId": 4001 + "weaponId": 4001, + "dead": false, + "specialReady": false }, { "name": "しゅーた", @@ -88,7 +102,9 @@ "SJ" ], "weaponLabel": ".52 Gal", - "weaponId": 50 + "weaponId": 50, + "dead": false, + "specialReady": false } ], "stageLabel": "Manta Maria" diff --git a/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-winners-qf/expected.json b/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-winners-qf/expected.json index fd171e31a..8e6b2e75a 100644 --- a/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-winners-qf/expected.json +++ b/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-winners-qf/expected.json @@ -13,7 +13,9 @@ "ISM" ], "weaponLabel": "Snipewriter 5H", - "weaponId": 2070 + "weaponId": 2070, + "dead": false, + "specialReady": false }, { "slot": "right", @@ -24,7 +26,9 @@ "SSU" ], "weaponLabel": "Splash-o-matic", - "weaponId": 20 + "weaponId": 20, + "dead": false, + "specialReady": false }, { "slot": "down", @@ -35,7 +39,9 @@ "SJ" ], "weaponLabel": "Custom Blaster", - "weaponId": 211 + "weaponId": 211, + "dead": false, + "specialReady": false }, { "slot": "left", @@ -46,7 +52,9 @@ "SJ" ], "weaponLabel": "Splatana Stamper", - "weaponId": 8000 + "weaponId": 8000, + "dead": false, + "specialReady": false } ], "enemies": [ @@ -58,7 +66,9 @@ "SJ" ], "weaponLabel": "Mint Decavitator", - "weaponId": 8020 + "weaponId": 8020, + "dead": false, + "specialReady": false }, { "name": "たなは", @@ -68,7 +78,9 @@ "SCU" ], "weaponLabel": "Splat Brella", - "weaponId": 6000 + "weaponId": 6000, + "dead": false, + "specialReady": false }, { "name": "いなてんきんぐ", @@ -78,7 +90,9 @@ "RSU" ], "weaponLabel": "Zink Mini Splatling", - "weaponId": 4001 + "weaponId": 4001, + "dead": false, + "specialReady": false }, { "name": "そふ", @@ -88,7 +102,9 @@ "RES" ], "weaponLabel": "Heavy Splatling", - "weaponId": 4010 + "weaponId": 4010, + "dead": false, + "specialReady": false } ], "stageLabel": "Mincemeat Metalworks" diff --git a/app/features/scanner/tests/fixtures/minimap/spectator-pxg/expected.json b/app/features/scanner/tests/fixtures/minimap/spectator-pxg/expected.json index 8add5bbcd..6af9551f5 100644 --- a/app/features/scanner/tests/fixtures/minimap/spectator-pxg/expected.json +++ b/app/features/scanner/tests/fixtures/minimap/spectator-pxg/expected.json @@ -13,7 +13,9 @@ "SJ" ], "weaponLabel": ".96 Gal", - "weaponId": 80 + "weaponId": 80, + "dead": false, + "specialReady": false }, { "slot": "right", @@ -24,7 +26,9 @@ "ISM" ], "weaponLabel": "Flingza Roller", - "weaponId": 1030 + "weaponId": 1030, + "dead": false, + "specialReady": false }, { "slot": "down", @@ -35,7 +39,9 @@ "SJ" ], "weaponLabel": "Order Slosher Replica", - "weaponId": 3005 + "weaponId": 3005, + "dead": false, + "specialReady": false }, { "slot": "left", @@ -46,7 +52,9 @@ "ISM" ], "weaponLabel": "Rapid Blaster Pro WNT-R", - "weaponId": 252 + "weaponId": 252, + "dead": false, + "specialReady": false } ], "enemies": [ @@ -58,7 +66,9 @@ "SJ" ], "weaponLabel": "S-BLAST '91", - "weaponId": 261 + "weaponId": 261, + "dead": false, + "specialReady": false }, { "name": "バッラスト", @@ -68,7 +78,9 @@ "ISM" ], "weaponLabel": "Snipewriter 5H", - "weaponId": 2070 + "weaponId": 2070, + "dead": false, + "specialReady": false }, { "name": "Jared", @@ -78,7 +90,9 @@ "SJ" ], "weaponLabel": "Twinklez Splat Dualies", - "weaponId": 5012 + "weaponId": 5012, + "dead": false, + "specialReady": false }, { "name": "Reiyu C:3", @@ -88,7 +102,9 @@ "SJ" ], "weaponLabel": "Splash-o-matic", - "weaponId": 20 + "weaponId": 20, + "dead": false, + "specialReady": false } ], "stageLabel": "Crableg Capital" diff --git a/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/expected.json b/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/expected.json new file mode 100644 index 000000000..54926a645 --- /dev/null +++ b/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/expected.json @@ -0,0 +1,118 @@ +{ + "event": "Minimap", + "data": { + "stage": 1, + "spectator": true, + "teammates": [ + { + "slot": "up", + "name": "King Inate", + "abilities": [ + "LDE", + "RSU", + "RSU" + ], + "weaponLabel": "Zink Mini Splatling", + "weaponId": 4001, + "dead": false, + "specialReady": false + }, + { + "slot": "right", + "name": "todo", + "abilities": [ + "CB", + "SSU", + "SJ" + ], + "weaponLabel": "Mint Decavitator", + "weaponId": 8020, + "dead": false, + "specialReady": false + }, + { + "slot": "down", + "name": "tanaha", + "abilities": [ + "RSU", + "SSU", + "SCU" + ], + "weaponLabel": "Splat Brella", + "weaponId": 6000, + "dead": false, + "specialReady": false + }, + { + "slot": "left", + "name": "soph", + "abilities": [ + "RSU", + "RSU", + "SJ" + ], + "weaponLabel": "Heavy Splatling", + "weaponId": 4010, + "dead": false, + "specialReady": false + } + ], + "enemies": [ + { + "name": "Burstie", + "abilities": [ + "SSU", + "SSU", + "SSU" + ], + "weaponLabel": ".96 Gal", + "weaponId": 80, + "dead": false, + "specialReady": false + }, + { + "name": "[K]yo!", + "abilities": [ + "CB", + "QR", + "SJ" + ], + "weaponLabel": "Slosher", + "weaponId": 3000, + "dead": false, + "specialReady": false + }, + { + "name": "leafi !!", + "abilities": [ + "OG", + "SSU", + "SJ" + ], + "weaponLabel": "Custom Blaster", + "weaponId": 211, + "dead": false, + "specialReady": false + }, + { + "name": "biscuit", + "abilities": [ + "LDE", + "SCU", + "ISM" + ], + "weaponLabel": "Snipewriter 5H", + "weaponId": 2070, + "dead": false, + "specialReady": false + } + ], + "stageLabel": "Eeltail Alley" + }, + "options": { + "skipFields": [ + "teammates.0.name" + ], + "notes": "8-player spectator map screen from the SWS26 cast, same game as the objective/ splat-zones-cast-* fixtures: green team left column, purple right — the cross-detector anchor pair for objective team-color tracking. teammates.0.name skipped: King Inate reads King lnate — capital I and lowercase l are pixel-identical in BlitzMain at this size (the POV nameplate in the sibling frame confirms Inate)." + } +} diff --git a/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/frame.png b/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/frame.png new file mode 100644 index 000000000..7bdb9650c Binary files /dev/null and b/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/frame.png differ diff --git a/app/features/scanner/tests/fixtures/minimap/stonks-cross-both-teams/expected.json b/app/features/scanner/tests/fixtures/minimap/stonks-cross-both-teams/expected.json index 2b7ff4744..8d454070f 100644 --- a/app/features/scanner/tests/fixtures/minimap/stonks-cross-both-teams/expected.json +++ b/app/features/scanner/tests/fixtures/minimap/stonks-cross-both-teams/expected.json @@ -12,7 +12,9 @@ "SJ" ], "weaponLabel": "Tri-Stringer", - "weaponId": 7010 + "weaponId": 7010, + "dead": false, + "specialReady": false }, { "slot": "left", @@ -23,14 +25,18 @@ "SS" ], "weaponLabel": "Splattershot Jr.", - "weaponId": 10 + "weaponId": 10, + "dead": false, + "specialReady": false }, { "slot": "right", "name": null, "abilities": [], "weaponLabel": null, - "weaponId": null + "weaponId": null, + "dead": true, + "specialReady": false }, { "slot": "self", @@ -41,7 +47,9 @@ "SS" ], "weaponLabel": "Ballpoint Splatling", - "weaponId": 4030 + "weaponId": 4030, + "dead": false, + "specialReady": false } ], "enemies": [ @@ -52,17 +60,23 @@ "IRU" ], "weaponLabel": "Slosher", - "weaponId": 3000 + "weaponId": 3000, + "dead": false, + "specialReady": false }, { "abilities": [], "weaponLabel": "Splat Roller", - "weaponId": 1010 + "weaponId": 1010, + "dead": true, + "specialReady": false }, { "abilities": [], "weaponLabel": "Dualie Squelchers", - "weaponId": 5030 + "weaponId": 5030, + "dead": true, + "specialReady": true }, { "abilities": [ @@ -71,7 +85,9 @@ "SS" ], "weaponLabel": "Splattershot", - "weaponId": 40 + "weaponId": 40, + "dead": false, + "specialReady": false } ], "stageLabel": "Museum d'Alfonsino" diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-left/expected.json b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-left/expected.json new file mode 100644 index 000000000..2840c881a --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-left/expected.json @@ -0,0 +1,13 @@ +{ + "event": "Objective", + "data": { + "mode": "SZ", + "time": 262, + "score": [50, 100], + "penalty": [null, null], + "control": [true, false] + }, + "options": { + "notes": "Casted stream (Splat World Series 2026 playoffs) mid-fight: lime team in control, white '50' on the bright lime fill blurred by stream compression — the leading '5' scored under the main digit floor and the read truncated to 0 before the extension floor existed." + } +} diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-left/frame.png b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-left/frame.png new file mode 100644 index 000000000..eac4244dd Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-left/frame.png differ diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-right/expected.json b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-right/expected.json new file mode 100644 index 000000000..0c719c6a7 --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-right/expected.json @@ -0,0 +1,13 @@ +{ + "event": "Objective", + "data": { + "mode": "SZ", + "time": 249, + "score": [100, 29], + "penalty": [null, null], + "control": [false, true] + }, + "options": { + "notes": "Same cast game seconds later after a camera swap put the lime team's plate on the right: white '29' on the bright lime fill, leading '2' blur-eroded under the main digit floor (was truncated to 9). Gray-band noise on this frame matches digit templates at up to 0.66 — the anchor-digit rule must keep rejecting it." + } +} diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-right/frame.png b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-right/frame.png new file mode 100644 index 000000000..9c800bf14 Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-right/frame.png differ diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-cast-overhead-purple-right/expected.json b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-overhead-purple-right/expected.json new file mode 100644 index 000000000..5bfc07aac --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-overhead-purple-right/expected.json @@ -0,0 +1,13 @@ +{ + "event": "Objective", + "data": { + "mode": "SZ", + "time": 243, + "score": [49, 98], + "penalty": [39, null], + "control": [false, true] + }, + "options": { + "notes": "Casted stream (SWS26) overhead camera of the same game as the specced-purple-left fixture: default arrangement puts green left (black plate, green digits, +39 penalty pill) and purple right (in control, team-color fill)." + } +} diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-cast-overhead-purple-right/frame.png b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-overhead-purple-right/frame.png new file mode 100644 index 000000000..5a5d085a5 Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-overhead-purple-right/frame.png differ diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-cast-specced-purple-left/expected.json b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-specced-purple-left/expected.json new file mode 100644 index 000000000..6669d007b --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-specced-purple-left/expected.json @@ -0,0 +1,13 @@ +{ + "event": "Objective", + "data": { + "mode": "SZ", + "time": 248, + "score": [100, 49], + "penalty": [null, null], + "control": [false, false] + }, + "options": { + "notes": "Casted stream (SWS26) from a purple-team player's POV: the specced team's plate sits left, so purple (magenta digits) is side 0 and green side 1 — opposite arrangement to the overhead frame from the same game. Zone neutral: both plates near-black." + } +} diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-cast-specced-purple-left/frame.png b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-specced-purple-left/frame.png new file mode 100644 index 000000000..6214e9ae9 Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-specced-purple-left/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-badgeless-all-alive/expected.json b/app/features/scanner/tests/fixtures/player-status/cast-areacup-badgeless-all-alive/expected.json new file mode 100644 index 000000000..c6184e548 --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/cast-areacup-badgeless-all-alive/expected.json @@ -0,0 +1,18 @@ +{ + "event": "PlayerStatus", + "data": { + "layout": "cast", + "time": 258, + "special": [ + [false, false, false, false], + [false, false, false, false] + ], + "dead": [ + [false, false, false, false], + [false, false, false, false] + ] + }, + "options": { + "notes": "JP broadcast (AREA CUP) spectator HUD with the camera-button badges hidden — the strip keeps the cast icon geometry, so the layout must resolve to cast via the geometry score, not the badge probe (reading it as POV drifts the outer-slot boxes off their icons and flickers phantom deaths). All eight players alive." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-badgeless-all-alive/frame.png b/app/features/scanner/tests/fixtures/player-status/cast-areacup-badgeless-all-alive/frame.png new file mode 100644 index 000000000..c44f87833 Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/cast-areacup-badgeless-all-alive/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-false-splat-on-alive-outer-slot/expected.json b/app/features/scanner/tests/fixtures/player-status/cast-areacup-false-splat-on-alive-outer-slot/expected.json new file mode 100644 index 000000000..6ae6baf29 --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/cast-areacup-false-splat-on-alive-outer-slot/expected.json @@ -0,0 +1,18 @@ +{ + "event": "PlayerStatus", + "data": { + "layout": "cast", + "time": 255, + "special": [ + [true, false, false, false], + [false, false, false, false] + ], + "dead": [ + [false, false, false, false], + [true, false, true, false] + ] + }, + "options": { + "notes": "AREA CUP FINAL (grey-win VoD, Flounder Heights) spectator HUD at 4:15. Right team's outermost slot is a plain alive purple body tilted mid-bounce — the live scan read it as splatted (✕·✕✕ @ 0.749), manufacturing a third death for the .52 Gal whose real second death only starts three seconds later; one second after this frame the same body washes into special ready. Right slots 1 and 3 are genuinely splatted. Left team's outermost slot carries the bright special-ready wash." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-false-splat-on-alive-outer-slot/frame.png b/app/features/scanner/tests/fixtures/player-status/cast-areacup-false-splat-on-alive-outer-slot/frame.png new file mode 100644 index 000000000..56ea66dc6 Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/cast-areacup-false-splat-on-alive-outer-slot/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-gauge-overlay-outer-splat-dark/expected.json b/app/features/scanner/tests/fixtures/player-status/cast-areacup-gauge-overlay-outer-splat-dark/expected.json new file mode 100644 index 000000000..41cfc0336 --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/cast-areacup-gauge-overlay-outer-splat-dark/expected.json @@ -0,0 +1,18 @@ +{ + "event": "PlayerStatus", + "data": { + "layout": "cast", + "time": 250, + "special": [ + [false, false, false, false], + [false, false, false, false] + ], + "dead": [ + [false, false, false, false], + [true, false, false, true] + ] + }, + "options": { + "notes": "Same footage one second on (4:10). Right slot 3 has respawned (gauge back at 15) while the outermost slot's X is still up over the dark hull; the live scan read only slot 1 dead (✕··· @ 0.814), missing the outer X for a second consecutive read." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-gauge-overlay-outer-splat-dark/frame.png b/app/features/scanner/tests/fixtures/player-status/cast-areacup-gauge-overlay-outer-splat-dark/frame.png new file mode 100644 index 000000000..4e173aa61 Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/cast-areacup-gauge-overlay-outer-splat-dark/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-gauge-overlay-outer-splat-sky/expected.json b/app/features/scanner/tests/fixtures/player-status/cast-areacup-gauge-overlay-outer-splat-sky/expected.json new file mode 100644 index 000000000..ed9bf0ac1 --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/cast-areacup-gauge-overlay-outer-splat-sky/expected.json @@ -0,0 +1,18 @@ +{ + "event": "PlayerStatus", + "data": { + "layout": "cast", + "time": 251, + "special": [ + [false, false, false, false], + [false, false, false, false] + ], + "dead": [ + [false, false, false, false], + [true, false, true, true] + ] + }, + "options": { + "notes": "Same footage at 4:11, gauge-percentage spectator HUD. Right team slots 1, 3 and 4 are all splatted; the outermost X sits over a dark navy sky/structure backdrop and carries no gauge number, and the live scan missed it (✕·✕· @ 0.924), splitting the .52 Gal's single 7-second death into two runs. Left team all alive (window glare streaks the backdrop between the first icons)." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-gauge-overlay-outer-splat-sky/frame.png b/app/features/scanner/tests/fixtures/player-status/cast-areacup-gauge-overlay-outer-splat-sky/frame.png new file mode 100644 index 000000000..c6017d7f2 Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/cast-areacup-gauge-overlay-outer-splat-sky/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-gauge-overlay-ready-wash/expected.json b/app/features/scanner/tests/fixtures/player-status/cast-areacup-gauge-overlay-ready-wash/expected.json new file mode 100644 index 000000000..81fc74415 --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/cast-areacup-gauge-overlay-ready-wash/expected.json @@ -0,0 +1,18 @@ +{ + "event": "PlayerStatus", + "data": { + "layout": "cast", + "time": 253, + "special": [ + [false, false, false, false], + [false, false, false, true] + ], + "dead": [ + [false, false, false, false], + [true, false, true, false] + ] + }, + "options": { + "notes": "Same footage at 4:13, spectator HUD with special-gauge percentages above the icons and camera-button/D-pad badges below (85/72/49 left, 11/17/26 right; a splatted icon keeps its number). Right team's outermost slot is fully washed with special ready and shows no gauge number — the live scan never captured this ★ (the .52 Gal's brief special between its two deaths, user-confirmed) and neighboring reads called the slot dead instead. Left outer slot's wash is gone by this frame (special spent), despite its missing gauge number." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-gauge-overlay-ready-wash/frame.png b/app/features/scanner/tests/fixtures/player-status/cast-areacup-gauge-overlay-ready-wash/frame.png new file mode 100644 index 000000000..b03158393 Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/cast-areacup-gauge-overlay-ready-wash/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-mirror-gauge-overlay-false-star-after-respawn/expected.json b/app/features/scanner/tests/fixtures/player-status/cast-areacup-mirror-gauge-overlay-false-star-after-respawn/expected.json new file mode 100644 index 000000000..7ffcabbfa --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/cast-areacup-mirror-gauge-overlay-false-star-after-respawn/expected.json @@ -0,0 +1,18 @@ +{ + "event": "PlayerStatus", + "data": { + "layout": "cast-mirror", + "time": 124, + "special": [ + [false, false, false, true], + [false, false, false, false] + ], + "dead": [ + [false, false, false, false], + [false, false, true, false] + ] + }, + "options": { + "notes": "Same footage one second on (2:06:32, clock 2:04), player-cam view with gauge digits and camera badges — but in the MIRRORED cast arrangement (left '+' pads at [625,701,777,852], right A/B/Y/X at [1110,1207,1303,1401]; the standard badge probes miss them). The live scan read this frame as pov and emitted ·★·★: the ★ on left slot 1 is FALSE — the .52 Gal just respawned and shows gauge 29 — which is the user-reported dead/ready flicker. Left slot 3's wash IS real (pale cream body, no gauge digit; spent by the next second). Right slot 2 splatted (27 kept over the X). Labels authored by Claude from pixel evidence; the user-confirmed part is that left slot 1 held no special here." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-mirror-gauge-overlay-false-star-after-respawn/frame.png b/app/features/scanner/tests/fixtures/player-status/cast-areacup-mirror-gauge-overlay-false-star-after-respawn/frame.png new file mode 100644 index 000000000..bc678f04e Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/cast-areacup-mirror-gauge-overlay-false-star-after-respawn/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-mirror-gauge-overlay-ready-spent/expected.json b/app/features/scanner/tests/fixtures/player-status/cast-areacup-mirror-gauge-overlay-ready-spent/expected.json new file mode 100644 index 000000000..7823f878a --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/cast-areacup-mirror-gauge-overlay-ready-spent/expected.json @@ -0,0 +1,18 @@ +{ + "event": "PlayerStatus", + "data": { + "layout": "cast-mirror", + "time": 123, + "special": [ + [false, false, false, false], + [false, false, false, false] + ], + "dead": [ + [false, false, false, false], + [false, false, true, false] + ] + }, + "options": { + "notes": "Same footage at 2:06:33 (clock 2:03), mirrored player-cam view: left team all alive with gauges 47/31/3 — slot 3's wash from one second earlier is gone (special spent), slot 1 (.52 Gal) alive at 31. Right slot 2 still splatted. Guards the mirror arrangement against false ★/✕ on a clean frame. Labels authored by Claude from pixel evidence." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-mirror-gauge-overlay-ready-spent/frame.png b/app/features/scanner/tests/fixtures/player-status/cast-areacup-mirror-gauge-overlay-ready-spent/frame.png new file mode 100644 index 000000000..c21c7b95a Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/cast-areacup-mirror-gauge-overlay-ready-spent/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-mirror-overhead-splat-each-side/expected.json b/app/features/scanner/tests/fixtures/player-status/cast-areacup-mirror-overhead-splat-each-side/expected.json new file mode 100644 index 000000000..9d7308bf4 --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/cast-areacup-mirror-overhead-splat-each-side/expected.json @@ -0,0 +1,19 @@ +{ + "event": "PlayerStatus", + "data": { + "layout": "cast-mirror", + "time": 125, + "special": [ + [false, false, false, false], + [false, false, false, false] + ], + "dead": [ + [false, true, false, false], + [false, false, true, false] + ] + }, + "options": { + "skipFields": ["layout"], + "notes": "AREA CUP FINAL (grey-win VoD, Flounder Heights) overhead map view at 2:06:31, same game as the gauge-overlay fixtures but after the specced POV switched teams: the badge-less strip is drawn in the MIRRORED cast arrangement (narrow ~76 pitch left, wide ~97 pitch right — the mirror's right column nearly coincides with POV's). The live scan classified this stretch as pov, which happened to read this frame right but flipped false ★s one second later. Left slot 1 (the yellow team's .52 Gal) is genuinely splatted here — its real death ends with a respawn by the next second — and right slot 2 is splatted too; everyone else alive. layout is skipped: a lone badge-less mirror frame is not distinguishable from POV by decisiveness (that ambiguity is exactly the misread this fixture guards), so a fresh parse reads it as pov with identical states; live scans resolve the arrangement from neighboring badge frames via stickiness. Labels authored by Claude from pixel evidence." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-mirror-overhead-splat-each-side/frame.png b/app/features/scanner/tests/fixtures/player-status/cast-areacup-mirror-overhead-splat-each-side/frame.png new file mode 100644 index 000000000..3212e06fc Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/cast-areacup-mirror-overhead-splat-each-side/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-lead-banner-alive/expected.json b/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-lead-banner-alive/expected.json new file mode 100644 index 000000000..d1d9885ab --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-lead-banner-alive/expected.json @@ -0,0 +1,18 @@ +{ + "event": "PlayerStatus", + "data": { + "layout": "cast", + "time": 174, + "special": [ + [false, false, false, false], + [false, false, false, false] + ], + "dead": [ + [false, false, true, false], + [true, false, false, false] + ] + }, + "options": { + "notes": "AREA CUP FINAL (grey-win VoD) spectator overhead map view: badge-less, digit-less strip whose left column sits a nudge right of the cast centers. The left team's yellow リード! lead banner clips the first slot's shoulder probe and the saturated yellow ink lights the glow class — previously a false special on an alive, ink-heavy body. One dead per side (left slot 3 crossed out, right slot 1 crossed out). Note the read side order: the overhead view keeps yellow on the left while the player-cam frames of the same footage put the specced purple team there." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-lead-banner-alive/frame.png b/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-lead-banner-alive/frame.png new file mode 100644 index 000000000..5d2fb2174 Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-lead-banner-alive/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-pale-backdrop-alive/expected.json b/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-pale-backdrop-alive/expected.json new file mode 100644 index 000000000..58ba56a47 --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-pale-backdrop-alive/expected.json @@ -0,0 +1,18 @@ +{ + "event": "PlayerStatus", + "data": { + "layout": "cast", + "time": 161, + "special": [ + [false, false, false, false], + [false, false, false, false] + ], + "dead": [ + [false, false, false, true], + [false, false, false, false] + ] + }, + "options": { + "notes": "Same AREA CUP overhead map view (grey-win VoD), 13s on. The first left slot's probes hang past the E-liter icon's edge onto pale sunlit buildings — bright AND unsaturated, so both the shoulder glow and the body pale class fired on an alive icon whose own body still reads ink-heavy; user-confirmed no special held here. Left slot 4 is splatted; everyone else alive." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-pale-backdrop-alive/frame.png b/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-pale-backdrop-alive/frame.png new file mode 100644 index 000000000..20e7f061e Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-pale-backdrop-alive/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-ready-wash/expected.json b/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-ready-wash/expected.json new file mode 100644 index 000000000..1c298405a --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-ready-wash/expected.json @@ -0,0 +1,18 @@ +{ + "event": "PlayerStatus", + "data": { + "layout": "cast", + "time": 152, + "special": [ + [true, false, false, false], + [false, false, true, false] + ], + "dead": [ + [false, false, true, true], + [true, false, false, false] + ] + }, + "options": { + "notes": "Actually dead[1][2] should be true but it is not in proper view so accepted." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-ready-wash/frame.png b/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-ready-wash/frame.png new file mode 100644 index 000000000..365bf94e4 Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-ready-wash/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-wash-read-as-pov/expected.json b/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-wash-read-as-pov/expected.json new file mode 100644 index 000000000..79ca26b88 --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-wash-read-as-pov/expected.json @@ -0,0 +1,18 @@ +{ + "event": "PlayerStatus", + "data": { + "layout": "cast", + "time": 92, + "special": [ + [false, false, false, false], + [false, false, true, false] + ], + "dead": [ + [false, false, true, false], + [true, false, false, false] + ] + }, + "options": { + "notes": "AREA CUP FINAL (grey-win VoD) overhead map view at 1:32 — badge-less, digit-less strip with the リード! lead banner top-left and roster panels at the sides. The live scan classified this stretch as pov geometry (··✕· vs ✕··✕ (pov)), misaligning the slots: right slot 3's white special-ready wash went unread and the alive outermost slot got a false splat. Reads flip-flop pov/cast through this segment." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-wash-read-as-pov/frame.png b/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-wash-read-as-pov/frame.png new file mode 100644 index 000000000..f0c831259 Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/cast-areacup-overhead-wash-read-as-pov/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-ready-trough-two-dead/expected.json b/app/features/scanner/tests/fixtures/player-status/cast-areacup-ready-trough-two-dead/expected.json new file mode 100644 index 000000000..85d1dc33d --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/cast-areacup-ready-trough-two-dead/expected.json @@ -0,0 +1,18 @@ +{ + "event": "PlayerStatus", + "data": { + "layout": "cast", + "time": 256, + "special": [ + [true, false, false, false], + [false, false, false, false] + ], + "dead": [ + [false, false, false, false], + [true, false, true, false] + ] + }, + "options": { + "notes": "Same badge-less AREA CUP spectator HUD two seconds on. Left team's outermost slot holds special caught at the dim trough of the ready wash's pulse — pale but below the glow floor, previously misread as splatted. Right team's first and third slots are splatted (grey X'd icons)." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-ready-trough-two-dead/frame.png b/app/features/scanner/tests/fixtures/player-status/cast-areacup-ready-trough-two-dead/frame.png new file mode 100644 index 000000000..025db0d3a Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/cast-areacup-ready-trough-two-dead/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-splat-on-inked-backdrop/expected.json b/app/features/scanner/tests/fixtures/player-status/cast-areacup-splat-on-inked-backdrop/expected.json new file mode 100644 index 000000000..19254b110 --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/cast-areacup-splat-on-inked-backdrop/expected.json @@ -0,0 +1,18 @@ +{ + "event": "PlayerStatus", + "data": { + "layout": "cast", + "time": 248, + "special": [ + [false, false, true, false], + [false, false, false, false] + ], + "dead": [ + [false, false, false, false], + [false, false, false, true] + ] + }, + "options": { + "notes": "AREA CUP spectator HUD with the camera-button badges visible. Right team's outermost slot is splatted with the arena behind it covered in that team's ink color — the ink bleeding around the translucent crossed-out icon previously read as an alive body. Left team's third slot holds special (pale wash behind the icon)." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/cast-areacup-splat-on-inked-backdrop/frame.png b/app/features/scanner/tests/fixtures/player-status/cast-areacup-splat-on-inked-backdrop/frame.png new file mode 100644 index 000000000..5dc0634c9 Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/cast-areacup-splat-on-inked-backdrop/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/cast-sws26-three-dead-one-special/expected.json b/app/features/scanner/tests/fixtures/player-status/cast-sws26-three-dead-one-special/expected.json new file mode 100644 index 000000000..5ae34b12e --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/cast-sws26-three-dead-one-special/expected.json @@ -0,0 +1,18 @@ +{ + "event": "PlayerStatus", + "data": { + "layout": "cast", + "time": 214, + "special": [ + [false, false, false, false], + [false, false, true, false] + ], + "dead": [ + [false, false, false, true], + [true, false, false, true] + ] + }, + "options": { + "notes": "Casted stream (SWS26) spectator HUD: bigger icons with camera-button badges below and special gauge percentages above (52/56/54/66 left, 72/27 right — exact counts not parsed yet; a dead icon can keep its number on screen). Left team's slot nearest the timer and the right team's outer two slots are splatted (grey X'd icons); the right team's third slot glows white with special ready." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/cast-sws26-three-dead-one-special/frame.png b/app/features/scanner/tests/fixtures/player-status/cast-sws26-three-dead-one-special/frame.png new file mode 100644 index 000000000..d473fae48 Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/cast-sws26-three-dead-one-special/frame.png differ diff --git a/app/features/scanner/tests/fixtures/player-status/pov-wahoo-world-special-dead/expected.json b/app/features/scanner/tests/fixtures/player-status/pov-wahoo-world-special-dead/expected.json new file mode 100644 index 000000000..d53ab7424 --- /dev/null +++ b/app/features/scanner/tests/fixtures/player-status/pov-wahoo-world-special-dead/expected.json @@ -0,0 +1,18 @@ +{ + "event": "PlayerStatus", + "data": { + "layout": "pov", + "time": 23, + "special": [ + [true, false, false, false], + [false, false, true, true] + ], + "dead": [ + [false, false, false, true], + [false, false, false, false] + ] + }, + "options": { + "notes": "POV footage (720p), Wahoo World SZ. Slots left-to-right per side; left team's slot closest to the timer is splatted, its outermost slot holds special; right team holds special on its two outermost slots (icon bg below the weapon lights up in team color)." + } +} diff --git a/app/features/scanner/tests/fixtures/player-status/pov-wahoo-world-special-dead/frame.png b/app/features/scanner/tests/fixtures/player-status/pov-wahoo-world-special-dead/frame.png new file mode 100644 index 000000000..8af1e72a0 Binary files /dev/null and b/app/features/scanner/tests/fixtures/player-status/pov-wahoo-world-special-dead/frame.png differ diff --git a/app/features/scanner/tests/fixtures/strip-weapons/triton-mahi-allalive-4m33/expected.json b/app/features/scanner/tests/fixtures/strip-weapons/triton-mahi-allalive-4m33/expected.json new file mode 100644 index 000000000..36ad33739 --- /dev/null +++ b/app/features/scanner/tests/fixtures/strip-weapons/triton-mahi-allalive-4m33/expected.json @@ -0,0 +1,14 @@ +{ + "event": "StripWeapons", + "data": { + "layout": "cast", + "weapons": [ + [2070, 10, 211, 1010], + [1042, 50, 21, 2070] + ], + "weaponLabels": [ + ["Snipewriter 5H", "Splattershot Jr.", "Custom Blaster", "Splat Roller"], + ["Planetz Big Swig Roller", ".52 Gal", "Neo Splash-o-matic", "Snipewriter 5H"] + ] + } +} diff --git a/app/features/scanner/tests/fixtures/strip-weapons/triton-mahi-allalive-4m33/frame.png b/app/features/scanner/tests/fixtures/strip-weapons/triton-mahi-allalive-4m33/frame.png new file mode 100644 index 000000000..086d6431c Binary files /dev/null and b/app/features/scanner/tests/fixtures/strip-weapons/triton-mahi-allalive-4m33/frame.png differ diff --git a/app/features/scanner/tests/fixtures/strip-weapons/triton-mahi-allalive-4m59/expected.json b/app/features/scanner/tests/fixtures/strip-weapons/triton-mahi-allalive-4m59/expected.json new file mode 100644 index 000000000..288ce24e0 --- /dev/null +++ b/app/features/scanner/tests/fixtures/strip-weapons/triton-mahi-allalive-4m59/expected.json @@ -0,0 +1,18 @@ +{ + "event": "StripWeapons", + "data": { + "layout": "cast", + "weapons": [ + [2070, 10, 211, 1010], + [1042, 50, 21, 2070] + ], + "weaponLabels": [ + ["Snipewriter 5H", "Splattershot Jr.", "Custom Blaster", "Splat Roller"], + ["Planetz Big Swig Roller", ".52 Gal", "Neo Splash-o-matic", "Snipewriter 5H"] + ] + }, + "options": { + "skipFields": ["layout"], + "notes": "badge-less broadcast; in isolation the decisiveness score picks pov on this frame (sticky layout corrects it in a real scan)" + } +} diff --git a/app/features/scanner/tests/fixtures/strip-weapons/triton-mahi-allalive-4m59/frame.png b/app/features/scanner/tests/fixtures/strip-weapons/triton-mahi-allalive-4m59/frame.png new file mode 100644 index 000000000..0a5de1d2e Binary files /dev/null and b/app/features/scanner/tests/fixtures/strip-weapons/triton-mahi-allalive-4m59/frame.png differ diff --git a/app/features/scanner/tests/fixtures/strip-weapons/triton-mahi-twodead-4m17/expected.json b/app/features/scanner/tests/fixtures/strip-weapons/triton-mahi-twodead-4m17/expected.json new file mode 100644 index 000000000..74301477d --- /dev/null +++ b/app/features/scanner/tests/fixtures/strip-weapons/triton-mahi-twodead-4m17/expected.json @@ -0,0 +1,14 @@ +{ + "event": "StripWeapons", + "data": { + "layout": "cast", + "weapons": [ + [2070, 10, null, 1010], + [1042, 50, 21, null] + ], + "weaponLabels": [ + ["Snipewriter 5H", "Splattershot Jr.", "splatted (Custom Blaster)", "Splat Roller"], + ["Planetz Big Swig Roller", ".52 Gal", "Neo Splash-o-matic", "splatted (Snipewriter 5H)"] + ] + } +} diff --git a/app/features/scanner/tests/fixtures/strip-weapons/triton-mahi-twodead-4m17/frame.png b/app/features/scanner/tests/fixtures/strip-weapons/triton-mahi-twodead-4m17/frame.png new file mode 100644 index 000000000..71bda5bbf Binary files /dev/null and b/app/features/scanner/tests/fixtures/strip-weapons/triton-mahi-twodead-4m17/frame.png differ diff --git a/app/features/scanner/tests/match-builder.test.ts b/app/features/scanner/tests/match-builder.test.ts index e72973bcb..616a0b316 100644 --- a/app/features/scanner/tests/match-builder.test.ts +++ b/app/features/scanner/tests/match-builder.test.ts @@ -13,6 +13,8 @@ import type { } from "../core/detectors/minimap/index"; import { SPECTATOR_SLOTS } from "../core/detectors/minimap/rois"; import type { ObjectiveData } from "../core/detectors/objective/index"; +import type { PlayerStatusData } from "../core/detectors/objective/player-status"; +import type { StripWeaponsData } from "../core/detectors/objective/strip-weapons"; import type { ScoreboardData } from "../core/detectors/scoreboard/index"; import type { ScoreboardBattleLogData } from "../core/detectors/scoreboard-battle-log/index"; import type { ScoreboardBattleLogReplayData } from "../core/detectors/scoreboard-battle-log-replay/index"; @@ -51,16 +53,26 @@ function death( return { type: "Death", t, confidence: 0.9, data }; } +// default timer stays consistent with t (clock zero projected at 300s of +// footage) so reads register as one live game to the replay filter function objective( t: number, { - time = 215 as number | null, + time = (300 - Math.round(t)) as number | null, score = [95, 53] as [number | null, number | null], penalty = [null, null] as [number | null, number | null], control = [true, false] as [boolean, boolean], + teamColor = [null, null] as ObjectiveData["teamColor"], } = {}, ): DetectedEvent { - const data: ObjectiveData = { mode: "SZ", time, score, penalty, control }; + const data: ObjectiveData = { + mode: "SZ", + time, + score, + penalty, + control, + teamColor, + }; return { type: "Objective", t, confidence: 0.9, data }; } @@ -129,6 +141,8 @@ function teammate(weaponId: MainWeaponId | null, i: number): MinimapTeammate { name: null, weaponId, abilities: [], + dead: false, + specialReady: false, }; } @@ -137,6 +151,8 @@ function enemy(weaponId: MainWeaponId | null): MinimapEnemy { name: null, weaponId, abilities: [], + dead: false, + specialReady: false, }; } @@ -147,13 +163,25 @@ function minimap( alpha = ALPHA as (MainWeaponId | null)[], bravo = BRAVO as (MainWeaponId | null)[], spectator = true, + teamColors = [null, null] as MinimapData["teamColors"], + dead = [[], []] as [number[], number[]], + specialReady = [[], []] as [number[], number[]], } = {}, ): DetectedEvent { const data: MinimapData = { stage, spectator, - teammates: alpha.map(teammate), - enemies: bravo.map(enemy), + teammates: alpha.map((id, i) => ({ + ...teammate(id, i), + dead: dead[0].includes(i), + specialReady: specialReady[0].includes(i), + })), + enemies: bravo.map((id, i) => ({ + ...enemy(id), + dead: dead[1].includes(i), + specialReady: specialReady[1].includes(i), + })), + teamColors, }; return { type: "Minimap", t, confidence: 0.8, data }; } @@ -250,7 +278,7 @@ test("a losing-side pov swaps objective samples into teams order", () => { ]); assert.deepEqual(built[0]!.match.objective!.samples[0], { t: 120, - time: 215, + time: 180, score: [53, 95], penalty: [null, 4], control: [false, true], @@ -279,6 +307,76 @@ test("an unknown-mode match keeps its objective reads", () => { assert.deepEqual(invalidObjectiveEvents(built), []); }); +const GREEN_INK = { r: 146, g: 180, b: 96 }; +const PURPLE_INK = { r: 130, g: 43, b: 130 }; + +test("casted plate swaps are reoriented by team ink color", () => { + const built = buildScannerMatches([ + minimap(0, { teamColors: [GREEN_INK, PURPLE_INK] }), + objective(60, { + score: [80, 90], + control: [true, false], + teamColor: [GREEN_INK, PURPLE_INK], + }), + // the caster specs a purple player: purple's plate moves left + objective(120, { + score: [90, 75], + penalty: [4, null], + control: [true, false], + teamColor: [PURPLE_INK, GREEN_INK], + }), + // colors unreadable: the previous arrangement carries over + objective(125, { + score: [85, 75], + control: [true, false], + teamColor: [null, null], + }), + minimap(180), + ]); + assert.equal(built.length, 1); + const samples = built[0]!.match.objective!.samples; + assert.deepEqual( + samples.map((sample) => sample.score), + [ + [80, 90], + [75, 90], + [75, 85], + ], + ); + assert.deepEqual( + samples.map((sample) => sample.penalty), + [ + [null, null], + [null, 4], + [null, null], + ], + ); + assert.deepEqual( + samples.map((sample) => sample.control), + [ + [true, false], + [false, true], + [false, true], + ], + ); +}); + +test("minimap ink colors anchor a bravo-first cluster into teams order", () => { + const built = buildScannerMatches([ + minimap(0, { teamColors: [GREEN_INK, PURPLE_INK] }), + // every read had purple (bravo) on the left plate + objective(60, { + score: [90, 80], + control: [false, true], + teamColor: [PURPLE_INK, GREEN_INK], + }), + minimap(120), + ]); + const samples = built[0]!.match.objective!.samples; + assert.deepEqual(samples[0]!.score, [80, 90]); + assert.deepEqual(samples[0]!.control, [true, false]); +}); + test("without a pov the side whose count got lower is the winner side", () => { const built = buildScannerMatches([ mapStart(0), @@ -295,6 +393,91 @@ test("without a pov the side whose count got lower is the winner side", () => { ); }); +test("a transient score dip is voided against the surrounding countdown", () => { + const built = buildScannerMatches([ + mapStart(0), + objective(60, { score: [54, 100] }), + // truncated misread of "50" — only the trailing 0 was read + objective(61, { score: [0, 100], penalty: [4, null] }), + objective(62, { score: [49, 100] }), + objective(63, { score: [47, 100] }), + scoreboard(300), + ]); + const samples = built[0]!.match.objective!.samples; + assert.deepEqual( + samples.map((sample) => sample.score), + [ + [54, 100], + [null, 100], + [49, 100], + [47, 100], + ], + ); + assert.deepEqual(samples[1]!.penalty, [4, null]); +}); + +test("post-game replay wipes are dropped by their clock projection", () => { + const built = buildScannerMatches([ + mapStart(0), + objective(60, { score: [80, 6], penalty: [null, 56] }), + objective(61, { score: [78, 6], penalty: [null, 56] }), + objective(62, { score: [77, 6], penalty: [null, 56] }), + // broadcast re-runs the opening moments, clock jumped back to 4:51 + objective(90, { time: 291, score: [100, 100] }), + objective(91, { time: 290, score: [99, 100] }), + // then the closing moments again + objective(100, { time: 62, score: [6, 77], penalty: [56, null] }), + objective(101, { time: 61, score: [6, 75], penalty: [56, null] }), + scoreboard(300), + ]); + const samples = built[0]!.match.objective!.samples; + assert.deepEqual( + samples.map((sample) => sample.t), + [60, 61, 62], + ); + assert.deepEqual(samples.at(-1)!.penalty, [null, 56]); +}); + +test("timerless reads share their live neighbor's replay-filter fate", () => { + const built = buildScannerMatches([ + mapStart(0), + // timerless head inherits from the first anchored read + objective(59, { time: null, score: [82, 6] }), + objective(60, { score: [80, 6] }), + objective(61, { score: [79, 6] }), + objective(62, { time: null, score: [78, 6] }), + // replay wipe, including a timerless read inside it + objective(90, { time: 291, score: [100, 100] }), + objective(91, { time: null, score: [99, 100] }), + objective(92, { time: 289, score: [97, 100] }), + scoreboard(300), + ]); + assert.deepEqual( + built[0]!.match.objective!.samples.map((sample) => sample.t), + [59, 60, 61, 62], + ); +}); + +test("a stray full-count blip is voided against the surrounding countdown", () => { + const built = buildScannerMatches([ + mapStart(0), + objective(60, { score: [80, 100] }), + objective(61, { score: [100, 100] }), + objective(62, { score: [75, 100] }), + objective(63, { score: [73, 100] }), + scoreboard(300), + ]); + assert.deepEqual( + built[0]!.match.objective!.samples.map((sample) => sample.score), + [ + [80, 100], + [null, 100], + [75, 100], + [73, 100], + ], + ); +}); + test("enriches players with abilities from the match's deaths", () => { const build: AbilityWithUnknown[][] = [ ["ISM", "ISS", "ISS", "ISS"], @@ -655,3 +838,439 @@ test("a scoreboard is the preferred weapon/mode source and closes a match", () = test("no minimaps and no scoreboard means no match", () => { assert.deepEqual(buildScannerMatches([mapStart(30), mapStart(400)]), []); }); + +// ---- player-status samples ---- + +const ALL_FALSE = [ + [false, false, false, false], + [false, false, false, false], +] as PlayerStatusData["special"]; + +function playerStatus( + t: number, + { + time = (300 - Math.round(t)) as number | null, + special = ALL_FALSE, + dead = ALL_FALSE, + layout = "pov" as PlayerStatusData["layout"], + } = {}, +): DetectedEvent { + const data: PlayerStatusData = { time, special, dead, layout }; + return { type: "PlayerStatus", t, confidence: 0.9, data }; +} + +function stripWeaponsEvent( + t: number, + slots: [(MainWeaponId | null)[], (MainWeaponId | null)[]], + { score = 0.6, time = (300 - Math.round(t)) as number | null } = {}, +): DetectedEvent { + const data: StripWeaponsData = { + time, + layout: "cast", + slots: slots.map((side) => + side.map((weaponId) => + weaponId === null ? null : [{ weaponId, score }], + ), + ) as StripWeaponsData["slots"], + }; + return { type: "StripWeapons", t, confidence: score, data }; +} + +test("player-status reads become teams-order samples on the match", () => { + const special = [ + [true, false, false, false], + [false, false, false, false], + ] as PlayerStatusData["special"]; + const dead = [ + [false, false, false, false], + [false, false, true, false], + ] as PlayerStatusData["dead"]; + const built = buildScannerMatches([ + mapStart(0), + playerStatus(120, { special, dead }), + scoreboard(300), + ]); + assert.deepEqual(built[0]!.match.playerStatus, { + samples: [{ t: 120, time: 180, special, dead }], + }); +}); + +test("a losing-side pov swaps player-status samples into teams order", () => { + const built = buildScannerMatches([ + playerStatus(120, { + special: [ + [true, false, false, false], + [false, false, false, false], + ], + dead: [ + [false, false, false, false], + [false, true, false, false], + ], + }), + scoreboard(300, { povIndex: 5 }), + ]); + const sample = built[0]!.match.playerStatus!.samples[0]!; + assert.deepEqual(sample.special, [ + [false, false, false, false], + [true, false, false, false], + ]); + assert.deepEqual(sample.dead, [ + [false, true, false, false], + [false, false, false, false], + ]); +}); + +test("status reads inherit the nearest counter read's cast orientation", () => { + const built = buildScannerMatches([ + minimap(0, { teamColors: [GREEN_INK, PURPLE_INK] }), + objective(60, { + score: [80, 90], + teamColor: [GREEN_INK, PURPLE_INK], + }), + playerStatus(60, { + dead: [ + [true, false, false, false], + [false, false, false, false], + ], + layout: "cast", + }), + // the caster specs a purple player: sides swap + objective(120, { + score: [90, 75], + teamColor: [PURPLE_INK, GREEN_INK], + }), + playerStatus(120, { + dead: [ + [true, false, false, false], + [false, false, false, false], + ], + layout: "cast", + }), + minimap(180), + ]); + // the two minimap reads contribute their own (all-clear) samples + const samples = built[0]!.match.playerStatus!.samples; + assert.deepEqual( + samples.map((sample) => sample.t), + [0, 60, 120, 180], + ); + assert.deepEqual(samples[1]!.dead, [ + [true, false, false, false], + [false, false, false, false], + ]); + // the same on-screen left side is now the other team + assert.deepEqual(samples[2]!.dead, [ + [false, false, false, false], + [true, false, false, false], + ]); + assert.equal(built[0]!.match.cast, true); +}); + +test("sub-2s dead-flag blips between dense opposite reads get flipped", () => { + const deadAt = (slots: number[]) => + [ + [false, false, false, false], + [0, 1, 2, 3].map((slot) => slots.includes(slot)), + ] as PlayerStatusData["dead"]; + const built = buildScannerMatches([ + mapStart(0), + // slot0: a real death 101-107 with a one-read false "respawn" at 104 + // (background ink bleeding through the crossed-out icon), plus a + // one-read false death at 111 after the real respawn + playerStatus(100, { dead: deadAt([]) }), + playerStatus(101, { dead: deadAt([0]) }), + playerStatus(102, { dead: deadAt([0]) }), + playerStatus(103, { dead: deadAt([0]) }), + playerStatus(104, { dead: deadAt([]) }), + playerStatus(105, { dead: deadAt([0]) }), + playerStatus(106, { dead: deadAt([0]) }), + playerStatus(107, { dead: deadAt([0]) }), + playerStatus(108, { dead: deadAt([]) }), + playerStatus(109, { dead: deadAt([]) }), + playerStatus(110, { dead: deadAt([]) }), + playerStatus(111, { dead: deadAt([0]) }), + playerStatus(112, { dead: deadAt([]) }), + playerStatus(113, { dead: deadAt([]) }), + scoreboard(300), + ]); + const slot0Deads = built[0]!.match.playerStatus!.samples.map( + (sample) => sample.dead[1][0], + ); + assert.deepEqual(slot0Deads, [ + false, + ...Array.from({ length: 7 }, () => true), + ...Array.from({ length: 6 }, () => false), + ]); +}); + +test("a lone dead read between sparse reads is kept", () => { + const dead = [ + [false, false, false, false], + [true, false, false, false], + ] as PlayerStatusData["dead"]; + const built = buildScannerMatches([ + mapStart(0), + playerStatus(60), + playerStatus(120, { dead }), + playerStatus(180), + scoreboard(300), + ]); + assert.deepEqual(built[0]!.match.playerStatus!.samples[1]!.dead, dead); +}); + +test("a sub-10s not-ready gap between ready reads with no death bridges to ready", () => { + const specialAt = (on: boolean) => + [ + [on, false, false, false], + [false, false, false, false], + ] as PlayerStatusData["special"]; + const built = buildScannerMatches([ + mapStart(0), + playerStatus(100, { special: specialAt(true) }), + playerStatus(102, { special: specialAt(false) }), + playerStatus(104, { special: specialAt(false) }), + playerStatus(106, { special: specialAt(true) }), + playerStatus(108, { special: specialAt(false) }), + scoreboard(300), + ]); + const slot0Specials = built[0]!.match.playerStatus!.samples.map( + (sample) => sample.special[0][0], + ); + // the interior gap bridges; the trailing not-ready run is an edge and stays + assert.deepEqual(slot0Specials, [true, true, true, true, false]); +}); + +test("a not-ready gap explained by a death inside it is kept", () => { + const read = (special: boolean, dead: boolean) => ({ + special: [ + [special, false, false, false], + [false, false, false, false], + ] as PlayerStatusData["special"], + dead: [ + [dead, false, false, false], + [false, false, false, false], + ] as PlayerStatusData["dead"], + }); + const built = buildScannerMatches([ + mapStart(0), + playerStatus(100, read(true, false)), + playerStatus(102, read(false, true)), + playerStatus(106, read(false, false)), + playerStatus(108, read(true, false)), + scoreboard(300), + ]); + const slot0Specials = built[0]!.match.playerStatus!.samples.map( + (sample) => sample.special[0][0], + ); + assert.deepEqual(slot0Specials, [true, false, false, true]); +}); + +test("a not-ready gap wide enough to regain a special is kept", () => { + const specialAt = (on: boolean) => + [ + [on, false, false, false], + [false, false, false, false], + ] as PlayerStatusData["special"]; + const built = buildScannerMatches([ + mapStart(0), + playerStatus(100, { special: specialAt(true) }), + playerStatus(102, { special: specialAt(false) }), + playerStatus(112, { special: specialAt(false) }), + playerStatus(114, { special: specialAt(true) }), + scoreboard(300), + ]); + const slot0Specials = built[0]!.match.playerStatus!.samples.map( + (sample) => sample.special[0][0], + ); + assert.deepEqual(slot0Specials, [true, false, false, true]); +}); + +test("a known non-SZ match drops its player-status reads too", () => { + const events = [ + mapStart(0, { mode: "CB" }), + objective(60), + playerStatus(61), + scoreboard(300, { mode: "CB" }), + ]; + const built = buildScannerMatches(events); + assert.equal(built[0]!.match.playerStatus, null); + assert.deepEqual(invalidObjectiveEvents(built), [events[1], events[2]]); +}); + +test("replay wipes drop status reads by the shared clock projection", () => { + const built = buildScannerMatches([ + mapStart(0), + objective(60, { score: [80, 6] }), + playerStatus(60), + objective(61, { score: [78, 6] }), + // broadcast re-runs the opening moments, clock jumped back + playerStatus(90, { time: 291 }), + objective(91, { time: 290, score: [99, 100] }), + scoreboard(300), + ]); + assert.deepEqual( + built[0]!.match.playerStatus!.samples.map((sample) => sample.t), + [60], + ); +}); + +test("minimap card states become timerless player-status samples", () => { + const built = buildScannerMatches([ + minimap(70, { dead: [[2], [0]], specialReady: [[], [3]] }), + minimap(120), + ]); + assert.deepEqual(built[0]!.match.playerStatus, { + samples: [ + { + t: 70, + time: null, + special: [ + [false, false, false, false], + [false, false, false, true], + ], + dead: [ + [false, false, true, false], + [true, false, false, false], + ], + }, + { + t: 120, + time: null, + special: ALL_FALSE, + dead: ALL_FALSE, + }, + ], + }); +}); + +test("a known non-SZ match still gets its minimap-sourced status samples", () => { + const events = [ + mapStart(0, { mode: "CB" }), + objective(60), + playerStatus(61), + minimap(90, { spectator: false, dead: [[0], []] }), + scoreboard(300, { mode: "CB" }), + ]; + const built = buildScannerMatches(events); + assert.equal(built[0]!.match.objective, null); + const samples = built[0]!.match.playerStatus!.samples; + assert.deepEqual( + samples.map((sample) => sample.t), + [90], + ); + assert.deepEqual(samples[0]!.dead, [ + [true, false, false, false], + [false, false, false, false], + ]); + assert.deepEqual(invalidObjectiveEvents(built), [events[1], events[2]]); +}); + +test("a losing-side pov swaps minimap-sourced samples into teams order", () => { + const built = buildScannerMatches([ + minimap(90, { spectator: false, dead: [[0], []] }), + scoreboard(300, { povIndex: 6 }), + ]); + const sample = built[0]!.match.playerStatus!.samples[0]!; + assert.deepEqual(sample.dead, [ + [false, false, false, false], + [true, false, false, false], + ]); +}); + +// ---- strip-slot → scoreboard-row assignment ---- + +test("strip weapon evidence reorders status slots into scoreboard rows", () => { + // strip seating [2010, 40, 3030, 1001] vs scoreboard rows ALPHA + // [40, 1001, 2010, 3030]: slot0 belongs to row2 + const built = buildScannerMatches([ + mapStart(0), + playerStatus(120, { + dead: [ + [true, false, false, false], + [false, false, false, false], + ], + }), + stripWeaponsEvent(121, [ + [2010, 40, 3030, 1001], + [null, null, null, null], + ]), + scoreboard(300), + ]); + const sample = built[0]!.match.playerStatus!.samples[0]!; + assert.deepEqual(sample.dead, [ + [false, false, true, false], + [false, false, false, false], + ]); +}); + +test("weapon evidence below the assignment floor keeps the as-drawn order", () => { + const dead = [ + [true, false, false, false], + [false, false, false, false], + ] as PlayerStatusData["dead"]; + const built = buildScannerMatches([ + mapStart(0), + playerStatus(120, { dead }), + stripWeaponsEvent( + 121, + [ + [2010, null, null, null], + [null, null, null, null], + ], + { + score: 0.5, + }, + ), + scoreboard(300), + ]); + assert.deepEqual(built[0]!.match.playerStatus!.samples[0]!.dead, dead); +}); + +test("minimap enemy-card weapons vote the strip assignment too", () => { + // enemy cards in strip seating [4010, 50, 8000, 210] vs rows BRAVO + // [50, 210, 4010, 8000]: the strip-sourced side1 slot0 belongs to row2 + const seating: (MainWeaponId | null)[] = [4010, 50, 8000, 210]; + const built = buildScannerMatches([ + mapStart(0), + minimap(60, { bravo: seating }), + minimap(90, { bravo: seating }), + playerStatus(120, { + dead: [ + [false, false, false, false], + [true, false, false, false], + ], + }), + scoreboard(300), + ]); + const strip = built[0]!.match.playerStatus!.samples.at(-1)!; + assert.deepEqual(strip.dead, [ + [false, false, false, false], + [false, false, true, false], + ]); +}); + +test("pov diamond cards map to scoreboard rows by name", () => { + const cards = [ + { ...teammate(ALPHA[1]!, 0), name: "w2", dead: true }, + { ...teammate(ALPHA[0]!, 1), name: "w1" }, + { ...teammate(ALPHA[3]!, 2), name: "w4" }, + { ...teammate(ALPHA[2]!, 3), name: "w3" }, + ]; + const data: MinimapData = { + stage: 0 as StageId, + spectator: false, + teammates: cards, + enemies: BRAVO.map((id) => enemy(id)), + teamColors: [null, null], + }; + const built = buildScannerMatches([ + mapStart(0), + { type: "Minimap", t: 90, confidence: 0.8, data } as DetectedEvent, + scoreboard(300), + ]); + const sample = built[0]!.match.playerStatus!.samples[0]!; + assert.deepEqual(sample.dead, [ + [false, true, false, false], + [false, false, false, false], + ]); +}); diff --git a/app/features/scanner/tests/match-sets.test.ts b/app/features/scanner/tests/match-sets.test.ts index ded9a29fe..6b7c5c945 100644 --- a/app/features/scanner/tests/match-sets.test.ts +++ b/app/features/scanner/tests/match-sets.test.ts @@ -27,6 +27,7 @@ function match( replayCode: null, cast: false, objective: null, + playerStatus: null, teams: [{ players: alpha.map(player) }, { players: bravo.map(player) }], winner: null, pov: null, diff --git a/app/features/scanner/tests/minimap.test.ts b/app/features/scanner/tests/minimap.test.ts index ec6e49697..369428d1f 100644 --- a/app/features/scanner/tests/minimap.test.ts +++ b/app/features/scanner/tests/minimap.test.ts @@ -17,6 +17,7 @@ import { createScoreboardDetector } from "../core/detectors/scoreboard/index"; import { createScoreboardBattleLogReplayDetector } from "../core/detectors/scoreboard-battle-log-replay/index"; import { createScoreboardOwnDetector } from "../core/detectors/scoreboard-own/index"; import type { Detector } from "../core/detectors/types"; +import { hueDistance, hueOf } from "../core/ink-color"; import { type Fixture, isFieldSkipped, @@ -133,6 +134,16 @@ for (const fixture of fixtures) { `abilities (debug: ${cardDebug})`, ); } + if (want.dead !== undefined) { + assert.equal(got.dead, want.dead, `dead (debug: ${cardDebug})`); + } + if (want.specialReady !== undefined) { + assert.equal( + got.specialReady, + want.specialReady, + `specialReady (debug: ${cardDebug})`, + ); + } }, ); } @@ -176,12 +187,44 @@ for (const fixture of fixtures) { `abilities (debug: ${rowDebug})`, ); } + if (want.dead !== undefined) { + assert.equal(got.dead, want.dead, `dead (debug: ${rowDebug})`); + } + if (want.specialReady !== undefined) { + assert.equal( + got.specialReady, + want.specialReady, + `specialReady (debug: ${rowDebug})`, + ); + } }, ); } }); } +// The columns' sub-tile ink means anchor the objective counter's color +// clusters to `teams` order (match-builder); the SWS26 spectator fixture +// pairs with objective/splat-zones-cast-* from the same game, where the +// plates read green ~78° and purple ~302°. +test("spectator sub tiles read the two team ink colors", async () => { + const fixture = fixtures.find((f) => f.name === "spectator-sws26-swiss"); + assert.ok(fixture, "spectator-sws26-swiss fixture missing"); + const { events } = await runDetectorOnFixture( + detector, + fixture!, + ); + const teamColors = events[0]?.data.teamColors; + assert.ok(teamColors?.[0] && teamColors[1], "column ink color unreadable"); + const [alpha, bravo] = teamColors; + assert.ok( + hueDistance(hueOf(alpha), hueOf(bravo)) >= 90, + "the two columns' ink hues do not separate", + ); + assert.ok(hueDistance(hueOf(alpha), 84) <= 25, "left column is not green"); + assert.ok(hueDistance(hueOf(bravo), 300) <= 25, "right column is not purple"); +}); + // The map overlay replaces everything else on screen; its gate may not fire // on any other detector's positives, nor theirs on the minimap fixtures. const otherPositives = [ diff --git a/app/features/scanner/tests/objective.test.ts b/app/features/scanner/tests/objective.test.ts index 22a3491f5..c05a4726e 100644 --- a/app/features/scanner/tests/objective.test.ts +++ b/app/features/scanner/tests/objective.test.ts @@ -18,7 +18,8 @@ import { import { createScoreboardDetector } from "../core/detectors/scoreboard/index"; import { createScoreboardBattleLogReplayDetector } from "../core/detectors/scoreboard-battle-log-replay/index"; import { createScoreboardOwnDetector } from "../core/detectors/scoreboard-own/index"; -import type { Detector } from "../core/detectors/types"; +import type { DetectedEvent, Detector } from "../core/detectors/types"; +import { hueDistance, hueOf } from "../core/ink-color"; import { type Fixture, isFieldSkipped, @@ -39,10 +40,13 @@ test("objective fixtures exist", () => { for (const fixture of fixtures) { test(`objective/${fixture.name}`, async (t) => { - const { gate, events } = await runDetectorOnFixture( + const { gate, events: allEvents } = await runDetectorOnFixture( detector, fixture, ); + const events = allEvents.filter( + (event) => event.type === "Objective", + ) as DetectedEvent[]; const expectPositive = fixture.expected.event === "Objective"; await t.test("gate", () => { @@ -130,6 +134,42 @@ for (const fixture of fixtures) { }); } +// The cast fixture pair captures the same game under both camera +// arrangements (the specced team's plate sits left, so purple is left in +// one frame and right in the other): each frame's two ink hues must +// separate cleanly, and cross-frame the same team's hue must land on the +// same cluster with the sides swapped — the invariant cast score tracking +// (match-builder's color orientation) rests on. +test("cast fixture pair: team ink hues identify sides across camera swaps", async () => { + const pair = [ + "splat-zones-cast-specced-purple-left", + "splat-zones-cast-overhead-purple-right", + ].map((name) => fixtures.find((fixture) => fixture.name === name)); + assert.ok(pair[0] && pair[1], "cast fixture pair missing"); + + const colors = []; + for (const fixture of pair) { + const { events } = await runDetectorOnFixture(detector, fixture!); + const teamColor = ( + events.find((event) => event.type === "Objective") as + | DetectedEvent + | undefined + )?.data.teamColor; + assert.ok(teamColor?.[0] && teamColor[1], "side ink color unreadable"); + colors.push([teamColor[0], teamColor[1]] as const); + } + + for (const [left, right] of colors) { + assert.ok( + hueDistance(hueOf(left), hueOf(right)) >= 90, + "the two teams' ink hues do not separate", + ); + } + const [specced, overhead] = colors; + assert.ok(hueDistance(hueOf(specced![0]), hueOf(overhead![1])) <= 20); + assert.ok(hueDistance(hueOf(specced![1]), hueOf(overhead![0])) <= 20); +}); + // Screens that replace gameplay can never show the counters — the gate must // stay quiet on their positives. const otherPositiveSets = [ diff --git a/app/features/scanner/tests/player-status.test.ts b/app/features/scanner/tests/player-status.test.ts new file mode 100644 index 000000000..fd5e37986 --- /dev/null +++ b/app/features/scanner/tests/player-status.test.ts @@ -0,0 +1,168 @@ +/** + * Golden-file tests for the PlayerStatus event over every fixture in + * player-status/, mirroring objective.test.ts. The event is emitted by the + * ObjectiveDetector alongside each counter read — a positive fixture must + * produce both events off one parse, with the icon-strip statuses and the + * shared timer matching the hand-corrected labels. Other detectors' gates + * must stay quiet on these frames (they show live gameplay HUD, which only + * the objective family may claim — death excepted, see objective.test.ts). + */ + +import assert from "node:assert/strict"; +import { loadOpenCV } from "../core/cv"; +import { createDeathDetector } from "../core/detectors/death/index"; +import { createMapStartDetector } from "../core/detectors/map-start/index"; +import { createMinimapDetector } from "../core/detectors/minimap/index"; +import { createObjectiveDetector } from "../core/detectors/objective/index"; +import { + PLAYER_STATUS_EVENT_TYPE, + type PlayerStatusData, +} from "../core/detectors/objective/player-status"; +import { createScoreboardDetector } from "../core/detectors/scoreboard/index"; +import { createScoreboardBattleLogReplayDetector } from "../core/detectors/scoreboard-battle-log-replay/index"; +import { createScoreboardOwnDetector } from "../core/detectors/scoreboard-own/index"; +import type { DetectedEvent, Detector } from "../core/detectors/types"; +import { + type Fixture, + isFieldSkipped, + loadFixtures, + runDetectorOnFixture, +} from "../node/fixtures"; +import { loadScoreboardResources } from "../node/resources"; +import test from "./node-test-compat"; + +await loadOpenCV(); +const resources = await loadScoreboardResources(); +const fixtures = loadFixtures("player-status"); + +test("player-status fixtures exist", () => { + assert.ok(fixtures.length > 0, "no fixtures found under player-status/"); +}); + +for (const fixture of fixtures) { + test(`player-status/${fixture.name}`, async (t) => { + // fresh detector per fixture: the objective detector carries sticky + // layout state across reads, and fixtures are unrelated frames + const { gate, events } = await runDetectorOnFixture( + createObjectiveDetector(resources), + fixture, + ); + const expectPositive = fixture.expected.event === "PlayerStatus"; + + await t.test("gate", () => { + assert.equal( + gate.pass, + expectPositive, + `gate ${gate.pass ? "fired" : "did not fire"} (score=${gate.score.toFixed(3)}), expected ${expectPositive ? "fire" : "no fire"}`, + ); + }); + + if (!expectPositive) return; + const event = events.find((e) => e.type === PLAYER_STATUS_EVENT_TYPE) as + | DetectedEvent + | undefined; + assert.ok( + event, + gate.pass + ? "gate passed but no PlayerStatus event (counter unreadable?)" + : "no event (gate did not fire)", + ); + const expected = fixture.expected.data ?? {}; + const debug = () => JSON.stringify(event.debug); + + await t.test( + "layout", + { skip: expected.layout === undefined || skip(fixture, "layout") }, + () => { + assert.equal(event.data.layout, expected.layout); + }, + ); + + await t.test( + "time", + { skip: expected.time === undefined || skip(fixture, "time") }, + () => { + assert.equal( + event.data.time, + expected.time, + `time mismatch (${debug()})`, + ); + }, + ); + + for (const side of [0, 1] as const) { + for (const slot of [0, 1, 2, 3] as const) { + await t.test( + `special[${side}][${slot}]`, + { + skip: + expected.special === undefined || + skip(fixture, `special.${side}.${slot}`), + }, + () => { + assert.equal( + event.data.special[side][slot], + expected.special![side]![slot], + `special[${side}][${slot}] mismatch (${debug()})`, + ); + }, + ); + await t.test( + `dead[${side}][${slot}]`, + { + skip: + expected.dead === undefined || + skip(fixture, `dead.${side}.${slot}`), + }, + () => { + assert.equal( + event.data.dead[side][slot], + expected.dead![side]![slot], + `dead[${side}][${slot}] mismatch (${debug()})`, + ); + }, + ); + } + } + }); +} + +// The status strip only exists on the live-gameplay HUD, which replaces no +// other detector's screen — their gates must stay quiet on these frames +// (death excepted: its overlay rides live gameplay, see objective.test.ts). +const otherDetectors: readonly [string, Detector][] = [ + ["scoreboard", createScoreboardDetector(resources) as Detector], + [ + "scoreboard-battle-log-replay", + createScoreboardBattleLogReplayDetector(resources) as Detector, + ], + [ + "scoreboard-own", + createScoreboardOwnDetector(resources) as Detector, + ], + ["death", createDeathDetector(resources) as Detector], + ["map-start", createMapStartDetector(resources) as Detector], + ["minimap", createMinimapDetector(resources) as Detector], +]; +for (const fixture of fixtures.filter( + (f) => f.expected.event === "PlayerStatus", +)) { + test(`other gates stay quiet on player-status/${fixture.name}`, async () => { + for (const [name, other] of otherDetectors) { + if (name === "death") continue; + const { gate } = await runDetectorOnFixture(other, fixture); + assert.equal( + gate.pass, + false, + `${name} gate fired (score=${gate.score.toFixed(3)})`, + ); + } + }); +} + +// Shared negatives: the objective gate guards PlayerStatus emission too, +// and objective.test.ts already sweeps it over negative/ — no repeat here. + +function skip(fixture: Fixture, field: string): boolean | string { + return isFieldSkipped(fixture, field) ? "skipFields" : false; +} diff --git a/app/features/scanner/tests/sendou-upload.test.ts b/app/features/scanner/tests/sendou-upload.test.ts index 3585cbb50..4135fdb77 100644 --- a/app/features/scanner/tests/sendou-upload.test.ts +++ b/app/features/scanner/tests/sendou-upload.test.ts @@ -60,8 +60,17 @@ test("weapons are padded to 4 slots per team so uneven rosters keep the team spl name: null, weaponId: ALPHA[i]!, abilities: [], + dead: false, + specialReady: false, })), - enemies: BRAVO.map((weaponId) => ({ name: null, weaponId, abilities: [] })), + enemies: BRAVO.map((weaponId) => ({ + name: null, + weaponId, + abilities: [], + dead: false, + specialReady: false, + })), + teamColors: [null, null], }; const matches = prefilledMatches([ { type: "Minimap", t: 300, confidence: 0.9, data }, diff --git a/app/features/scanner/tests/strip-weapons.test.ts b/app/features/scanner/tests/strip-weapons.test.ts new file mode 100644 index 000000000..f41db5957 --- /dev/null +++ b/app/features/scanner/tests/strip-weapons.test.ts @@ -0,0 +1,125 @@ +/** + * Golden-file tests for the StripWeapons evidence event over every fixture + * in strip-weapons/. Single reads are deliberately weak (the true weapon + * ranks top-1 only about half the time), so per-slot assertions stay + * structural — splatted slots skipped, alive slots ranked — and the + * accuracy assertion is the one production relies on: votes aggregated + * across the fixtures assign every slot to its scoreboard row. The + * fixtures are frames of one match of the sendou-triton VoD, whose results + * screen (and D-column ground truth) attests both sides' row orders. + */ + +import assert from "node:assert/strict"; +import type { MainWeaponId } from "~/modules/in-game-lists/types"; +import { loadOpenCV } from "../core/cv"; +import { createObjectiveDetector } from "../core/detectors/objective/index"; +import { + STRIP_WEAPONS_EVENT_TYPE, + type StripWeaponsData, +} from "../core/detectors/objective/strip-weapons"; +import type { DetectedEvent } from "../core/detectors/types"; +import { weaponSlotRowPermutation } from "../core/slot-row-assignment"; +import { + type Fixture, + isFieldSkipped, + loadFixtures, + runDetectorOnFixture, +} from "../node/fixtures"; +import { loadScoreboardResources } from "../node/resources"; +import test from "./node-test-compat"; + +await loadOpenCV(); +const resources = await loadScoreboardResources(); +const fixtures = loadFixtures("strip-weapons"); + +test("strip-weapons fixtures exist", () => { + assert.ok(fixtures.length > 0, "no fixtures found under strip-weapons/"); +}); + +const parsed = new Map>(); + +for (const fixture of fixtures) { + test(`strip-weapons/${fixture.name}`, async (t) => { + const { gate, events } = await runDetectorOnFixture( + createObjectiveDetector(resources), + fixture, + ); + assert.ok(gate.pass, `objective gate did not fire (${gate.score})`); + const event = events.find((e) => e.type === STRIP_WEAPONS_EVENT_TYPE) as + | DetectedEvent + | undefined; + assert.ok(event, "no StripWeapons event alongside the counter read"); + parsed.set(fixture.name, event); + const expected = fixture.expected.data ?? {}; + + await t.test( + "layout", + { skip: expected.layout === undefined || skip(fixture, "layout") }, + () => { + assert.equal(event.data.layout, expected.layout); + }, + ); + + for (const side of [0, 1] as const) { + for (const slot of [0, 1, 2, 3] as const) { + const truth = expected.weapons?.[side]?.[slot]; + await t.test( + `slot[${side}][${slot}]`, + { + skip: + truth === undefined || skip(fixture, `weapons.${side}.${slot}`), + }, + () => { + const candidates = event.data.slots[side][slot]; + if (truth === null) { + assert.equal(candidates, null, "splatted slot should be skipped"); + } else { + assert.ok(candidates, "alive slot should carry candidates"); + assert.ok(candidates.length > 0, "empty candidate list"); + } + }, + ); + } + } + }); +} + +// The assertion production leans on: aggregated across the match's sampled +// reads, each side's best-of-24 assignment against the results screen's +// row weapons places every slot. Row orders attested on the VoD's results +// screen: left/losing side rows [Snipewriter 5H, Custom Blaster, +// Splattershot Jr., Splat Roller] vs strip seating [Snipewriter, Jr, +// Custom Blaster, Roller]; right/winning side rows [.52 Gal, Neo +// Splash-o-matic, Snipewriter 5H, Planetz Big Swig Roller] vs seating +// [Planetz, .52, Neo Splash, Snipewriter]. +test("aggregated votes assign every slot to its scoreboard row", () => { + assert.ok(parsed.size >= 2, "needs at least two parsed fixtures"); + const votes = [0, 1].map(() => + [0, 1, 2, 3].map(() => new Map()), + ); + for (const event of parsed.values()) { + for (const side of [0, 1] as const) { + for (const [slot, candidates] of event.data.slots[side].entries()) { + for (const candidate of candidates ?? []) { + const slotVotes = votes[side]![slot]!; + slotVotes.set( + candidate.weaponId, + (slotVotes.get(candidate.weaponId) ?? 0) + candidate.score, + ); + } + } + } + } + assert.deepEqual( + weaponSlotRowPermutation(votes[0]!, [2070, 211, 10, 1010]), + [0, 2, 1, 3], + ); + assert.deepEqual( + weaponSlotRowPermutation(votes[1]!, [50, 21, 2070, 1042]), + [3, 0, 1, 2], + ); +}); + +function skip(fixture: Fixture, field: string): boolean | string { + return isFieldSkipped(fixture, field) ? "skipFields" : false; +} diff --git a/app/features/scanner/worker/analyzer.worker.ts b/app/features/scanner/worker/analyzer.worker.ts index e0a1aaef7..02ffb56d7 100644 --- a/app/features/scanner/worker/analyzer.worker.ts +++ b/app/features/scanner/worker/analyzer.worker.ts @@ -28,6 +28,7 @@ import { DetectorScheduler } from "../core/detectors/scheduler"; import { createScanTelemetry, detectorTelemetry, + type ScanTelemetry, } from "../core/detectors/telemetry"; import type { Detector } from "../core/detectors/types"; import { normalizeFrame, toMat } from "../core/image"; @@ -53,7 +54,9 @@ const PREVIEW_HEIGHT = 270; let detectors: Detector[] = []; let scheduler: DetectorScheduler | null = null; -let telemetry = createScanTelemetry(); +/** null unless the init message asked for telemetry */ +let telemetry: ScanTelemetry | null = null; +let collectTelemetry = false; let chunkAborted = false; /** last per-frame t, to reset telemetry when a new session rewinds the clock */ let lastFrameT = Number.NEGATIVE_INFINITY; @@ -65,6 +68,7 @@ function post(message: WorkerResponse, transfer: Transferable[] = []): void { async function init({ assetsBaseUrl, suppressSteadyFrames = true, + collectTelemetry: collect = false, }: InitRequest): Promise { try { await loadOpenCV(); @@ -75,7 +79,8 @@ async function init({ matchOpeningTypes: [MAP_START_EVENT_TYPE], matchClosingTypes: SCOREBOARD_EVENT_TYPES, }); - telemetry = createScanTelemetry(); + collectTelemetry = collect; + telemetry = freshTelemetry(); post({ kind: "ready" }); } catch (error) { post({ kind: "error", message: `init failed: ${String(error)}` }); @@ -115,7 +120,7 @@ async function analyzeFrame( } finally { src.delete(); } - telemetry.analyzedFrames++; + if (telemetry) telemetry.analyzedFrames++; // On detection, ship back the exact analyzed pixels (lossless, at capture // resolution) so the UI never has to re-grab a later frame — encoded at @@ -127,21 +132,27 @@ async function analyzeFrame( try { for (const detector of detectors) { if (!due.includes(detector.id)) continue; - const counters = detectorTelemetry(telemetry, detector.id); - counters.checks++; - const gateStart = performance.now(); + const counters = telemetry + ? detectorTelemetry(telemetry, detector.id) + : null; + const gateStart = counters ? performance.now() : 0; const gate = detector.gate(frame); - counters.gateMs += performance.now() - gateStart; + if (counters) { + counters.checks++; + counters.gateMs += performance.now() - gateStart; + } scheduler!.recordGate(detector.id, t, gate.pass, gate.signature); - if (gate.pass) counters.gatePasses++; + if (counters && gate.pass) counters.gatePasses++; const runParse = gate.pass && scheduler!.shouldParse(detector.id, t); - if (gate.pass && !runParse) counters.suppressedParses++; + if (counters && gate.pass && !runParse) counters.suppressedParses++; let events: ReturnType = []; if (runParse) { - const parseStart = performance.now(); + const parseStart = counters ? performance.now() : 0; events = detector.parse(frame, t, gate); - counters.parses++; - counters.parseMs += performance.now() - parseStart; + if (counters) { + counters.parses++; + counters.parseMs += performance.now() - parseStart; + } scheduler!.recordParse(detector.id, t, events); } const blob = @@ -163,7 +174,7 @@ async function analyzeFrame( } async function analyze({ bitmap, t }: AnalyzeRequest): Promise { - if (t + 5 < lastFrameT) telemetry = createScanTelemetry(); + if (t + 5 < lastFrameT) telemetry = freshTelemetry(); lastFrameT = t; try { await analyzeFrame(bitmap, t); @@ -181,7 +192,7 @@ async function scanChunk({ }: ScanChunkRequest): Promise { chunkAborted = false; scheduler!.reset(tStart); - telemetry = createScanTelemetry(); + telemetry = freshTelemetry(); const wallStart = performance.now(); let lastProgressAt = 0; let lastPreviewAt = 0; @@ -201,11 +212,13 @@ async function scanChunk({ const packets = new EncodedPacketSink(track); const handleSample = async (sample: VideoSample): Promise => { - telemetry.decodedFrames++; const t = sample.timestamp; - const span = Math.max(0, t - cursor); - if (mode === "active") telemetry.activeVideoS += span; - else telemetry.skimVideoS += span; + if (telemetry) { + telemetry.decodedFrames++; + const span = Math.max(0, t - cursor); + if (mode === "active") telemetry.activeVideoS += span; + else telemetry.skimVideoS += span; + } cursor = Math.max(cursor, t); const frame = sample.toVideoFrame(); sample.close(); @@ -225,7 +238,7 @@ async function scanChunk({ } if (preview || now - lastProgressAt >= PROGRESS_POST_INTERVAL_MS) { lastProgressAt = now; - telemetry.wallMs = performance.now() - wallStart; + if (telemetry) telemetry.wallMs = performance.now() - wallStart; post( { kind: "chunkProgress", @@ -282,7 +295,7 @@ async function scanChunk({ } } - telemetry.wallMs = performance.now() - wallStart; + if (telemetry) telemetry.wallMs = performance.now() - wallStart; post({ kind: "chunkDone", chunkIndex, telemetry }); } catch (error) { post({ @@ -294,6 +307,10 @@ async function scanChunk({ } } +function freshTelemetry(): ScanTelemetry | null { + return collectTelemetry ? createScanTelemetry() : null; +} + self.onmessage = (e: MessageEvent) => { const msg = e.data as WorkerRequest; if (msg.kind === "init") void init(msg); diff --git a/app/features/scanner/worker/client.ts b/app/features/scanner/worker/client.ts index 9b87d378b..e586db72b 100644 --- a/app/features/scanner/worker/client.ts +++ b/app/features/scanner/worker/client.ts @@ -16,7 +16,8 @@ export type ResultHandler = ( export type ErrorHandler = (message: string) => void; export interface DoneInfo { calm: boolean; - telemetry: ScanTelemetry; + /** null unless the client was created with collectTelemetry */ + telemetry: ScanTelemetry | null; } export type DoneHandler = (t: number, info: DoneInfo) => void; export type ChunkProgress = Extract; @@ -29,7 +30,7 @@ export function defaultScanWorkerCount(): number { } interface PendingChunk { - resolve(telemetry: ScanTelemetry): void; + resolve(telemetry: ScanTelemetry | null): void; reject(error: Error): void; onProgress?: ChunkProgressHandler; } @@ -51,7 +52,10 @@ export class AnalyzerClient { // biome-ignore lint/suspicious/noConsole: default sink for worker errors when no handler is passed onError: ErrorHandler = console.error, onDone?: DoneHandler, - options: { suppressSteadyFrames?: boolean } = {}, + options: { + suppressSteadyFrames?: boolean; + collectTelemetry?: boolean; + } = {}, ) { this.#onResult = onResult; this.#onError = onError; @@ -104,6 +108,7 @@ export class AnalyzerClient { kind: "init", assetsBaseUrl: Config.staticAssetsUrl, suppressSteadyFrames: options.suppressSteadyFrames ?? true, + collectTelemetry: options.collectTelemetry ?? false, }); } @@ -141,7 +146,7 @@ export class AnalyzerClient { scanChunk( request: { file: File; chunkIndex: number; tStart: number; tEnd: number }, onProgress?: ChunkProgressHandler, - ): Promise { + ): Promise { if (this.busy) { return Promise.reject(new Error("analyzer is busy")); } diff --git a/app/features/scanner/worker/protocol.ts b/app/features/scanner/worker/protocol.ts index 399ca25a7..ca4d4d466 100644 --- a/app/features/scanner/worker/protocol.ts +++ b/app/features/scanner/worker/protocol.ts @@ -16,6 +16,12 @@ export interface InitRequest { * off to get every detector on every frame */ suppressSteadyFrames?: boolean; + /** + * accumulate scan telemetry counters (and time the detectors) so they can + * be reported back with progress and done messages; default false — the + * VoD tab only asks for them when the telemetry panel is opted into + */ + collectTelemetry?: boolean; } export interface AnalyzeRequest { @@ -70,7 +76,8 @@ export type WorkerResponse = t: number; /** scheduler sees dead air — the caller may widen its sampling stride */ calm: boolean; - telemetry: ScanTelemetry; + /** null when the worker was not asked to collect telemetry */ + telemetry: ScanTelemetry | null; } | { kind: "chunkProgress"; @@ -78,9 +85,9 @@ export type WorkerResponse = /** seconds of video the chunk scan has reached */ t: number; mode: "active" | "skim"; - telemetry: ScanTelemetry; + telemetry: ScanTelemetry | null; /** small bitmap of the latest decoded frame, for the preview canvas */ preview?: ImageBitmap; } - | { kind: "chunkDone"; chunkIndex: number; telemetry: ScanTelemetry } + | { kind: "chunkDone"; chunkIndex: number; telemetry: ScanTelemetry | null } | { kind: "error"; message: string }; diff --git a/app/features/tournament-match/components/TournamentMatchTabs.tsx b/app/features/tournament-match/components/TournamentMatchTabs.tsx index 588cdfb3a..201c41aa6 100644 --- a/app/features/tournament-match/components/TournamentMatchTabs.tsx +++ b/app/features/tournament-match/components/TournamentMatchTabs.tsx @@ -8,6 +8,7 @@ import type { } from "~/components/match-page/MatchTimeline"; import type { WeaponPoolWeapon } from "~/components/match-page/WeaponPool"; import type { ObjectiveTimelineEvent } from "~/components/ObjectiveTimeline"; +import type { PlayerStatusTimelineSample } from "~/components/PlayerStatusTimeline"; import { useUser } from "~/features/auth/core/user"; import type { IngestedScoreboardData } from "~/features/scanner-ingest/core/Scoreboards"; import { useTournament } from "~/features/tournament/tournament-context"; @@ -244,6 +245,10 @@ function resolveTimelineScoreboard( ingestedScoreboard.data.objective, alphaIsWinner, ), + playerStatus: toTimelinePlayerStatus( + ingestedScoreboard.data.playerStatus, + alphaIsWinner, + ), }; } @@ -268,6 +273,23 @@ function toTimelineObjective( })); } +/** Stored status samples are winner-first; the timeline charts alpha-first. */ +function toTimelinePlayerStatus( + playerStatus: IngestedScoreboardData["playerStatus"], + alphaIsWinner: boolean, +): PlayerStatusTimelineSample[] | undefined { + if (!playerStatus) return undefined; + + const alphaFirst = (pair: [T, T]): [T, T] => + alphaIsWinner ? pair : [pair[1], pair[0]]; + + return playerStatus.samples.map((sample) => ({ + t: sample.t, + special: alphaFirst(sample.special), + dead: alphaFirst(sample.dead), + })); +} + function resolveTimelinePickBanData( data: TournamentMatchLoaderData, opponentOneId: number, diff --git a/locales/da/common.json b/locales/da/common.json index 8e66a7aa5..797ecd2f9 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -186,6 +186,8 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "playerStatusTimeline.splatted": "", + "playerStatusTimeline.specialReady": "", "maps.createMapList": "Lav bane-liste", "maps.halfSz": "50% DD", "maps.mapPool": "Banepulje", diff --git a/locales/de/common.json b/locales/de/common.json index 5741ab6d0..9682de81a 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -186,6 +186,8 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "playerStatusTimeline.splatted": "", + "playerStatusTimeline.specialReady": "", "maps.createMapList": "Arenen-Liste erstellen", "maps.halfSz": "50% Herrschaft", "maps.mapPool": "Arenen-Pool", diff --git a/locales/en/common.json b/locales/en/common.json index 6842d857f..3ef8cd4d8 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -186,6 +186,8 @@ "objectiveTimeline.timeLeft": "{{time}} left", "objectiveTimeline.penalty": "+{{value}} penalty", "objectiveTimeline.inControl": "In control", + "playerStatusTimeline.splatted": "Splatted", + "playerStatusTimeline.specialReady": "Special ready", "maps.createMapList": "Create map list", "maps.halfSz": "50% SZ", "maps.mapPool": "Map pool", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index cf41ab370..c26cb66c7 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -186,6 +186,8 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "playerStatusTimeline.splatted": "", + "playerStatusTimeline.specialReady": "", "maps.createMapList": "Crear lista de mapas", "maps.halfSz": "50% Pintazonas", "maps.mapPool": "Rotación de mapas", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index 471dc135e..e4c859441 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -186,6 +186,8 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "playerStatusTimeline.splatted": "", + "playerStatusTimeline.specialReady": "", "maps.createMapList": "Crear lista de escenarios", "maps.halfSz": "50% Pintazonas", "maps.mapPool": "Grupo de escenario", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 3a604da32..f86623015 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -186,6 +186,8 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "playerStatusTimeline.splatted": "", + "playerStatusTimeline.specialReady": "", "maps.createMapList": "Créer une liste de stages", "maps.halfSz": "50% DdZ", "maps.mapPool": "Pool de stages", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index f985b9681..a2a701708 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -186,6 +186,8 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "playerStatusTimeline.splatted": "", + "playerStatusTimeline.specialReady": "", "maps.createMapList": "Créer une liste de stages", "maps.halfSz": "50% DdZ", "maps.mapPool": "Pool de stages", diff --git a/locales/he/common.json b/locales/he/common.json index 461529b42..bf578d96d 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -186,6 +186,8 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "playerStatusTimeline.splatted": "", + "playerStatusTimeline.specialReady": "", "maps.createMapList": "יצירת רשימת מפות", "maps.halfSz": "50% SZ", "maps.mapPool": "מאגר מפות", diff --git a/locales/it/common.json b/locales/it/common.json index bcba08421..1c015abed 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -186,6 +186,8 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "playerStatusTimeline.splatted": "", + "playerStatusTimeline.specialReady": "", "maps.createMapList": "Crea lista scenari", "maps.halfSz": "50% ZS", "maps.mapPool": "Pool di scenari", diff --git a/locales/ja/common.json b/locales/ja/common.json index f14afc6ec..2e74b58f7 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -186,6 +186,8 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "playerStatusTimeline.splatted": "", + "playerStatusTimeline.specialReady": "", "maps.createMapList": "ステージリストを作る", "maps.halfSz": "半分エリア", "maps.mapPool": "選択可能なステージ", diff --git a/locales/ko/common.json b/locales/ko/common.json index a219a01fd..5467b7e66 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -186,6 +186,8 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "playerStatusTimeline.splatted": "", + "playerStatusTimeline.specialReady": "", "maps.createMapList": "맵 목록 생성", "maps.halfSz": "에어리어 50%", "maps.mapPool": "맵 풀", diff --git a/locales/nl/common.json b/locales/nl/common.json index aa092c9c5..8135eca66 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -186,6 +186,8 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "playerStatusTimeline.splatted": "", + "playerStatusTimeline.specialReady": "", "maps.createMapList": "Maak levellijst", "maps.halfSz": "50% SZ", "maps.mapPool": "Beschikbare levels", diff --git a/locales/pl/common.json b/locales/pl/common.json index 6543cbe96..acbdafbf6 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -186,6 +186,8 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "playerStatusTimeline.splatted": "", + "playerStatusTimeline.specialReady": "", "maps.createMapList": "Stwórz liste map", "maps.halfSz": "50% SZ", "maps.mapPool": "Pula map", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 3a9cf8882..5836bed41 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -186,6 +186,8 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "playerStatusTimeline.splatted": "", + "playerStatusTimeline.specialReady": "", "maps.createMapList": "Criar lista de mapas", "maps.halfSz": "50% Zones", "maps.mapPool": "Seleção de mapas", diff --git a/locales/ru/common.json b/locales/ru/common.json index f01ec743f..ecde31d6d 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -186,6 +186,8 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "playerStatusTimeline.splatted": "", + "playerStatusTimeline.specialReady": "", "maps.createMapList": "Создать список карт", "maps.halfSz": "50% Зон", "maps.mapPool": "Пул карт", diff --git a/locales/zh/common.json b/locales/zh/common.json index e790bd6e8..4f6acf2f0 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -186,6 +186,8 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "playerStatusTimeline.splatted": "", + "playerStatusTimeline.specialReady": "", "maps.createMapList": "创建场地列表", "maps.halfSz": "真格区域占 50%", "maps.mapPool": "场地池", diff --git a/package.json b/package.json index 4ec6f9043..b00293316 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "test:e2e:flaky-detect": "playwright test --repeat-each=10 --max-failures=1", "check-plural-collapse": "node scripts/collapse-single-plural-keys.ts --check", "checks": "pnpm run biome:fix && pnpm run test:unit:browser && pnpm run check-translation-jsons && pnpm run check-plural-collapse && pnpm run typecheck && pnpm run knip", + "checks:scanner": "pnpm checks && pnpm test:scanner", "seed": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/seed.ts", "setup": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/setup.ts", "notification:test": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/send-test-notification.ts", diff --git a/scripts/scanner/report.ts b/scripts/scanner/report.ts index 2b8158e2d..066c5fe49 100644 --- a/scripts/scanner/report.ts +++ b/scripts/scanner/report.ts @@ -349,6 +349,7 @@ for (const config of configs) { names: { ok: 0, total: 0 } as Tally, abilities: { ok: 0, total: 0 } as Tally, stage: { ok: 0, total: 0 } as Tally, + status: { ok: 0, total: 0 } as Tally, }; let charEdits = 0; let charTotal = 0; @@ -372,11 +373,15 @@ for (const config of configs) { name?: string | null; weaponId?: number | null; abilities?: (string | null)[]; + dead?: boolean; + specialReady?: boolean; }[], { name?: string | null; weaponId: number | null; abilities: (string | null)[]; + dead: boolean; + specialReady: boolean; }[], ][] = [ ["teammate", expected.teammates ?? [], event.data.teammates], @@ -415,6 +420,15 @@ for (const config of configs) { `${fixture.name} ${side}${i}: ability [${slot}] "${gotId}" != "${wantId}"`, ); }); + for (const flag of ["dead", "specialReady"] as const) { + if (want[flag] === undefined) continue; + tally.status.total++; + if (got[flag] === want[flag]) tally.status.ok++; + else + misses.push( + `${fixture.name} ${side}${i}: ${flag} ${got[flag]} != ${want[flag]}`, + ); + } }); } if (expected.stage !== undefined) { @@ -433,6 +447,7 @@ for (const config of configs) { console.info(`names ${pct(tally.names)}`); console.info(`abilities ${pct(tally.abilities)}`); console.info(`stage ${pct(tally.stage)}`); + console.info(`status ${pct(tally.status)}`); console.info( `name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`, );