Death/Special

This commit is contained in:
Kalle 2026-08-09 07:07:58 +03:00
parent d9d0237c85
commit a56f49042e
48 changed files with 1656 additions and 108 deletions

View File

@ -0,0 +1,87 @@
.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;
gap: 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;
}

View File

@ -0,0 +1,158 @@
/**
* 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 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. */
const 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 + 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}%`;
return (
<div className={styles.container}>
<div className={styles.legend}>
<span className={styles.legendItem}>
<span className={styles.legendSwatchDead} />
{t("common:playerStatusTimeline.splatted")}
</span>
<span className={styles.legendItem}>
<span className={styles.legendSwatchSpecial} />
{t("common:playerStatusTimeline.specialReady")}
</span>
</div>
{([0, 1] as const).map((side) => (
<div key={side} className={styles.team}>
<div className={styles.teamLabel}>{teams[side].label}</div>
{[0, 1, 2, 3].map((slot) => (
<div key={slot} className={styles.row}>
<SlotWeapon weaponSplId={teams[side].weapons[slot] ?? null} />
<div className={styles.track}>
{statusSpans(sorted, (sample) => sample.dead[side][slot]!).map(
(span, i) => (
<div
key={`d${i}`}
className={styles.spanDead}
style={{ left: leftOf(span), width: widthOf(span) }}
/>
),
)}
{statusSpans(
sorted,
(sample) => sample.special[side][slot]!,
).map((span, i) => (
<div
key={`s${i}`}
className={styles.spanSpecial}
style={{ left: leftOf(span), width: widthOf(span) }}
/>
))}
</div>
</div>
))}
</div>
))}
</div>
);
}
function SlotWeapon({ weaponSplId }: { weaponSplId: MainWeaponId | null }) {
if (weaponSplId === null) {
return (
<Image
path={abilityImageUrl("UNKNOWN")}
alt="?"
size={22}
className={styles.unknownWeapon}
/>
);
}
return <WeaponImage weaponSplId={weaponSplId} variant="badge" size={22} />;
}
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.
*/
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 + 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 + TAIL_SECONDS });
return spans;
}

View File

@ -36,6 +36,10 @@ import {
type ObjectiveTimelineEvent,
} from "../ObjectiveTimeline";
import { matchScoresFromObjective } from "../objective-timeline-utils";
import {
PlayerStatusTimeline,
type PlayerStatusTimelineSample,
} from "../PlayerStatusTimeline";
import styles from "./MatchTimeline.module.css";
import { type InferredSubstitution, inferSubstitutions } from "./utils";
import type { WeaponPoolWeapon } from "./WeaponPool";
@ -93,6 +97,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,6 +448,22 @@ function TimelineScoreboardSection({
</button>
{isExpanded ? (
<div className={styles.scoreboardPanel}>
{scoreboard.playerStatus && scoreboard.playerStatus.length > 0 ? (
<PlayerStatusTimeline
samples={scoreboard.playerStatus}
teams={[
{
label: teams.alpha.name,
weapons: scoreboard.alpha.map((player) => player.weaponSplId),
},
{
label: teams.bravo.name,
weapons: scoreboard.bravo.map((player) => player.weaponSplId),
},
]}
domain={objectiveEventsDomain(scoreboard.objective)}
/>
) : null}
{scoreboard.objective && scoreboard.objective.length > 0 ? (
<ObjectiveTimeline
events={scoreboard.objective}
@ -464,6 +486,14 @@ function TimelineScoreboardSection({
);
}
function objectiveEventsDomain(
events: ObjectiveTimelineEvent[] | undefined,
): [number, number] | undefined {
if (!events || events.length === 0) return undefined;
const ts = events.map((event) => event.t);
return [Math.min(...ts), Math.max(...ts)];
}
function ScoreboardTable({
name,
players,

View File

@ -299,6 +299,7 @@ function testMatch(partial: Partial<ScannerMatch> = {}): 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]!)) },

View File

@ -37,6 +37,7 @@ function testMatch(partial: Partial<ScannerMatch> = {}): 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<ScannerMatch["playerStatus"]> = {
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],
]);
});
});

View File

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

View File

@ -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<number, AbilityWithUnknown[][]>;
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 }]);

View File

@ -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 = <T>(pair: [T, T]): [T, T] =>
winner === 0 ? [pair[0], pair[1]] : [pair[1], pair[0]];
const firstT = Math.min(...objective.samples.map((sample) => sample.t));
return {
mode: objective.mode,
@ -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 = <T>(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

View File

@ -83,7 +83,13 @@ 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
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; the cast layout is picked by its D-pad camera badges), with
the same `time` value so the two reads pair downstream; its fixtures
live under `tests/fixtures/player-status/`. 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
@ -99,7 +105,12 @@ sequenceDiagram
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. 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.

View File

@ -26,6 +26,10 @@ 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 type { ScoreboardData } from "../core/detectors/scoreboard/index";
import {
SCOREBOARD_OWN_EVENT_TYPE,
@ -40,6 +44,7 @@ import { useEventTimeFormatter } from "./format";
import { MapStartCard } from "./MapStartCard";
import { MinimapCard } from "./MinimapCard";
import { ObjectiveCard } from "./ObjectiveCard";
import { PlayerStatusCard } from "./PlayerStatusCard";
import { ScoreboardCard } from "./ScoreboardCard";
import { ScoreboardOwnCard } from "./ScoreboardOwnCard";
@ -147,6 +152,8 @@ function renderCard(
<MinimapCard {...shared} data={data as MinimapData} />
) : type === OBJECTIVE_EVENT_TYPE ? (
<ObjectiveCard {...shared} data={data as ObjectiveData} />
) : type === PLAYER_STATUS_EVENT_TYPE ? (
<PlayerStatusCard {...shared} data={data as PlayerStatusData} />
) : (
<ScoreboardCard
{...shared}

View File

@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { SendouButton } from "~/components/elements/Button";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { ObjectiveTimeline } from "~/components/ObjectiveTimeline";
import { PlayerStatusTimeline } from "~/components/PlayerStatusTimeline";
import {
listVideoInputs,
openVirtualCamera,
@ -17,6 +18,7 @@ import {
} from "../core/detectors/map-start/index";
import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index";
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
import { PLAYER_STATUS_EVENT_TYPE } from "../core/detectors/objective/player-status";
import { SCOREBOARD_EVENT_TYPES } from "../core/detectors/registry";
import type { DetectedEvent, GateResult } from "../core/detectors/types";
import type { BuiltMatch } from "../core/match-builder";
@ -43,6 +45,7 @@ import { downloadEventsCsv } from "./events-csv";
import { type FixtureData, saveFixture } from "./fixture-export";
import { MatchCard } from "./MatchCard";
import { MatchLobbyTabs } from "./MatchLobbyTabs";
import { objectiveDomain, playerStatusTeams } from "./player-status-view";
import {
aggregateSendStatus,
matchContaining,
@ -197,7 +200,8 @@ export function LivePage({
for (const event of result.events as DetectedEvent<FixtureData>[]) {
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;
@ -407,8 +411,11 @@ export function LivePage({
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,
);
const newest = built === builtMatches.at(-1);
return (
@ -425,6 +432,16 @@ export function LivePage({
: undefined
}
>
{statusSamples.length > 0 ? (
<PlayerStatusTimeline
samples={statusSamples}
teams={playerStatusTeams(
built.match,
SCANNER_TEAM_LABELS,
)}
domain={objectiveDomain(objectiveEvents)}
/>
) : null}
{objectiveEvents.length > 0 ? (
<ObjectiveTimeline
events={objectiveEvents}

View File

@ -0,0 +1,51 @@
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
} from "../core/detectors/objective/player-status";
import { FrameThumb } from "./FrameThumb";
import { formatClock, useEventTimeFormatter } from "./format";
import { MetaPills } from "./MetaChips";
export function PlayerStatusCard(props: {
t: number;
confidence: number;
data: PlayerStatusData;
thumbnail?: string;
detectedAt?: number;
/** lazy loader for the exact analyzed frame — enables fixture export */
getFrame?: () => Promise<Blob | null | undefined>;
onInspect?: () => void;
}) {
const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } =
props;
const side = (index: 0 | 1) =>
data.dead[index]
.map((dead, slot) => (dead ? "✕" : data.special[index][slot] ? "★" : "·"))
.join("");
const formatDetectedAt = useEventTimeFormatter();
return (
<div className="card">
<div className="meta">
<MetaPills
t={t}
confidence={confidence}
type={PLAYER_STATUS_EVENT_TYPE}
label={`players (${data.layout})`}
/>
<span>
{data.time !== null ? `${formatClock(data.time)} · ` : null}
<b>
{side(0)} {side(1)}
</b>
</span>
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
<FrameThumb
thumbnail={thumbnail}
getFrame={getFrame}
onInspect={onInspect}
fixture={{ data, type: PLAYER_STATUS_EVENT_TYPE }}
/>
</div>
</div>
);
}

View File

@ -22,10 +22,12 @@ import { SendouButton } from "~/components/elements/Button";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { ObjectiveTimeline } from "~/components/ObjectiveTimeline";
import { PlayerStatusTimeline } from "~/components/PlayerStatusTimeline";
import { useSearchParam } from "~/modules/search-params/hooks";
import { openSeekScan, probeWebCodecs } from "../capture/vod-frames";
import { connectAbilities } from "../core/ability-harvest";
import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index";
import { PLAYER_STATUS_EVENT_TYPE } from "../core/detectors/objective/player-status";
import {
mergeScanTelemetry,
type ScanTelemetry,
@ -62,6 +64,7 @@ import type { FixtureData } from "./fixture-export";
import { formatTime, useEventDateTimeFormatter } from "./format";
import { MatchCard } from "./MatchCard";
import { MatchLobbyTabs } from "./MatchLobbyTabs";
import { objectiveDomain, playerStatusTeams } from "./player-status-view";
import {
countIngestableMatches,
type SendouUser,
@ -739,8 +742,11 @@ export function VodPage({
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,
);
return (
<MatchCard
@ -756,6 +762,16 @@ export function VodPage({
send?.state === "sent" && link ? { ...send, link } : send
}
>
{statusSamples.length > 0 ? (
<PlayerStatusTimeline
samples={statusSamples}
teams={playerStatusTeams(
built.match,
SCANNER_TEAM_LABELS,
)}
domain={objectiveDomain(objectiveEvents)}
/>
) : null}
{objectiveEvents.length > 0 ? (
<ObjectiveTimeline
events={objectiveEvents}

View File

@ -20,6 +20,10 @@ 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 type { ScoreboardData } from "../core/detectors/scoreboard/index";
import type { ScoreboardBattleLogReplayData } from "../core/detectors/scoreboard-battle-log-replay/index";
import {
@ -39,7 +43,8 @@ export type FixtureData =
| MapStartData
| ScoreboardOwnData
| MinimapData
| ObjectiveData;
| ObjectiveData
| PlayerStatusData;
function isDeath(_data: FixtureData, eventType: string): _data is DeathData {
return eventType === DEATH_EVENT_TYPE;
@ -148,6 +153,22 @@ 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`;
}
// 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) {

