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)`, );