From a56f49042ebc4903a1d13b9ed6a32bc574d5900a Mon Sep 17 00:00:00 2001
From: Kalle <38327916+Sendouc@users.noreply.github.com>
Date: Sun, 9 Aug 2026 07:07:58 +0300
Subject: [PATCH] Death/Special
---
.../PlayerStatusTimeline.module.css | 87 +++++++
app/components/PlayerStatusTimeline.tsx | 158 ++++++++++++
app/components/match-page/MatchTimeline.tsx | 30 +++
.../ScannerIngestRepository.server.test.ts | 1 +
.../scanner-ingest/core/Matches.test.ts | 51 ++++
app/features/scanner-ingest/core/Matches.ts | 30 +++
.../scanner-ingest/core/Scoreboards.test.ts | 70 ++++++
.../scanner-ingest/core/Scoreboards.ts | 62 ++++-
app/features/scanner/README.md | 15 +-
app/features/scanner/components/EventCard.tsx | 7 +
app/features/scanner/components/LivePage.tsx | 21 +-
.../scanner/components/PlayerStatusCard.tsx | 51 ++++
app/features/scanner/components/VodPage.tsx | 18 +-
.../scanner/components/fixture-export.ts | 23 +-
.../scanner/components/player-status-view.ts | 27 ++
.../scanner/core/detectors/objective/index.ts | 92 ++++---
.../core/detectors/objective/player-status.ts | 208 ++++++++++++++++
.../scanner/core/detectors/objective/rois.ts | 93 ++++++-
app/features/scanner/core/match-builder.ts | 230 +++++++++++++-----
app/features/scanner/core/scanner-match.ts | 33 +++
app/features/scanner/core/timeline/index.ts | 16 +-
app/features/scanner/node/fixtures.ts | 7 +
app/features/scanner/scanner-schemas.ts | 26 ++
.../expected.json | 18 ++
.../frame.png | Bin 0 -> 2129051 bytes
.../expected.json | 18 ++
.../pov-wahoo-world-special-dead/frame.png | Bin 0 -> 1129209 bytes
.../scanner/tests/match-builder.test.ts | 135 ++++++++++
app/features/scanner/tests/match-sets.test.ts | 1 +
app/features/scanner/tests/objective.test.ts | 18 +-
.../scanner/tests/player-status.test.ts | 164 +++++++++++++
.../components/TournamentMatchTabs.tsx | 22 ++
locales/da/common.json | 2 +
locales/de/common.json | 2 +
locales/en/common.json | 2 +
locales/es-ES/common.json | 2 +
locales/es-US/common.json | 2 +
locales/fr-CA/common.json | 2 +
locales/fr-EU/common.json | 2 +
locales/he/common.json | 2 +
locales/it/common.json | 2 +
locales/ja/common.json | 2 +
locales/ko/common.json | 2 +
locales/nl/common.json | 2 +
locales/pl/common.json | 2 +
locales/pt-BR/common.json | 2 +
locales/ru/common.json | 2 +
locales/zh/common.json | 2 +
48 files changed, 1656 insertions(+), 108 deletions(-)
create mode 100644 app/components/PlayerStatusTimeline.module.css
create mode 100644 app/components/PlayerStatusTimeline.tsx
create mode 100644 app/features/scanner/components/PlayerStatusCard.tsx
create mode 100644 app/features/scanner/components/player-status-view.ts
create mode 100644 app/features/scanner/core/detectors/objective/player-status.ts
create mode 100644 app/features/scanner/tests/fixtures/player-status/cast-sws26-three-dead-one-special/expected.json
create mode 100644 app/features/scanner/tests/fixtures/player-status/cast-sws26-three-dead-one-special/frame.png
create mode 100644 app/features/scanner/tests/fixtures/player-status/pov-wahoo-world-special-dead/expected.json
create mode 100644 app/features/scanner/tests/fixtures/player-status/pov-wahoo-world-special-dead/frame.png
create mode 100644 app/features/scanner/tests/player-status.test.ts
diff --git a/app/components/PlayerStatusTimeline.module.css b/app/components/PlayerStatusTimeline.module.css
new file mode 100644
index 000000000..8b9c240ef
--- /dev/null
+++ b/app/components/PlayerStatusTimeline.module.css
@@ -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;
+}
diff --git a/app/components/PlayerStatusTimeline.tsx b/app/components/PlayerStatusTimeline.tsx
new file mode 100644
index 000000000..7b64f3064
--- /dev/null
+++ b/app/components/PlayerStatusTimeline.tsx
@@ -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 (
+
+
+
+
+ {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.
+ */
+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;
+}
diff --git a/app/components/match-page/MatchTimeline.tsx b/app/components/match-page/MatchTimeline.tsx
index 41c3d82c2..591606fb1 100644
--- a/app/components/match-page/MatchTimeline.tsx
+++ b/app/components/match-page/MatchTimeline.tsx
@@ -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({
{isExpanded ? (
+ {scoreboard.playerStatus && scoreboard.playerStatus.length > 0 ? (
+
player.weaponSplId),
+ },
+ {
+ label: teams.bravo.name,
+ weapons: scoreboard.bravo.map((player) => player.weaponSplId),
+ },
+ ]}
+ domain={objectiveEventsDomain(scoreboard.objective)}
+ />
+ ) : null}
{scoreboard.objective && scoreboard.objective.length > 0 ? (
event.t);
+ return [Math.min(...ts), Math.max(...ts)];
+}
+
function ScoreboardTable({
name,
players,
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 db91cb3b4..fd7c2e0fc 100644
--- a/app/features/scanner/README.md
+++ b/app/features/scanner/README.md
@@ -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.
diff --git a/app/features/scanner/components/EventCard.tsx b/app/features/scanner/components/EventCard.tsx
index 73583c07e..22f87cce1 100644
--- a/app/features/scanner/components/EventCard.tsx
+++ b/app/features/scanner/components/EventCard.tsx
@@ -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(
) : type === OBJECTIVE_EVENT_TYPE ? (
+ ) : type === PLAYER_STATUS_EVENT_TYPE ? (
+
) : (
[]) {
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 ? (
+
+ ) : null}
{objectiveEvents.length > 0 ? (
Promise;
+ 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 (
+
+
+
+
+ {data.time !== null ? `${formatClock(data.time)} · ` : null}
+
+ {side(0)} – {side(1)}
+
+
+ {detectedAt ? {formatDetectedAt(detectedAt)} : null}
+
+
+
+ );
+}
diff --git a/app/features/scanner/components/VodPage.tsx b/app/features/scanner/components/VodPage.tsx
index 95f975246..eaa590d2c 100644
--- a/app/features/scanner/components/VodPage.tsx
+++ b/app/features/scanner/components/VodPage.tsx
@@ -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 (
+ {statusSamples.length > 0 ? (
+
+ ) : null}
{objectiveEvents.length > 0 ? (
, 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];
+}
diff --git a/app/features/scanner/core/detectors/objective/index.ts b/app/features/scanner/core/detectors/objective/index.ts
index 61c636bd8..d6b298f3a 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 {
@@ -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 {
+): Detector {
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[] {
+ function parse(
+ frame: Mat,
+ t: number,
+ ): DetectedEvent[] {
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),
];
}
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..974938f92
--- /dev/null
+++ b/app/features/scanner/core/detectors/objective/player-status.ts
@@ -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 {
+ 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;
+ });
+}
diff --git a/app/features/scanner/core/detectors/objective/rois.ts b/app/features/scanner/core/detectors/objective/rois.ts
index fe7e2ab0d..1a7ce3583 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;
@@ -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;
diff --git a/app/features/scanner/core/match-builder.ts b/app/features/scanner/core/match-builder.ts
index f704fe939..e65da22a5 100644
--- a/app/features/scanner/core/match-builder.ts
+++ b/app/features/scanner/core/match-builder.ts
@@ -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(
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[] = [];
const finalize = (): void => {
if (!open) return;
if (open.scoreboard || open.minimaps.length > 0) {
@@ -131,6 +138,7 @@ export function buildScannerMatches(
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(
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(
(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(
}
/**
- * 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[],
@@ -222,7 +238,11 @@ 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,
+ ),
);
}
@@ -291,6 +311,8 @@ 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[];
scoreboard: E | null;
/**
* per-stage read counts (a MapStart's stage seeds it); the plurality
@@ -308,6 +330,7 @@ function startMatch(): OpenMatch {
minimaps: [],
deaths: [],
objectives: [],
+ playerStatuses: [],
scoreboard: null,
stageVotes: new Map(),
lastMinimapT: null,
@@ -357,6 +380,7 @@ function toBuiltMatch(
...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(
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(
? 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(
- 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(
);
let previousKept = keptAnchored.get(anchored[0]!.i)!;
- return objectives.filter((_, i) => {
+ return reads.filter((_, i) => {
previousKept = keptAnchored.get(i) ?? previousKept;
return previousKept;
});
diff --git a/app/features/scanner/core/scanner-match.ts b/app/features/scanner/core/scanner-match.ts
index e5ce29a03..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;
@@ -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]
diff --git a/app/features/scanner/core/timeline/index.ts b/app/features/scanner/core/timeline/index.ts
index c07c5a8cc..57b1c7d88 100644
--- a/app/features/scanner/core/timeline/index.ts
+++ b/app/features/scanner/core/timeline/index.ts
@@ -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,
};
diff --git a/app/features/scanner/node/fixtures.ts b/app/features/scanner/node/fixtures.ts
index 8806eaa50..183a7dcc4 100644
--- a/app/features/scanner/node/fixtures.ts
+++ b/app/features/scanner/node/fixtures.ts
@@ -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 */
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/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 0000000000000000000000000000000000000000..d473fae48ade7961a9fcf05dd9d27dd19bb0c05d
GIT binary patch
literal 2129051
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