View File

@ -0,0 +1,27 @@
/**
* Prop derivation for rendering a ScannerMatch's status samples with the
* shared <PlayerStatusTimeline />, 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];
}
/** The objective chart's x-range, so both timelines share one axis. */
export function objectiveDomain(
events: readonly { t: number }[],
): [number, number] | undefined {
if (events.length === 0) return undefined;
return [events[0]!.t, events[events.length - 1]!.t];
}

View File

@ -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 {
@ -39,6 +44,7 @@ import {
} from "../scoreboard/banner";
import type { ScoreboardResources } from "../scoreboard/index";
import type { DetectedEvent, Detector, GateResult } from "../types";
import { type PlayerStatusData, parsePlayerStatus } from "./player-status";
import {
CONTROL_PLATE_MIN_SATURATION,
GATE_PLATE_MAX_STD,
@ -61,7 +67,7 @@ import {
TIMER_DIGIT_MIN_CONF,
TIMER_DIGIT_MIN_HEIGHT_RATIO,
TIMER_DIGIT_ROI,
TIMER_TEXT_HEIGHT,
TIMER_TEXT_HEIGHTS,
} from "./rois";
export type ObjectiveData = SplatZonesObjectiveData;
@ -126,7 +132,7 @@ interface SideRead {
export function createObjectiveDetector(
resources: ScoreboardResources,
): Detector<ObjectiveData> {
): Detector<ObjectiveData | PlayerStatusData> {
const cv = getCV();
const scoreSets: GlyphSet[] = resources.paintDigits
@ -143,12 +149,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 } {
@ -236,34 +244,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. */
@ -315,7 +336,10 @@ export function createObjectiveDetector(
return { mean: sum / count, saturation: satSum / count };
}
function parse(frame: Mat, t: number): DetectedEvent<ObjectiveData>[] {
function parse(
frame: Mat,
t: number,
): DetectedEvent<ObjectiveData | PlayerStatusData>[] {
const gray = new cv.Mat();
cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY);
@ -349,6 +373,9 @@ export function createObjectiveDetector(
...(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,
@ -372,6 +399,7 @@ export function createObjectiveDetector(
plateFills: sides.map((side) => side.fill),
},
},
parsePlayerStatus(frame, t, timer.value),
];
}

View File

@ -0,0 +1,208 @@
/**
* 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, two pixel-class fractions decide the state (rois.ts documents
* the calibration): an alive icon's body is saturated team ink; holding
* special washes the upper body into a bright pale glow (the shoulder
* probe); a splatted icon is an unsaturated grey/dark X with neither. The
* casted spectator HUD draws the same strip at its own geometry the
* white D-pad camera badges under the right team pick the layout.
*/
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_SHOULDER_GLOW,
STATUS_DPAD_PROBES,
STATUS_GLOW_MIN_VALUE,
STATUS_INK_MIN_SPREAD,
STATUS_INK_MIN_VALUE,
STATUS_READY_MIN_SHOULDER_GLOW,
STATUS_SHOULDER_BOX_CAST,
STATUS_SHOULDER_BOX_POV,
STATUS_SLOT_CENTERS_CAST,
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";
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;
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.
*/
export function parsePlayerStatus(
frame: Mat,
t: number,
time: number | null,
): DetectedEvent<PlayerStatusData> {
const layout: PlayerStatusLayout = isCastLayout(frame) ? "cast" : "pov";
const centers =
layout === "cast" ? STATUS_SLOT_CENTERS_CAST : STATUS_SLOT_CENTERS_POV;
const shoulderBox =
layout === "cast" ? STATUS_SHOULDER_BOX_CAST : STATUS_SHOULDER_BOX_POV;
const bodyBox =
layout === "cast" ? STATUS_BODY_BOX_CAST : STATUS_BODY_BOX_POV;
const sides = 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, shoulder.glow);
}),
) as [SlotRead[], SlotRead[]];
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,
bodyInk: reads.map((read) => Number(read.bodyInk.toFixed(2))),
shoulderGlow: reads.map((read) => Number(read.shoulderGlow.toFixed(2))),
},
};
}
/**
* State from the two fractions, with a confidence scaled by the distance
* to the nearest decision boundary (1 at twice the threshold / at zero).
*/
function classifySlot(bodyInk: number, shoulderGlow: number): SlotRead {
const dead =
bodyInk <= STATUS_DEAD_MAX_BODY_INK &&
shoulderGlow <= STATUS_DEAD_MAX_SHOULDER_GLOW;
const special = !dead && shoulderGlow >= STATUS_READY_MIN_SHOULDER_GLOW;
const confidence = dead
? Math.min(
1,
(STATUS_DEAD_MAX_BODY_INK - bodyInk) / STATUS_DEAD_MAX_BODY_INK,
)
: special
? Math.min(1, shoulderGlow / (STATUS_READY_MIN_SHOULDER_GLOW * 2))
: Math.min(1, bodyInk / (STATUS_DEAD_MAX_BODY_INK * 2));
return { dead, special, confidence, bodyInk, shoulderGlow };
}
/** Ink and glow pixel fractions of a ROI (see rois.ts for the classes). */
function classFractions(frame: Mat, roi: Roi): { ink: number; glow: number } {
const crop = copyRoi(frame, roi);
const { data } = crop;
const channels = crop.channels();
let ink = 0;
let glow = 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++;
count++;
}
crop.delete();
return { ink: ink / count, glow: glow / count };
}
/** All four D-pad probes reading white = the casted spectator layout. */
function isCastLayout(frame: Mat): boolean {
return STATUS_DPAD_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;
});
}

