From 9e3fa42d41b4cf35172dd215b506a7a14f76a2b4 Mon Sep 17 00:00:00 2001
From: Kalle <38327916+Sendouc@users.noreply.github.com>
Date: Sun, 9 Aug 2026 10:51:48 +0300
Subject: [PATCH] Dead/special from minimap
---
app/components/ObjectiveTimeline.tsx | 12 +-
app/components/PlayerStatusTimeline.tsx | 11 ++
app/components/objective-timeline-utils.ts | 13 ++
app/features/scanner/README.md | 7 +-
.../scanner/components/MinimapCard.tsx | 10 +
app/features/scanner/components/events-csv.ts | 5 +-
.../scanner/components/fixture-export.ts | 4 +
app/features/scanner/components/styles.css | 23 +++
.../scanner/core/detectors/minimap/index.ts | 47 ++++-
app/features/scanner/core/match-builder.ts | 110 +++++++++--
app/features/scanner/core/timeline/index.ts | 10 +-
app/features/scanner/node/fixtures.ts | 10 +-
.../scanner/tests/dedupe-events.test.ts | 22 ++-
.../minimap/snix-special-ready/expected.json | 32 ++-
.../spectator-area-cup-round1/expected.json | 32 ++-
.../expected.json | 32 ++-
.../minimap/spectator-pxg/expected.json | 32 ++-
.../spectator-sws26-swiss/expected.json | 182 +++++++++++-------
.../stonks-cross-both-teams/expected.json | 32 ++-
.../scanner/tests/match-builder.test.ts | 90 ++++++++-
app/features/scanner/tests/minimap.test.ts | 20 ++
.../scanner/tests/sendou-upload.test.ts | 10 +-
scripts/scanner/report.ts | 15 ++
23 files changed, 613 insertions(+), 148 deletions(-)
diff --git a/app/components/ObjectiveTimeline.tsx b/app/components/ObjectiveTimeline.tsx
index 42584e904..587d75ddc 100644
--- a/app/components/ObjectiveTimeline.tsx
+++ b/app/components/ObjectiveTimeline.tsx
@@ -30,7 +30,7 @@ import { Line } from "react-chartjs-2";
import { useTranslation } from "react-i18next";
import { useThemeColors } from "~/hooks/useThemeColors";
import styles from "./ObjectiveTimeline.module.css";
-import { smoothPenalties } from "./objective-timeline-utils";
+import { formatElapsed, smoothPenalties } from "./objective-timeline-utils";
ChartJS.register(
LinearScale,
@@ -266,16 +266,6 @@ function gridColor(
return value === 0 ? colors.borderHigh : colors.border;
}
-/** Position on the x-axis: m:ss, growing an hours part only when needed. */
-function formatElapsed(seconds: number): string {
- const hours = Math.floor(seconds / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
- const rest = String(Math.floor(seconds % 60)).padStart(2, "0");
- return hours > 0
- ? `${hours}:${String(minutes).padStart(2, "0")}:${rest}`
- : `${minutes}:${rest}`;
-}
-
/** the match timer's M:SS (215 → "3:35") */
function formatClock(seconds: number): string {
const minutes = Math.floor(seconds / 60);
diff --git a/app/components/PlayerStatusTimeline.tsx b/app/components/PlayerStatusTimeline.tsx
index 7b64f3064..171bf1c81 100644
--- a/app/components/PlayerStatusTimeline.tsx
+++ b/app/components/PlayerStatusTimeline.tsx
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { abilityImageUrl } from "~/utils/urls";
import { Image, WeaponImage } from "./Image";
+import { formatElapsed } from "./objective-timeline-utils";
import styles from "./PlayerStatusTimeline.module.css";
/** Consecutive reads further apart than this leave an unknown gap. */
@@ -59,6 +60,8 @@ export function PlayerStatusTimeline({
const leftOf = (span: StatusSpan) => `${((span.start - min) / range) * 100}%`;
const widthOf = (span: StatusSpan) =>
`${((span.end - span.start) / range) * 100}%`;
+ const titleOf = (label: string, span: StatusSpan) =>
+ `${label} · ${formatElapsed(span.start)}–${formatElapsed(span.end)}`;
return (
@@ -85,6 +88,10 @@ export function PlayerStatusTimeline({
key={`d${i}`}
className={styles.spanDead}
style={{ left: leftOf(span), width: widthOf(span) }}
+ title={titleOf(
+ t("common:playerStatusTimeline.splatted"),
+ span,
+ )}
/>
),
)}
@@ -96,6 +103,10 @@ export function PlayerStatusTimeline({
key={`s${i}`}
className={styles.spanSpecial}
style={{ left: leftOf(span), width: widthOf(span) }}
+ title={titleOf(
+ t("common:playerStatusTimeline.specialReady"),
+ span,
+ )}
/>
))}
diff --git a/app/components/objective-timeline-utils.ts b/app/components/objective-timeline-utils.ts
index ebee39873..592cf754a 100644
--- a/app/components/objective-timeline-utils.ts
+++ b/app/components/objective-timeline-utils.ts
@@ -90,6 +90,19 @@ export function matchScoresFromObjective(
return [lastCountTaken(0), lastCountTaken(1)];
}
+/**
+ * Seconds into the source formatted for display: m:ss, growing an hours
+ * part only when needed.
+ */
+export function formatElapsed(seconds: number): string {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const rest = String(Math.floor(seconds % 60)).padStart(2, "0");
+ return hours > 0
+ ? `${hours}:${String(minutes).padStart(2, "0")}:${rest}`
+ : `${minutes}:${rest}`;
+}
+
function medianFilterValues(
values: readonly (number | null)[],
): (number | null)[] {
diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md
index fd7c2e0fc..4d55bf87b 100644
--- a/app/features/scanner/README.md
+++ b/app/features/scanner/README.md
@@ -109,7 +109,12 @@ sequenceDiagram
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
+ shared with the match page) above the objective chart. Minimap reads
+ feed the same samples: every card/row carries `dead` (respawn
+ cross-out) and `specialReady` (special camo) flags, merged in timerless
+ on the shared replay anchor — and mode-agnostic, so a known non-SZ
+ match keeps its minimap-sourced samples while its counter/status
+ misreads are voided. Parsing details
are in each detector's module
header; accuracy-critical matching internals in `core/glyphs.ts` and
`core/detectors/scoreboard/weapons.ts` — read those before touching
diff --git a/app/features/scanner/components/MinimapCard.tsx b/app/features/scanner/components/MinimapCard.tsx
index 50c38489d..f91c91894 100644
--- a/app/features/scanner/components/MinimapCard.tsx
+++ b/app/features/scanner/components/MinimapCard.tsx
@@ -69,6 +69,16 @@ function PlayerRow({
?
)}
{player.name ?? ""}
+ {player.dead ? (
+
+ ✕
+
+ ) : null}
+ {player.specialReady ? (
+
+ ★
+
+ ) : null}
diff --git a/app/features/scanner/components/events-csv.ts b/app/features/scanner/components/events-csv.ts
index bcc8cc556..6078cc646 100644
--- a/app/features/scanner/components/events-csv.ts
+++ b/app/features/scanner/components/events-csv.ts
@@ -93,9 +93,12 @@ function formatMinimapPlayers(data: MinimapData): string {
name: string | null;
weaponId: number | null;
abilities: (string | null)[];
+ dead: boolean;
+ specialReady: boolean;
},
) =>
- `${label} ${p.name ?? "?"} · ${mainWeaponLabel(p.weaponId as MainWeaponId | null) ?? "?"} · ${formatMinimapAbilities(p.abilities)}`;
+ `${label} ${p.name ?? "?"} · ${mainWeaponLabel(p.weaponId as MainWeaponId | null) ?? "?"} · ${formatMinimapAbilities(p.abilities)}` +
+ `${p.dead ? " · splatted" : ""}${p.specialReady ? " · special" : ""}`;
return [
...data.teammates.map((p) => fmt(p.slot, p)),
...data.enemies.map((p, i) => fmt(`enemy${i + 1}`, p)),
diff --git a/app/features/scanner/components/fixture-export.ts b/app/features/scanner/components/fixture-export.ts
index 4bb33c810..df13ad2e1 100644
--- a/app/features/scanner/components/fixture-export.ts
+++ b/app/features/scanner/components/fixture-export.ts
@@ -123,12 +123,16 @@ function buildExpectedJson(
weaponLabel: mainWeaponLabel(p.weaponId),
weaponId: p.weaponId,
abilities: p.abilities,
+ dead: p.dead,
+ specialReady: p.specialReady,
})),
enemies: minimap.enemies.map((p) => ({
...(minimap.spectator && { name: p.name }),
weaponLabel: mainWeaponLabel(p.weaponId),
weaponId: p.weaponId,
abilities: p.abilities,
+ dead: p.dead,
+ specialReady: p.specialReady,
})),
},
},
diff --git a/app/features/scanner/components/styles.css b/app/features/scanner/components/styles.css
index ac8070c8c..5b45b5e78 100644
--- a/app/features/scanner/components/styles.css
+++ b/app/features/scanner/components/styles.css
@@ -355,6 +355,29 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
gap: var(--s-0-5);
flex-shrink: 0;
}
+
+ & .status-chip {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ border-radius: var(--radius-full);
+ font-size: var(--font-2xs);
+ font-weight: var(--weight-bold);
+ line-height: 1;
+
+ &.dead {
+ background: var(--color-error-low);
+ color: var(--color-error-high);
+ }
+
+ &.special {
+ background: var(--color-warning-low);
+ color: var(--color-warning-high);
+ }
+ }
}
/* d-pad chevron / face-button pucks, same round-solid language as weapon icons */
diff --git a/app/features/scanner/core/detectors/minimap/index.ts b/app/features/scanner/core/detectors/minimap/index.ts
index 343d846f6..4cfa5dc41 100644
--- a/app/features/scanner/core/detectors/minimap/index.ts
+++ b/app/features/scanner/core/detectors/minimap/index.ts
@@ -3,11 +3,12 @@
* own-team callout cards (name, main weapon, the three main-ability
* badges), the enemy panel rows (weapon, abilities; the game shows no
* enemy names) — plus the stage, matched from the drawn map (stage.ts).
- * The goal is the most complete read of every card/row; per-match state
- * (respawn cross-outs, special charge, map control) is deliberately not
- * reported.
+ * The goal is the most complete read of every card/row; map control is
+ * deliberately not reported.
*
- * Two screen states still steer the reads without being emitted:
+ * Two per-player screen states are reported (`dead`, `specialReady` — the
+ * match builder merges them into the death/special timeline alongside the
+ * icon-strip PlayerStatus reads) and steer the reads themselves:
* - a respawning player's card is struck through with a large team-color
* X that covers the name and badges (own cards also lose the weapon;
* enemy rows keep theirs — the X spares the row's weapon icon). Reading
@@ -96,6 +97,10 @@ export interface MinimapTeammate {
* unreadable badge); empty when a respawn cross-out sits over the badges
*/
abilities: (AbilityWithUnknown | null)[];
+ /** struck through with the respawn cross-out at the read */
+ dead: boolean;
+ /** on the light camo surface of a charged special at the read */
+ specialReady: boolean;
}
export interface MinimapEnemy {
@@ -107,6 +112,10 @@ export interface MinimapEnemy {
/** readable even on struck rows: the cross-out spares the weapon icon */
weaponId: MainWeaponId | null;
abilities: (AbilityWithUnknown | null)[];
+ /** struck through with the respawn cross-out at the read */
+ dead: boolean;
+ /** on the light camo surface of a charged special at the read */
+ specialReady: boolean;
}
export interface MinimapData {
@@ -139,6 +148,30 @@ export interface MinimapData {
export const MINIMAP_EVENT_TYPE = "Minimap";
+/**
+ * Timeline content guard: minimap frames inside the merge window collapse
+ * only while every card/row keeps its dead/special state, so each flip a
+ * map-open catches (a respawn, a special charged or spent) stays its own
+ * event. Names, weapons and badges are not compared — OCR wobble on an
+ * unchanged screen is still the same state.
+ */
+export function sameMinimapStatusData(a: unknown, b: unknown): boolean {
+ const da = a as MinimapData;
+ const db = b as MinimapData;
+ const sameSide = (
+ xs: readonly { dead: boolean; specialReady: boolean }[],
+ ys: readonly { dead: boolean; specialReady: boolean }[],
+ ): boolean =>
+ xs.length === ys.length &&
+ xs.every(
+ (x, i) =>
+ x.dead === ys[i]!.dead && x.specialReady === ys[i]!.specialReady,
+ );
+ return (
+ sameSide(da.teammates, db.teammates) && sameSide(da.enemies, db.enemies)
+ );
+}
+
/** Badge match below this is reported as null (kept in debug). */
const ABILITY_MIN_SCORE = 0.45;
@@ -407,6 +440,8 @@ export function createMinimapDetector(
name,
weaponId: matched ? toMainWeaponId(matched.id) : null,
abilities,
+ dead: occluded,
+ specialReady: lightSurface,
};
if (dx === 0) {
teammates.push({ slot: SPECTATOR_SLOTS[row]!, ...fields });
@@ -563,6 +598,8 @@ export function createMinimapDetector(
name,
weaponId: matched ? toMainWeaponId(matched.id) : null,
abilities,
+ dead: occluded,
+ specialReady: lightSurface,
});
}
debug.cards = cardDebug;
@@ -627,6 +664,8 @@ export function createMinimapDetector(
name: null,
weaponId: matched ? toMainWeaponId(matched.id) : null,
abilities,
+ dead: occluded,
+ specialReady: lightSurface,
});
}
debug.enemies = enemyDebug;
diff --git a/app/features/scanner/core/match-builder.ts b/app/features/scanner/core/match-builder.ts
index e65da22a5..84c709bf3 100644
--- a/app/features/scanner/core/match-builder.ts
+++ b/app/features/scanner/core/match-builder.ts
@@ -28,6 +28,7 @@ import {
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
+ type PlayerStatusFlags,
} from "./detectors/objective/player-status";
import { SCOREBOARD_EVENT_TYPES } from "./detectors/registry";
import type { ScoreboardData } from "./detectors/scoreboard/index";
@@ -403,21 +404,25 @@ function toBuiltMatch(
t: event.t,
data: event.data as PlayerStatusData,
}));
- const minimaps = open.minimaps.map((event) => event.data as MinimapData);
+ const minimapReads = open.minimaps.map((event) => ({
+ t: event.t,
+ data: event.data as MinimapData,
+ }));
+ const minimaps = minimapReads.map((read) => read.data);
const mode = board?.mode ?? start?.mode ?? null;
// only the SZ counter is parsed — reads on a known other-mode match are
// misreads of a lookalike overlay, not progress data (statuses included:
- // they only ever ride along with counter reads)
- const progress =
- mode === null || mode === "SZ"
- ? buildProgress(
- objectives,
- playerStatuses,
- board,
- minimapTeamColors(minimaps),
- )
- : { objective: null, playerStatus: null };
+ // they only ever ride along with counter reads). Minimap card states are
+ // mode-agnostic, so they feed the status samples regardless
+ const counterModeValid = mode === null || mode === "SZ";
+ const progress = buildProgress(
+ counterModeValid ? objectives : [],
+ counterModeValid ? playerStatuses : [],
+ minimapReads,
+ board,
+ minimapTeamColors(minimaps),
+ );
const match: ScannerMatch = {
startsAt:
@@ -479,10 +484,22 @@ function floorOrNull(t: number | undefined): number | null {
* no signal the first read's arrangement stands. A side's four slots keep
* their on-screen left-to-right order through a side swap — whether the
* game mirrors slot order across sides is unattested so far.
+ *
+ * Minimap reads contribute status samples too (their card cross-out and
+ * special-camo states), interleaved with the icon-strip reads on the same
+ * replay-wipe anchor (timerless, so each follows its anchored neighbor).
+ * Their sides are own/alpha-vs-enemy/bravo — camera-stable, unlike the
+ * plates — so they skip the per-read cluster orientation and map to
+ * `teams` through the same minimap ink anchor the whole match uses
+ * (identity on a minimap-grouped match by construction). Whether the
+ * minimap's card order matches the icon strip's slot order within a side
+ * is unattested so far; both follow the order `teams` players are seated
+ * in for their respective sources.
*/
function buildProgress(
objectives: readonly { t: number; data: ObjectiveData }[],
playerStatuses: readonly { t: number; data: PlayerStatusData }[],
+ minimapReads: readonly { t: number; data: MinimapData }[],
board: ScoreboardData | undefined,
minimapColors: [InkRgb | null, InkRgb | null] | null,
): {
@@ -491,7 +508,17 @@ function buildProgress(
} {
const dominant = dominantAnchorOf([...objectives, ...playerStatuses]);
const live = withoutReplayReads(objectives, dominant);
- const liveStatuses = withoutReplayReads(playerStatuses, dominant);
+ const statusReads = [
+ ...playerStatuses.map(
+ (read): StatusRead => ({
+ t: read.t,
+ fromMinimap: false,
+ data: read.data,
+ }),
+ ),
+ ...minimapStatusReads(minimapReads),
+ ].sort((a, b) => a.t - b.t);
+ const liveStatuses = withoutReplayReads(statusReads, dominant);
const clusterHues = seedClusterHues(live);
const swapFlags = readSwapFlags(live, clusterHues);
@@ -502,6 +529,7 @@ function buildProgress(
? board.povIndex >= PLAYERS_PER_TEAM
: bestCount(oriented, 1) < bestCount(oriented, 0)
: minimapAnchorSwap(clusterHues, minimapColors);
+ const minimapSwapped = swap !== minimapAnchorSwap(clusterHues, minimapColors);
const objective =
oriented.length === 0
@@ -525,9 +553,10 @@ function buildProgress(
? 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);
+ const swapped = read.fromMinimap
+ ? minimapSwapped
+ : nearestSwapFlag(live, swapFlags, read.t) !== swap;
+ const [a, b] = swapped ? ([1, 0] as const) : ([0, 1] as const);
return {
t: Math.max(0, Math.floor(read.t)),
time: read.data.time,
@@ -540,6 +569,57 @@ function buildProgress(
return { objective, playerStatus };
}
+/** A status read from either source, sides as read (pre-orientation). */
+interface StatusRead {
+ t: number;
+ /** minimap sides are own/enemy — camera-stable, unlike the HUD plates */
+ fromMinimap: boolean;
+ data: {
+ time: number | null;
+ special: [PlayerStatusFlags, PlayerStatusFlags];
+ dead: [PlayerStatusFlags, PlayerStatusFlags];
+ };
+}
+
+/**
+ * The minimap reads' card/row states as status reads: own/alpha side
+ * first, slots in card order — the same order `teamsFromMinimaps` seats
+ * players — with absent cards padded false (nothing to chart, never a
+ * fabricated state). A read that saw no cards at all identifies nobody
+ * and contributes nothing.
+ */
+function minimapStatusReads(
+ minimapReads: readonly { t: number; data: MinimapData }[],
+): StatusRead[] {
+ return minimapReads.flatMap((read): StatusRead[] => {
+ const { teammates, enemies } = read.data;
+ if (teammates.length === 0 && enemies.length === 0) return [];
+ return [
+ {
+ t: read.t,
+ fromMinimap: true,
+ data: {
+ time: null,
+ special: [
+ sideFlags(teammates, "specialReady"),
+ sideFlags(enemies, "specialReady"),
+ ],
+ dead: [sideFlags(teammates, "dead"), sideFlags(enemies, "dead")],
+ },
+ },
+ ];
+ });
+}
+
+function sideFlags(
+ players: readonly { dead: boolean; specialReady: boolean }[],
+ key: "dead" | "specialReady",
+): PlayerStatusFlags {
+ return [0, 1, 2, 3].map(
+ (slot) => players[slot]?.[key] ?? false,
+ ) as PlayerStatusFlags;
+}
+
/**
* The cluster-orientation flag of the counter read nearest in time —
* status reads are emitted off the same frames as counter reads, so the
diff --git a/app/features/scanner/core/timeline/index.ts b/app/features/scanner/core/timeline/index.ts
index 57b1c7d88..bad031ae2 100644
--- a/app/features/scanner/core/timeline/index.ts
+++ b/app/features/scanner/core/timeline/index.ts
@@ -4,6 +4,10 @@
* highest-confidence version; events below a confidence floor are dropped.
*/
+import {
+ MINIMAP_EVENT_TYPE,
+ sameMinimapStatusData,
+} from "../detectors/minimap/index";
import {
OBJECTIVE_EVENT_TYPE,
sameObjectiveData,
@@ -43,7 +47,8 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
// of one death land within the window while consecutive deaths are outside;
// players flick the map open for 1-3s and each open is a fresh sample
// (slots read differently across opens), so minimap frames merge only
- // within one open
+ // within one open — and a dead/special flip caught mid-open stays its
+ // own event via the content guard
// objective counter reads repeat every check second; the content guard
// below keeps every actual change while the window collapses static
// stretches into one event per state. Player statuses can revisit an
@@ -51,7 +56,7 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
// window must stay under that
mergeWindowByType: {
Death: 8,
- Minimap: 5,
+ [MINIMAP_EVENT_TYPE]: 5,
[OBJECTIVE_EVENT_TYPE]: 10,
[PLAYER_STATUS_EVENT_TYPE]: 5,
},
@@ -59,6 +64,7 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
[SCOREBOARD_EVENT_TYPE]: sameScoreboardMatch,
[SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE]: sameScoreboardMatch,
[SCOREBOARD_BATTLE_LOG_EVENT_TYPE]: sameScoreboardMatch,
+ [MINIMAP_EVENT_TYPE]: sameMinimapStatusData,
[OBJECTIVE_EVENT_TYPE]: sameObjectiveData,
[PLAYER_STATUS_EVENT_TYPE]: samePlayerStatusData,
},
diff --git a/app/features/scanner/node/fixtures.ts b/app/features/scanner/node/fixtures.ts
index 183a7dcc4..98d41c408 100644
--- a/app/features/scanner/node/fixtures.ts
+++ b/app/features/scanner/node/fixtures.ts
@@ -36,12 +36,16 @@ interface ExpectedPlayer {
}
interface ExpectedMinimapTeammate {
- slot?: "up" | "left" | "right" | "self";
+ slot?: "up" | "left" | "right" | "self" | "down";
name?: string | null;
/** informational for the human corrector; tests compare weaponId */
weaponLabel?: string | null;
weaponId?: MainWeaponId | null;
abilities?: (AbilityWithUnknown | null)[];
+ /** struck through with the respawn cross-out */
+ dead?: boolean;
+ /** on the light camo surface of a charged special */
+ specialReady?: boolean;
}
interface ExpectedMinimapEnemy {
@@ -51,6 +55,10 @@ interface ExpectedMinimapEnemy {
weaponLabel?: string | null;
weaponId?: MainWeaponId | null;
abilities?: (AbilityWithUnknown | null)[];
+ /** struck through with the respawn cross-out */
+ dead?: boolean;
+ /** on the light camo surface of a charged special */
+ specialReady?: boolean;
}
interface ExpectedScoreboard {
diff --git a/app/features/scanner/tests/dedupe-events.test.ts b/app/features/scanner/tests/dedupe-events.test.ts
index 8271e151a..5d97e84bb 100644
--- a/app/features/scanner/tests/dedupe-events.test.ts
+++ b/app/features/scanner/tests/dedupe-events.test.ts
@@ -24,16 +24,24 @@ function teammate(
{
name = null as string | null,
abilities = [] as (AbilityWithUnknown | null)[],
+ dead = false,
} = {},
): MinimapTeammate {
- return { slot: SPECTATOR_SLOTS[i]!, name, weaponId, abilities };
+ return {
+ slot: SPECTATOR_SLOTS[i]!,
+ name,
+ weaponId,
+ abilities,
+ dead,
+ specialReady: false,
+ };
}
function enemy(
weaponId: MainWeaponId | null,
{ name = null as string | null } = {},
): MinimapEnemy {
- return { name, weaponId, abilities: [] };
+ return { name, weaponId, abilities: [], dead: false, specialReady: false };
}
function minimap(
@@ -90,6 +98,16 @@ test("a changed ability read keeps both minimaps", () => {
assert.equal(kept.length, 2);
});
+test("a changed dead state keeps both minimaps", () => {
+ const kept = withoutRepeatEvents([
+ minimap(70),
+ minimap(73, {
+ teammates: ALPHA.map((id, i) => teammate(id, i, { dead: i === 0 })),
+ }),
+ ]);
+ assert.equal(kept.length, 2);
+});
+
test("dedupes across interleaved events of other types, dropping none of them", () => {
const kept = withoutRepeatEvents([minimap(70), death(72), minimap(74)]);
assert.deepEqual(
diff --git a/app/features/scanner/tests/fixtures/minimap/snix-special-ready/expected.json b/app/features/scanner/tests/fixtures/minimap/snix-special-ready/expected.json
index 94c987e72..5103b084b 100644
--- a/app/features/scanner/tests/fixtures/minimap/snix-special-ready/expected.json
+++ b/app/features/scanner/tests/fixtures/minimap/snix-special-ready/expected.json
@@ -12,7 +12,9 @@
"SJ"
],
"weaponLabel": "Splattershot",
- "weaponId": 40
+ "weaponId": 40,
+ "dead": false,
+ "specialReady": false
},
{
"slot": "left",
@@ -23,7 +25,9 @@
"SJ"
],
"weaponLabel": "Splatana Stamper",
- "weaponId": 8000
+ "weaponId": 8000,
+ "dead": false,
+ "specialReady": true
},
{
"slot": "right",
@@ -34,7 +38,9 @@
"ISS"
],
"weaponLabel": "Splattershot Jr.",
- "weaponId": 10
+ "weaponId": 10,
+ "dead": false,
+ "specialReady": false
},
{
"slot": "self",
@@ -45,7 +51,9 @@
"SJ"
],
"weaponLabel": "Custom Blaster",
- "weaponId": 211
+ "weaponId": 211,
+ "dead": false,
+ "specialReady": false
}
],
"enemies": [
@@ -56,7 +64,9 @@
"SJ"
],
"weaponLabel": "Inkbrush Nouveau",
- "weaponId": 1101
+ "weaponId": 1101,
+ "dead": false,
+ "specialReady": true
},
{
"abilities": [
@@ -65,7 +75,9 @@
"SJ"
],
"weaponLabel": ".52 Gal",
- "weaponId": 50
+ "weaponId": 50,
+ "dead": false,
+ "specialReady": false
},
{
"abilities": [
@@ -74,7 +86,9 @@
"SCU"
],
"weaponLabel": "Big Swig Roller",
- "weaponId": 1040
+ "weaponId": 1040,
+ "dead": false,
+ "specialReady": true
},
{
"abilities": [
@@ -83,7 +97,9 @@
"SSU"
],
"weaponLabel": "Luna Blaster Neo",
- "weaponId": 201
+ "weaponId": 201,
+ "dead": false,
+ "specialReady": false
}
],
"stageLabel": "Undertow Spillway"
diff --git a/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-round1/expected.json b/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-round1/expected.json
index 130827b3f..822c476a0 100644
--- a/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-round1/expected.json
+++ b/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-round1/expected.json
@@ -13,7 +13,9 @@
"SJ"
],
"weaponLabel": "Splash-o-matic",
- "weaponId": 20
+ "weaponId": 20,
+ "dead": false,
+ "specialReady": false
},
{
"slot": "right",
@@ -24,7 +26,9 @@
"SJ"
],
"weaponLabel": "Dread Wringer",
- "weaponId": 3050
+ "weaponId": 3050,
+ "dead": false,
+ "specialReady": false
},
{
"slot": "down",
@@ -35,7 +39,9 @@
"SJ"
],
"weaponLabel": "Custom Blaster",
- "weaponId": 211
+ "weaponId": 211,
+ "dead": false,
+ "specialReady": false
},
{
"slot": "left",
@@ -46,7 +52,9 @@
"ISM"
],
"weaponLabel": "Snipewriter 5H",
- "weaponId": 2070
+ "weaponId": 2070,
+ "dead": false,
+ "specialReady": false
}
],
"enemies": [
@@ -58,7 +66,9 @@
"SJ"
],
"weaponLabel": "Snipewriter 5H",
- "weaponId": 2070
+ "weaponId": 2070,
+ "dead": false,
+ "specialReady": false
},
{
"name": "ねこすき",
@@ -68,7 +78,9 @@
"SJ"
],
"weaponLabel": "S-BLAST '91",
- "weaponId": 261
+ "weaponId": 261,
+ "dead": false,
+ "specialReady": false
},
{
"name": "そっぴ",
@@ -78,7 +90,9 @@
"SJ"
],
"weaponLabel": "Zink Mini Splatling",
- "weaponId": 4001
+ "weaponId": 4001,
+ "dead": false,
+ "specialReady": false
},
{
"name": "しゅーた",
@@ -88,7 +102,9 @@
"SJ"
],
"weaponLabel": ".52 Gal",
- "weaponId": 50
+ "weaponId": 50,
+ "dead": false,
+ "specialReady": false
}
],
"stageLabel": "Manta Maria"
diff --git a/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-winners-qf/expected.json b/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-winners-qf/expected.json
index fd171e31a..8e6b2e75a 100644
--- a/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-winners-qf/expected.json
+++ b/app/features/scanner/tests/fixtures/minimap/spectator-area-cup-winners-qf/expected.json
@@ -13,7 +13,9 @@
"ISM"
],
"weaponLabel": "Snipewriter 5H",
- "weaponId": 2070
+ "weaponId": 2070,
+ "dead": false,
+ "specialReady": false
},
{
"slot": "right",
@@ -24,7 +26,9 @@
"SSU"
],
"weaponLabel": "Splash-o-matic",
- "weaponId": 20
+ "weaponId": 20,
+ "dead": false,
+ "specialReady": false
},
{
"slot": "down",
@@ -35,7 +39,9 @@
"SJ"
],
"weaponLabel": "Custom Blaster",
- "weaponId": 211
+ "weaponId": 211,
+ "dead": false,
+ "specialReady": false
},
{
"slot": "left",
@@ -46,7 +52,9 @@
"SJ"
],
"weaponLabel": "Splatana Stamper",
- "weaponId": 8000
+ "weaponId": 8000,
+ "dead": false,
+ "specialReady": false
}
],
"enemies": [
@@ -58,7 +66,9 @@
"SJ"
],
"weaponLabel": "Mint Decavitator",
- "weaponId": 8020
+ "weaponId": 8020,
+ "dead": false,
+ "specialReady": false
},
{
"name": "たなは",
@@ -68,7 +78,9 @@
"SCU"
],
"weaponLabel": "Splat Brella",
- "weaponId": 6000
+ "weaponId": 6000,
+ "dead": false,
+ "specialReady": false
},
{
"name": "いなてんきんぐ",
@@ -78,7 +90,9 @@
"RSU"
],
"weaponLabel": "Zink Mini Splatling",
- "weaponId": 4001
+ "weaponId": 4001,
+ "dead": false,
+ "specialReady": false
},
{
"name": "そふ",
@@ -88,7 +102,9 @@
"RES"
],
"weaponLabel": "Heavy Splatling",
- "weaponId": 4010
+ "weaponId": 4010,
+ "dead": false,
+ "specialReady": false
}
],
"stageLabel": "Mincemeat Metalworks"
diff --git a/app/features/scanner/tests/fixtures/minimap/spectator-pxg/expected.json b/app/features/scanner/tests/fixtures/minimap/spectator-pxg/expected.json
index 8add5bbcd..6af9551f5 100644
--- a/app/features/scanner/tests/fixtures/minimap/spectator-pxg/expected.json
+++ b/app/features/scanner/tests/fixtures/minimap/spectator-pxg/expected.json
@@ -13,7 +13,9 @@
"SJ"
],
"weaponLabel": ".96 Gal",
- "weaponId": 80
+ "weaponId": 80,
+ "dead": false,
+ "specialReady": false
},
{
"slot": "right",
@@ -24,7 +26,9 @@
"ISM"
],
"weaponLabel": "Flingza Roller",
- "weaponId": 1030
+ "weaponId": 1030,
+ "dead": false,
+ "specialReady": false
},
{
"slot": "down",
@@ -35,7 +39,9 @@
"SJ"
],
"weaponLabel": "Order Slosher Replica",
- "weaponId": 3005
+ "weaponId": 3005,
+ "dead": false,
+ "specialReady": false
},
{
"slot": "left",
@@ -46,7 +52,9 @@
"ISM"
],
"weaponLabel": "Rapid Blaster Pro WNT-R",
- "weaponId": 252
+ "weaponId": 252,
+ "dead": false,
+ "specialReady": false
}
],
"enemies": [
@@ -58,7 +66,9 @@
"SJ"
],
"weaponLabel": "S-BLAST '91",
- "weaponId": 261
+ "weaponId": 261,
+ "dead": false,
+ "specialReady": false
},
{
"name": "バッラスト",
@@ -68,7 +78,9 @@
"ISM"
],
"weaponLabel": "Snipewriter 5H",
- "weaponId": 2070
+ "weaponId": 2070,
+ "dead": false,
+ "specialReady": false
},
{
"name": "Jared",
@@ -78,7 +90,9 @@
"SJ"
],
"weaponLabel": "Twinklez Splat Dualies",
- "weaponId": 5012
+ "weaponId": 5012,
+ "dead": false,
+ "specialReady": false
},
{
"name": "Reiyu C:3",
@@ -88,7 +102,9 @@
"SJ"
],
"weaponLabel": "Splash-o-matic",
- "weaponId": 20
+ "weaponId": 20,
+ "dead": false,
+ "specialReady": false
}
],
"stageLabel": "Crableg Capital"
diff --git a/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/expected.json b/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/expected.json
index 3d10aa3bb..54926a645 100644
--- a/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/expected.json
+++ b/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/expected.json
@@ -1,68 +1,118 @@
{
- "event": "Minimap",
- "data": {
- "stage": 1,
- "spectator": true,
- "teammates": [
- {
- "slot": "up",
- "name": "King Inate",
- "abilities": ["LDE", "RSU", "RSU"],
- "weaponLabel": "Zink Mini Splatling",
- "weaponId": 4001
- },
- {
- "slot": "right",
- "name": "todo",
- "abilities": ["CB", "SSU", "SJ"],
- "weaponLabel": "Mint Decavitator",
- "weaponId": 8020
- },
- {
- "slot": "down",
- "name": "tanaha",
- "abilities": ["RSU", "SSU", "SCU"],
- "weaponLabel": "Splat Brella",
- "weaponId": 6000
- },
- {
- "slot": "left",
- "name": "soph",
- "abilities": ["RSU", "RSU", "SJ"],
- "weaponLabel": "Heavy Splatling",
- "weaponId": 4010
- }
- ],
- "enemies": [
- {
- "name": "Burstie",
- "abilities": ["SSU", "SSU", "SSU"],
- "weaponLabel": ".96 Gal",
- "weaponId": 80
- },
- {
- "name": "[K]yo!",
- "abilities": ["CB", "QR", "SJ"],
- "weaponLabel": "Slosher",
- "weaponId": 3000
- },
- {
- "name": "leafi !!",
- "abilities": ["OG", "SSU", "SJ"],
- "weaponLabel": "Custom Blaster",
- "weaponId": 211
- },
- {
- "name": "biscuit",
- "abilities": ["LDE", "SCU", "ISM"],
- "weaponLabel": "Snipewriter 5H",
- "weaponId": 2070
- }
- ],
- "stageLabel": "Eeltail Alley"
- },
- "options": {
- "skipFields": ["teammates.0.name"],
- "notes": "8-player spectator map screen from the SWS26 cast, same game as the objective/ splat-zones-cast-* fixtures: green team left column, purple right — the cross-detector anchor pair for objective team-color tracking. teammates.0.name skipped: King Inate reads King lnate — capital I and lowercase l are pixel-identical in BlitzMain at this size (the POV nameplate in the sibling frame confirms Inate)."
- }
+ "event": "Minimap",
+ "data": {
+ "stage": 1,
+ "spectator": true,
+ "teammates": [
+ {
+ "slot": "up",
+ "name": "King Inate",
+ "abilities": [
+ "LDE",
+ "RSU",
+ "RSU"
+ ],
+ "weaponLabel": "Zink Mini Splatling",
+ "weaponId": 4001,
+ "dead": false,
+ "specialReady": false
+ },
+ {
+ "slot": "right",
+ "name": "todo",
+ "abilities": [
+ "CB",
+ "SSU",
+ "SJ"
+ ],
+ "weaponLabel": "Mint Decavitator",
+ "weaponId": 8020,
+ "dead": false,
+ "specialReady": false
+ },
+ {
+ "slot": "down",
+ "name": "tanaha",
+ "abilities": [
+ "RSU",
+ "SSU",
+ "SCU"
+ ],
+ "weaponLabel": "Splat Brella",
+ "weaponId": 6000,
+ "dead": false,
+ "specialReady": false
+ },
+ {
+ "slot": "left",
+ "name": "soph",
+ "abilities": [
+ "RSU",
+ "RSU",
+ "SJ"
+ ],
+ "weaponLabel": "Heavy Splatling",
+ "weaponId": 4010,
+ "dead": false,
+ "specialReady": false
+ }
+ ],
+ "enemies": [
+ {
+ "name": "Burstie",
+ "abilities": [
+ "SSU",
+ "SSU",
+ "SSU"
+ ],
+ "weaponLabel": ".96 Gal",
+ "weaponId": 80,
+ "dead": false,
+ "specialReady": false
+ },
+ {
+ "name": "[K]yo!",
+ "abilities": [
+ "CB",
+ "QR",
+ "SJ"
+ ],
+ "weaponLabel": "Slosher",
+ "weaponId": 3000,
+ "dead": false,
+ "specialReady": false
+ },
+ {
+ "name": "leafi !!",
+ "abilities": [
+ "OG",
+ "SSU",
+ "SJ"
+ ],
+ "weaponLabel": "Custom Blaster",
+ "weaponId": 211,
+ "dead": false,
+ "specialReady": false
+ },
+ {
+ "name": "biscuit",
+ "abilities": [
+ "LDE",
+ "SCU",
+ "ISM"
+ ],
+ "weaponLabel": "Snipewriter 5H",
+ "weaponId": 2070,
+ "dead": false,
+ "specialReady": false
+ }
+ ],
+ "stageLabel": "Eeltail Alley"
+ },
+ "options": {
+ "skipFields": [
+ "teammates.0.name"
+ ],
+ "notes": "8-player spectator map screen from the SWS26 cast, same game as the objective/ splat-zones-cast-* fixtures: green team left column, purple right — the cross-detector anchor pair for objective team-color tracking. teammates.0.name skipped: King Inate reads King lnate — capital I and lowercase l are pixel-identical in BlitzMain at this size (the POV nameplate in the sibling frame confirms Inate)."
+ }
}
diff --git a/app/features/scanner/tests/fixtures/minimap/stonks-cross-both-teams/expected.json b/app/features/scanner/tests/fixtures/minimap/stonks-cross-both-teams/expected.json
index 2b7ff4744..8d454070f 100644
--- a/app/features/scanner/tests/fixtures/minimap/stonks-cross-both-teams/expected.json
+++ b/app/features/scanner/tests/fixtures/minimap/stonks-cross-both-teams/expected.json
@@ -12,7 +12,9 @@
"SJ"
],
"weaponLabel": "Tri-Stringer",
- "weaponId": 7010
+ "weaponId": 7010,
+ "dead": false,
+ "specialReady": false
},
{
"slot": "left",
@@ -23,14 +25,18 @@
"SS"
],
"weaponLabel": "Splattershot Jr.",
- "weaponId": 10
+ "weaponId": 10,
+ "dead": false,
+ "specialReady": false
},
{
"slot": "right",
"name": null,
"abilities": [],
"weaponLabel": null,
- "weaponId": null
+ "weaponId": null,
+ "dead": true,
+ "specialReady": false
},
{
"slot": "self",
@@ -41,7 +47,9 @@
"SS"
],
"weaponLabel": "Ballpoint Splatling",
- "weaponId": 4030
+ "weaponId": 4030,
+ "dead": false,
+ "specialReady": false
}
],
"enemies": [
@@ -52,17 +60,23 @@
"IRU"
],
"weaponLabel": "Slosher",
- "weaponId": 3000
+ "weaponId": 3000,
+ "dead": false,
+ "specialReady": false
},
{
"abilities": [],
"weaponLabel": "Splat Roller",
- "weaponId": 1010
+ "weaponId": 1010,
+ "dead": true,
+ "specialReady": false
},
{
"abilities": [],
"weaponLabel": "Dualie Squelchers",
- "weaponId": 5030
+ "weaponId": 5030,
+ "dead": true,
+ "specialReady": true
},
{
"abilities": [
@@ -71,7 +85,9 @@
"SS"
],
"weaponLabel": "Splattershot",
- "weaponId": 40
+ "weaponId": 40,
+ "dead": false,
+ "specialReady": false
}
],
"stageLabel": "Museum d'Alfonsino"
diff --git a/app/features/scanner/tests/match-builder.test.ts b/app/features/scanner/tests/match-builder.test.ts
index 1f9a36c1e..6aa80f618 100644
--- a/app/features/scanner/tests/match-builder.test.ts
+++ b/app/features/scanner/tests/match-builder.test.ts
@@ -140,6 +140,8 @@ function teammate(weaponId: MainWeaponId | null, i: number): MinimapTeammate {
name: null,
weaponId,
abilities: [],
+ dead: false,
+ specialReady: false,
};
}
@@ -148,6 +150,8 @@ function enemy(weaponId: MainWeaponId | null): MinimapEnemy {
name: null,
weaponId,
abilities: [],
+ dead: false,
+ specialReady: false,
};
}
@@ -159,13 +163,23 @@ function minimap(
bravo = BRAVO as (MainWeaponId | null)[],
spectator = true,
teamColors = [null, null] as MinimapData["teamColors"],
+ dead = [[], []] as [number[], number[]],
+ specialReady = [[], []] as [number[], number[]],
} = {},
): DetectedEvent {
const data: MinimapData = {
stage,
spectator,
- teammates: alpha.map(teammate),
- enemies: bravo.map(enemy),
+ teammates: alpha.map((id, i) => ({
+ ...teammate(id, i),
+ dead: dead[0].includes(i),
+ specialReady: specialReady[0].includes(i),
+ })),
+ enemies: bravo.map((id, i) => ({
+ ...enemy(id),
+ dead: dead[1].includes(i),
+ specialReady: specialReady[1].includes(i),
+ })),
teamColors,
};
return { type: "Minimap", t, confidence: 0.8, data };
@@ -916,13 +930,18 @@ test("status reads inherit the nearest counter read's cast orientation", () => {
}),
minimap(180),
]);
+ // the two minimap reads contribute their own (all-clear) samples
const samples = built[0]!.match.playerStatus!.samples;
- assert.deepEqual(samples[0]!.dead, [
+ assert.deepEqual(
+ samples.map((sample) => sample.t),
+ [0, 60, 120, 180],
+ );
+ assert.deepEqual(samples[1]!.dead, [
[true, false, false, false],
[false, false, false, false],
]);
// the same on-screen left side is now the other team
- assert.deepEqual(samples[1]!.dead, [
+ assert.deepEqual(samples[2]!.dead, [
[false, false, false, false],
[true, false, false, false],
]);
@@ -957,3 +976,66 @@ test("replay wipes drop status reads by the shared clock projection", () => {
[60],
);
});
+
+test("minimap card states become timerless player-status samples", () => {
+ const built = buildScannerMatches([
+ minimap(70, { dead: [[2], [0]], specialReady: [[], [3]] }),
+ minimap(120),
+ ]);
+ assert.deepEqual(built[0]!.match.playerStatus, {
+ samples: [
+ {
+ t: 70,
+ time: null,
+ special: [
+ [false, false, false, false],
+ [false, false, false, true],
+ ],
+ dead: [
+ [false, false, true, false],
+ [true, false, false, false],
+ ],
+ },
+ {
+ t: 120,
+ time: null,
+ special: ALL_FALSE,
+ dead: ALL_FALSE,
+ },
+ ],
+ });
+});
+
+test("a known non-SZ match still gets its minimap-sourced status samples", () => {
+ const events = [
+ mapStart(0, { mode: "CB" }),
+ objective(60),
+ playerStatus(61),
+ minimap(90, { spectator: false, dead: [[0], []] }),
+ scoreboard(300, { mode: "CB" }),
+ ];
+ const built = buildScannerMatches(events);
+ assert.equal(built[0]!.match.objective, null);
+ const samples = built[0]!.match.playerStatus!.samples;
+ assert.deepEqual(
+ samples.map((sample) => sample.t),
+ [90],
+ );
+ assert.deepEqual(samples[0]!.dead, [
+ [true, false, false, false],
+ [false, false, false, false],
+ ]);
+ assert.deepEqual(invalidObjectiveEvents(built), [events[1], events[2]]);
+});
+
+test("a losing-side pov swaps minimap-sourced samples into teams order", () => {
+ const built = buildScannerMatches([
+ minimap(90, { spectator: false, dead: [[0], []] }),
+ scoreboard(300, { povIndex: 6 }),
+ ]);
+ const sample = built[0]!.match.playerStatus!.samples[0]!;
+ assert.deepEqual(sample.dead, [
+ [false, false, false, false],
+ [true, false, false, false],
+ ]);
+});
diff --git a/app/features/scanner/tests/minimap.test.ts b/app/features/scanner/tests/minimap.test.ts
index d3609cc50..369428d1f 100644
--- a/app/features/scanner/tests/minimap.test.ts
+++ b/app/features/scanner/tests/minimap.test.ts
@@ -134,6 +134,16 @@ for (const fixture of fixtures) {
`abilities (debug: ${cardDebug})`,
);
}
+ if (want.dead !== undefined) {
+ assert.equal(got.dead, want.dead, `dead (debug: ${cardDebug})`);
+ }
+ if (want.specialReady !== undefined) {
+ assert.equal(
+ got.specialReady,
+ want.specialReady,
+ `specialReady (debug: ${cardDebug})`,
+ );
+ }
},
);
}
@@ -177,6 +187,16 @@ for (const fixture of fixtures) {
`abilities (debug: ${rowDebug})`,
);
}
+ if (want.dead !== undefined) {
+ assert.equal(got.dead, want.dead, `dead (debug: ${rowDebug})`);
+ }
+ if (want.specialReady !== undefined) {
+ assert.equal(
+ got.specialReady,
+ want.specialReady,
+ `specialReady (debug: ${rowDebug})`,
+ );
+ }
},
);
}
diff --git a/app/features/scanner/tests/sendou-upload.test.ts b/app/features/scanner/tests/sendou-upload.test.ts
index 8bd5f2c29..4135fdb77 100644
--- a/app/features/scanner/tests/sendou-upload.test.ts
+++ b/app/features/scanner/tests/sendou-upload.test.ts
@@ -60,8 +60,16 @@ test("weapons are padded to 4 slots per team so uneven rosters keep the team spl
name: null,
weaponId: ALPHA[i]!,
abilities: [],
+ dead: false,
+ specialReady: false,
+ })),
+ enemies: BRAVO.map((weaponId) => ({
+ name: null,
+ weaponId,
+ abilities: [],
+ dead: false,
+ specialReady: false,
})),
- enemies: BRAVO.map((weaponId) => ({ name: null, weaponId, abilities: [] })),
teamColors: [null, null],
};
const matches = prefilledMatches([
diff --git a/scripts/scanner/report.ts b/scripts/scanner/report.ts
index 2b8158e2d..066c5fe49 100644
--- a/scripts/scanner/report.ts
+++ b/scripts/scanner/report.ts
@@ -349,6 +349,7 @@ for (const config of configs) {
names: { ok: 0, total: 0 } as Tally,
abilities: { ok: 0, total: 0 } as Tally,
stage: { ok: 0, total: 0 } as Tally,
+ status: { ok: 0, total: 0 } as Tally,
};
let charEdits = 0;
let charTotal = 0;
@@ -372,11 +373,15 @@ for (const config of configs) {
name?: string | null;
weaponId?: number | null;
abilities?: (string | null)[];
+ dead?: boolean;
+ specialReady?: boolean;
}[],
{
name?: string | null;
weaponId: number | null;
abilities: (string | null)[];
+ dead: boolean;
+ specialReady: boolean;
}[],
][] = [
["teammate", expected.teammates ?? [], event.data.teammates],
@@ -415,6 +420,15 @@ for (const config of configs) {
`${fixture.name} ${side}${i}: ability [${slot}] "${gotId}" != "${wantId}"`,
);
});
+ for (const flag of ["dead", "specialReady"] as const) {
+ if (want[flag] === undefined) continue;
+ tally.status.total++;
+ if (got[flag] === want[flag]) tally.status.ok++;
+ else
+ misses.push(
+ `${fixture.name} ${side}${i}: ${flag} ${got[flag]} != ${want[flag]}`,
+ );
+ }
});
}
if (expected.stage !== undefined) {
@@ -433,6 +447,7 @@ for (const config of configs) {
console.info(`names ${pct(tally.names)}`);
console.info(`abilities ${pct(tally.abilities)}`);
console.info(`stage ${pct(tally.stage)}`);
+ console.info(`status ${pct(tally.status)}`);
console.info(
`name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`,
);