View File

@ -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;
@ -126,3 +129,87 @@ 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 (~103 left vs ~88 right).
/** 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[],
] = [
[542, 646, 750, 852],
[1085, 1173, 1261, 1348],
];
/**
* 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.
*/
export const STATUS_BODY_BOX_POV = { dx: -32, y: 44, w: 64, h: 48 };
export const STATUS_BODY_BOX_CAST = { dx: -32, y: 55, w: 64, h: 43 };
/**
* 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;
/**
* Splatted: body ink under the floor (attested dead <=0.09 vs alive
* >=0.40) with the shoulder-glow guard keeping the near-white ready wash
* (body ink as low as 0.03, glow >=0.40 vs dead <=0.03) out of it.
*/
export const STATUS_DEAD_MAX_BODY_INK = 0.22;
export const STATUS_DEAD_MAX_SHOULDER_GLOW = 0.2;
/** Special ready: shoulder glow past this (attested >=0.40 vs <=0.06). */
export const STATUS_READY_MIN_SHOULDER_GLOW = 0.25;
/**
* Cast-layout discriminator: the spectator HUD always draws white D-pad
* 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.
*/
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_WHITE_MIN_VALUE = 215;
export const STATUS_WHITE_MAX_SPREAD = 40;
export const STATUS_CAST_MIN_DPAD_WHITE = 0.35;

View File

@ -25,6 +25,10 @@ import {
OBJECTIVE_EVENT_TYPE,
type ObjectiveData,
} from "./detectors/objective/index";
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
} from "./detectors/objective/player-status";
import { SCOREBOARD_EVENT_TYPES } from "./detectors/registry";
import type { ScoreboardData } from "./detectors/scoreboard/index";
import {
@ -43,6 +47,8 @@ import type {
ScannerMatchObjective,
ScannerMatchObjectiveSample,
ScannerMatchPlayer,
ScannerMatchPlayerStatus,
ScannerMatchPlayerStatusSample,
ScannerMatchTeam,
} from "./scanner-match";
@ -111,9 +117,10 @@ export function buildScannerMatches<E extends DetectedEvent>(
const nextStage = buildNextStageMap(sorted);
let open: OpenMatch<E> | 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[] = [];
const finalize = (): void => {
if (!open) return;
if (open.scoreboard || open.minimaps.length > 0) {
@ -131,6 +138,7 @@ export function buildScannerMatches<E extends DetectedEvent>(
vote(open.stageVotes, (event.data as MapStartData).stage);
orphanDeaths = [];
orphanObjectives = [];
orphanPlayerStatuses = [];
} else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) {
if (!open) {
open = startMatch();
@ -140,12 +148,16 @@ export function buildScannerMatches<E extends DetectedEvent>(
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.scoreboard = event;
vote(open.stageVotes, (event.data as ScoreboardData).stage);
finalize();
orphanDeaths = [];
orphanObjectives = [];
orphanPlayerStatuses = [];
} else if (event.type === MINIMAP_EVENT_TYPE) {
const stage = (event.data as MinimapData).stage;
if (open) {
@ -171,6 +183,8 @@ export function buildScannerMatches<E extends DetectedEvent>(
(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);
}
}
finalize();
@ -211,10 +225,12 @@ export function ingestSkipReasons<E extends DetectedEvent>(
}
/**
* 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<E extends DetectedEvent>(
built: readonly BuiltMatch<E>[],
@ -222,7 +238,11 @@ export function invalidObjectiveEvents<E extends DetectedEvent>(
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,
),
);
}
@ -291,6 +311,8 @@ interface OpenMatch<E extends DetectedEvent> {
deaths: E[];
/** objective-counter reads; become the match's `objective` samples */
objectives: E[];
/** icon-strip reads; become the match's `playerStatus` samples */
playerStatuses: E[];
scoreboard: E | null;
/**
* per-stage read counts (a MapStart's stage seeds it); the plurality
@ -308,6 +330,7 @@ function startMatch<E extends DetectedEvent>(): OpenMatch<E> {
minimaps: [],
deaths: [],
objectives: [],
playerStatuses: [],
scoreboard: null,
stageVotes: new Map(),
lastMinimapT: null,
@ -357,6 +380,7 @@ function toBuiltMatch<E extends DetectedEvent>(
...open.minimaps,
...open.deaths,
...open.objectives,
...open.playerStatuses,
...(open.scoreboard ? [open.scoreboard] : []),
].sort((a, b) => a.t - b.t);
@ -375,9 +399,25 @@ function toBuiltMatch<E extends DetectedEvent>(
t: event.t,
data: event.data as ObjectiveData,
}));
const playerStatuses = open.playerStatuses.map((event) => ({
t: event.t,
data: event.data as PlayerStatusData,
}));
const minimaps = open.minimaps.map((event) => event.data as MinimapData);
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)
const progress =
mode === null || mode === "SZ"
? buildProgress(
objectives,
playerStatuses,
board,
minimapTeamColors(minimaps),
)
: { objective: null, playerStatus: null };
const match: ScannerMatch = {
startsAt:
@ -391,13 +431,11 @@ function toBuiltMatch<E extends DetectedEvent>(
? 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, minimapTeamColors(minimaps))
: null,
cast:
open.minimaps.some((event) => (event.data as MinimapData).spectator) ||
playerStatuses.some((read) => read.data.layout === "cast"),
objective: progress.objective,
playerStatus: progress.playerStatus,
teams: board
? teamsFromScoreboard(board, deaths)
: teamsFromMinimaps(minimaps, deaths),
@ -419,34 +457,45 @@ function floorOrNull(t: number | undefined): number | null {
}
/**
* The counter reads as `objective` samples 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 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. Broadcast
* replay wipes re-run an earlier moment with the counter overlay intact,
* so reads are first anchored by their projected clock zero (`t + time`),
* and only the dominant anchor cluster the live game survives;
* timerless reads follow their preceding anchored neighbor. A displayed
* count never increases (it shows the team's best remaining), so each
* side's series then keeps only its longest non-increasing run of scores
* surviving OCR blips are voided rather than charted. The whole
* series then goes into `teams` order: a scoreboard-closed match is
* 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.
* 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.
*/
function buildObjective(
function buildProgress(
objectives: readonly { t: number; data: ObjectiveData }[],
playerStatuses: readonly { t: number; data: PlayerStatusData }[],
board: ScoreboardData | undefined,
minimapColors: [InkRgb | null, InkRgb | null] | null,
): ScannerMatchObjective | null {
if (objectives.length === 0) return null;
): {
objective: ScannerMatchObjective | null;
playerStatus: ScannerMatchPlayerStatus | null;
} {
const dominant = dominantAnchorOf([...objectives, ...playerStatuses]);
const live = withoutReplayReads(objectives, dominant);
const liveStatuses = withoutReplayReads(playerStatuses, dominant);
const live = withoutReplayReads(objectives);
const clusterHues = seedClusterHues(live);
const oriented = withMonotonicScores(orientByTeamColor(live, clusterHues));
const swapFlags = readSwapFlags(live, clusterHues);
const oriented = withMonotonicScores(orientObjectives(live, swapFlags));
const swap = board
? board.povIndex !== null
@ -454,17 +503,64 @@ function buildObjective(
: bestCount(oriented, 1) < bestCount(oriented, 0)
: minimapAnchorSwap(clusterHues, minimapColors);
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]],
};
});
return { mode: "SZ", samples };
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: liveStatuses.map((read): ScannerMatchPlayerStatusSample => {
const clusterSwapped = nearestSwapFlag(live, swapFlags, read.t);
const [a, b] =
clusterSwapped !== swap ? ([1, 0] as const) : ([0, 1] as const);
return {
t: Math.max(0, Math.floor(read.t)),
time: read.data.time,
special: [read.data.special[a], read.data.special[b]],
dead: [read.data.dead[a], read.data.dead[b]],
};
}),
};
return { objective, playerStatus };
}
/**
* 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. */
@ -494,23 +590,33 @@ function seedClusterHues(
}
/**
* Assign every read's sides to the color clusters: a read whose ink hues
* sit closer to the clusters crosswise is swapped (the cast switched the
* 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 orientByTeamColor(
function readSwapFlags(
objectives: readonly { t: number; data: ObjectiveData }[],
clusterHues: [number, number] | null,
): OrientedObjectiveRead[] {
): boolean[] {
let previousSwapped = false;
return objectives.map(({ t, data }): OrientedObjectiveRead => {
return objectives.map(({ data }) => {
const swapped = clusterHues
? readSwapped(data, clusterHues, previousSwapped)
: false;
previousSwapped = swapped;
const [a, b] = swapped ? ([1, 0] as const) : ([0, 1] as const);
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,
time: data.time,
@ -581,6 +687,20 @@ function minimapTeamColors(
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
@ -591,15 +711,15 @@ function minimapTeamColors(
* 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: ObjectiveData }>(
objectives: readonly T[],
): T[] {
const anchored = objectives.flatMap((read, i) =>
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 [...objectives];
if (anchored.length === 0) return [...reads];
const dominant = dominantAnchor(anchored.map((read) => read.anchor));
const keptAnchored = new Map(
anchored.map(({ i, anchor }) => [
i,
@ -608,7 +728,7 @@ function withoutReplayReads<T extends { t: number; data: ObjectiveData }>(
);
let previousKept = keptAnchored.get(anchored[0]!.i)!;
return objectives.filter((_, i) => {
return reads.filter((_, i) => {
previousKept = keptAnchored.get(i) ?? previousKept;
return previousKept;
});

View File

@ -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;
@ -89,6 +116,12 @@ export interface ScannerMatch {
* 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]

View File

@ -8,6 +8,10 @@ import {
OBJECTIVE_EVENT_TYPE,
sameObjectiveData,
} from "../detectors/objective/index";
import {
PLAYER_STATUS_EVENT_TYPE,
samePlayerStatusData,
} from "../detectors/objective/player-status";
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";
@ -42,13 +46,21 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
// within one open
// 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
mergeWindowByType: {
Death: 8,
Minimap: 5,
[OBJECTIVE_EVENT_TYPE]: 10,
[PLAYER_STATUS_EVENT_TYPE]: 5,
},
sameEventDataByType: {
[SCOREBOARD_EVENT_TYPE]: sameScoreboardMatch,
[SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE]: sameScoreboardMatch,
[SCOREBOARD_BATTLE_LOG_EVENT_TYPE]: sameScoreboardMatch,
[OBJECTIVE_EVENT_TYPE]: sameObjectiveData,
[PLAYER_STATUS_EVENT_TYPE]: samePlayerStatusData,
},
minConfidence: 0.6,
};

View File

@ -63,6 +63,7 @@ interface ExpectedScoreboard {
| "MapStart"
| "Minimap"
| "Objective"
| "PlayerStatus"
| "none";
data?: {
lobby?: ScannerLobby;
@ -99,6 +100,12 @@ 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 only: which icon-strip geometry the frame shows */
layout?: "pov" | "cast";
/** Minimap only: casted 8-player spectator map screen (not parsed yet) */
spectator?: boolean;
/** Minimap only: own-team callout cards in slot order */

View File

@ -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<typeof scannerMatchObjectiveSchema>,
ScannerMatchObjective
>;
true satisfies MutuallyAssignable<
z.infer<typeof scannerMatchPlayerStatusSchema>,
ScannerMatchPlayerStatus
>;
true satisfies MutuallyAssignable<
z.infer<typeof scannerMatchSchema>,
ScannerMatch

View File

@ -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."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@ -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)."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -13,6 +13,7 @@ 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 { 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";
@ -822,3 +823,137 @@ 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 };
}
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),
]);
const samples = built[0]!.match.playerStatus!.samples;
assert.deepEqual(samples[0]!.dead, [
[true, false, false, false],
[false, false, false, false],
]);
// the same on-screen left side is now the other team
assert.deepEqual(samples[1]!.dead, [
[false, false, false, false],
[true, false, false, false],
]);
assert.equal(built[0]!.match.cast, 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],
);
});

View File

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

View File

@ -18,7 +18,7 @@ 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,
@ -40,10 +40,13 @@ test("objective fixtures exist", () => {
for (const fixture of fixtures) {
test(`objective/${fixture.name}`, async (t) => {
const { gate, events } = await runDetectorOnFixture<ObjectiveData>(
const { gate, events: allEvents } = await runDetectorOnFixture(
detector,
fixture,
);
const events = allEvents.filter(
(event) => event.type === "Objective",
) as DetectedEvent<ObjectiveData>[];
const expectPositive = fixture.expected.event === "Objective";
await t.test("gate", () => {
@ -146,11 +149,12 @@ test("cast fixture pair: team ink hues identify sides across camera swaps", asyn
const colors = [];
for (const fixture of pair) {
const { events } = await runDetectorOnFixture<ObjectiveData>(
detector,
fixture!,
);
const teamColor = events[0]?.data.teamColor;
const { events } = await runDetectorOnFixture(detector, fixture!);
const teamColor = (
events.find((event) => event.type === "Objective") as
| DetectedEvent<ObjectiveData>
| undefined
)?.data.teamColor;
assert.ok(teamColor?.[0] && teamColor[1], "side ink color unreadable");
colors.push([teamColor[0], teamColor[1]] as const);
}

View File

@ -0,0 +1,164 @@
/**
* 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 detector = createObjectiveDetector(resources);
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) => {
const { gate, events } = await runDetectorOnFixture(detector, 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<PlayerStatusData>
| 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<unknown>][] = [
["scoreboard", createScoreboardDetector(resources) as Detector<unknown>],
[
"scoreboard-battle-log-replay",
createScoreboardBattleLogReplayDetector(resources) as Detector<unknown>,
],
[
"scoreboard-own",
createScoreboardOwnDetector(resources) as Detector<unknown>,
],
["death", createDeathDetector(resources) as Detector<unknown>],
["map-start", createMapStartDetector(resources) as Detector<unknown>],
["minimap", createMinimapDetector(resources) as Detector<unknown>],
];
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;
}

View File

@ -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 = <T,>(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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -186,6 +186,8 @@
"objectiveTimeline.timeLeft": "",
"objectiveTimeline.penalty": "",
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"maps.createMapList": "יצירת רשימת מפות",
"maps.halfSz": "50% SZ",
"maps.mapPool": "מאגר מפות",

View File

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

View File

@ -186,6 +186,8 @@
"objectiveTimeline.timeLeft": "",
"objectiveTimeline.penalty": "",
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"maps.createMapList": "ステージリストを作る",
"maps.halfSz": "半分エリア",
"maps.mapPool": "選択可能なステージ",

View File

@ -186,6 +186,8 @@
"objectiveTimeline.timeLeft": "",
"objectiveTimeline.penalty": "",
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"maps.createMapList": "맵 목록 생성",
"maps.halfSz": "에어리어 50%",
"maps.mapPool": "맵 풀",

View File

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

View File

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

View File

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

View File

@ -186,6 +186,8 @@
"objectiveTimeline.timeLeft": "",
"objectiveTimeline.penalty": "",
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"maps.createMapList": "Создать список карт",
"maps.halfSz": "50% Зон",
"maps.mapPool": "Пул карт",

View File

@ -186,6 +186,8 @@
"objectiveTimeline.timeLeft": "",
"objectiveTimeline.penalty": "",
"objectiveTimeline.inControl": "",
"playerStatusTimeline.splatted": "",
"playerStatusTimeline.specialReady": "",
"maps.createMapList": "创建场地列表",
"maps.halfSz": "真格区域占 50%",
"maps.mapPool": "场地池",