diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index b8cb4bc17..143c32a4a 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -194,7 +194,11 @@ sequenceDiagram language at once (`core/localized-entries.ts`, generated) and events carry sendou ids. English display names come from `components/labels.ts`. - ROI coordinates live in each detector's `rois.ts`, in canonical 1920×1080 - space; every frame is normalized to that size first. + space; every frame is normalized to that size first — black bars around the + picture (letterbox/pillarbox, or a scene drawing the game smaller than its + canvas) are cropped away before the resize (`detectContentBox` in + `core/canonical.ts`; a bar must be level and ≥1% deep, since the Recent + Battles screen's own scanline-textured edge is dark but neither). - New event types implement `Detector` (`core/detectors/types.ts`): a cheap `gate(mat)` at sample rate plus `parse(mat, t)` when the gate fires. Register in `core/detectors/registry.ts`. diff --git a/app/features/scanner/components/FixturesPage.tsx b/app/features/scanner/components/FixturesPage.tsx index 9cbd3c3f1..8b087c3b4 100644 --- a/app/features/scanner/components/FixturesPage.tsx +++ b/app/features/scanner/components/FixturesPage.tsx @@ -2,7 +2,7 @@ import clsx from "clsx"; import { type ReactNode, useEffect, useState } from "react"; import { useSearchParam } from "~/modules/search-params/hooks"; import { mainWeaponImageUrl, SCANNER_PAGE } from "~/utils/urls"; -import { CANONICAL_HEIGHT, CANONICAL_WIDTH, type Roi } from "../core/canonical"; +import type { Roi } from "../core/canonical"; import type { PlayerStatusLayout } from "../core/detectors/objective/player-status"; import * as objective from "../core/detectors/objective/rois"; import type { FixtureListItem } from "../routes/scanner.fixtures"; @@ -10,6 +10,7 @@ import { scannerSearchParams } from "../scanner-search-params"; import { newInspectKey, putInspectFrame } from "../store/inspect"; import styles from "./FixturesPage.module.css"; import { mainWeaponLabel } from "./labels"; +import { drawNormalizedCanvas } from "./normalized-canvas"; import { formatTimer, RoiCrop } from "./ScreenshotPage"; const FIXTURES_ENDPOINT = "/scanner/fixtures"; @@ -223,13 +224,9 @@ function useNormalizedFrame( const image = new Image(); image.onload = () => { if (cancelled) return; - const canvas = document.createElement("canvas"); - canvas.width = CANONICAL_WIDTH; - canvas.height = CANONICAL_HEIGHT; - canvas - .getContext("2d")! - .drawImage(image, 0, 0, CANONICAL_WIDTH, CANONICAL_HEIGHT); - setFrame(canvas); + setFrame( + drawNormalizedCanvas(image, image.naturalWidth, image.naturalHeight), + ); }; image.src = url; return () => { diff --git a/app/features/scanner/components/MinimapCard.module.css b/app/features/scanner/components/MinimapCard.module.css index 5362e1f41..f30a695e1 100644 --- a/app/features/scanner/components/MinimapCard.module.css +++ b/app/features/scanner/components/MinimapCard.module.css @@ -81,16 +81,4 @@ width: 13px; height: 13px; } - - &.right svg { - transform: rotate(90deg); - } - - &.down svg { - transform: rotate(180deg); - } - - &.left svg { - transform: rotate(-90deg); - } } diff --git a/app/features/scanner/components/MinimapCard.tsx b/app/features/scanner/components/MinimapCard.tsx index 9c9f85c02..e628d6fd1 100644 --- a/app/features/scanner/components/MinimapCard.tsx +++ b/app/features/scanner/components/MinimapCard.tsx @@ -10,7 +10,6 @@ import { type MinimapEnemy, type MinimapTeammate, } from "../core/detectors/minimap/index"; -import type { CardSlot } from "../core/detectors/minimap/rois"; import { EventCardMeta, EventCardShell, @@ -45,22 +44,14 @@ function AbilityRow({ ); } -/** `up` needs no rotation, `self` renders a dot instead of a chevron */ -const SLOT_ROTATION_CLASS: Record = { - up: undefined, - down: styles.down, - left: styles.left, - right: styles.right, - self: undefined, -}; - -function TeammateMarker({ slot }: { slot: CardSlot }) { - if (slot === "self") { +/** the POV player's own card renders a dot, allies a jump chevron */ +function TeammateMarker({ self }: { self: boolean }) { + if (self) { return ; } return ( - - + + ); } @@ -143,10 +134,10 @@ export function MinimapCard(props: {

Team

- {data.teammates.map((p) => ( + {data.teammates.map((p, i) => ( } + key={i} + marker={} player={p} /> ))} diff --git a/app/features/scanner/components/ScreenshotPage.tsx b/app/features/scanner/components/ScreenshotPage.tsx index 43d259143..b24ee8657 100644 --- a/app/features/scanner/components/ScreenshotPage.tsx +++ b/app/features/scanner/components/ScreenshotPage.tsx @@ -43,6 +43,7 @@ import { stageLabel, weaponLabel, } from "./labels"; +import { drawNormalizedCanvas } from "./normalized-canvas"; import { ScannerDropzone } from "./ScannerChrome"; import styles from "./ScreenshotPage.module.css"; @@ -448,13 +449,7 @@ export function ScreenshotPage() { const bitmap = await createImageBitmap(file); // normalized frame for local crop display, same as the pipeline does - const norm = document.createElement("canvas"); - norm.width = CANONICAL_WIDTH; - norm.height = CANONICAL_HEIGHT; - norm - .getContext("2d")! - .drawImage(bitmap, 0, 0, CANONICAL_WIDTH, CANONICAL_HEIGHT); - setFrame(norm); + setFrame(drawNormalizedCanvas(bitmap, bitmap.width, bitmap.height)); resultRef.current = (r) => { setResults((prev) => ({ ...prev, [r.detector]: r })); @@ -762,7 +757,7 @@ export function ScreenshotPage() { {data.teammates .map( (p) => - `${p.slot}: ${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})${playerFlags(p)}`, + `${p.self ? "self: " : ""}${p.name ?? "?"} (${mainWeaponLabel(p.weaponId) ?? "?"})${playerFlags(p)}`, ) .join(", ") || "—"} @@ -779,10 +774,10 @@ export function ScreenshotPage() { {!data.spectator ? (
- {minimap.CARD_LAYOUTS.map((card) => ( + {minimap.CARD_LAYOUTS.map((card, i) => ( diff --git a/app/features/scanner/components/events-csv.ts b/app/features/scanner/components/events-csv.ts index 956b668b7..813a7ba02 100644 --- a/app/features/scanner/components/events-csv.ts +++ b/app/features/scanner/components/events-csv.ts @@ -113,7 +113,7 @@ function formatMinimapPlayers(data: MinimapData): string { `${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.teammates.map((p, i) => fmt(p.self ? "self" : `ally${i + 1}`, p)), ...data.enemies.map((p, i) => fmt(`enemy${i + 1}`, p)), ].join("; "); } @@ -234,7 +234,7 @@ function eventCells(event: CsvEvent): Cell[] { } case MINIMAP_EVENT_TYPE: { const d = event.data as MinimapData; - const self = d.teammates.find((p) => p.slot === "self"); + const self = d.teammates.find((p) => p.self); return [ ...base, "", // lobby diff --git a/app/features/scanner/components/fixture-export.ts b/app/features/scanner/components/fixture-export.ts index 16bb24554..b8f0a3b47 100644 --- a/app/features/scanner/components/fixture-export.ts +++ b/app/features/scanner/components/fixture-export.ts @@ -122,7 +122,7 @@ function buildExpectedJson( }), ...(minimap.spectator && { spectator: true }), teammates: minimap.teammates.map((p) => ({ - slot: p.slot, + self: p.self, name: p.name, weaponLabel: mainWeaponLabel(p.weaponId), weaponId: p.weaponId, diff --git a/app/features/scanner/components/normalized-canvas.ts b/app/features/scanner/components/normalized-canvas.ts new file mode 100644 index 000000000..78c0d3346 --- /dev/null +++ b/app/features/scanner/components/normalized-canvas.ts @@ -0,0 +1,47 @@ +import { + CANONICAL_HEIGHT, + CANONICAL_WIDTH, + detectContentBox, +} from "../core/canonical"; + +/** + * Draws a frame at canonical size the way the worker normalizes it — black + * bars around the picture cropped away, then scaled — so ROI crops shown in + * the UI line up with what the detectors read. + */ +export function drawNormalizedCanvas( + source: CanvasImageSource, + width: number, + height: number, +): HTMLCanvasElement { + const native = document.createElement("canvas"); + native.width = width; + native.height = height; + const nativeCtx = native.getContext("2d", { willReadFrequently: true })!; + nativeCtx.drawImage(source, 0, 0); + const { data } = nativeCtx.getImageData(0, 0, width, height); + const box = detectContentBox(width, height, data) ?? { + x: 0, + y: 0, + w: width, + h: height, + }; + + const canvas = document.createElement("canvas"); + canvas.width = CANONICAL_WIDTH; + canvas.height = CANONICAL_HEIGHT; + canvas + .getContext("2d")! + .drawImage( + native, + box.x, + box.y, + box.w, + box.h, + 0, + 0, + CANONICAL_WIDTH, + CANONICAL_HEIGHT, + ); + return canvas; +} diff --git a/app/features/scanner/core/canonical.ts b/app/features/scanner/core/canonical.ts index d14a44db7..8874e989a 100644 --- a/app/features/scanner/core/canonical.ts +++ b/app/features/scanner/core/canonical.ts @@ -8,3 +8,93 @@ export interface Roi { w: number; h: number; } + +/** Mean brightness an edge row/column stays under to count as bar; true black sits at 0-5, video-range black at 16. */ +const BORDER_LINE_MAX_MEAN = 20; +/** A bar's lines all sit at one level; the Recent Battles screen's scanline-textured edge is dark but hops several levels line to line. */ +const BORDER_LEVEL_TOLERANCE = 2; +/** Shallower dark runs are UI, not bars (that same scanline edge runs 1-5px deep at any resolution). */ +const BORDER_MIN_FRACTION = 0.01; +/** A dark edge deeper than this fraction of the dimension is a fade-to-black or a black screen, not a frame around the game. */ +const BORDER_MAX_FRACTION = 0.25; +/** The game always renders 16:9: a cropped box further off than this is dark scenery, so the frame is left alone. */ +const CONTENT_ASPECT_TOLERANCE = 0.01; + +/** + * Finds the game picture inside a capture padded with black bars (letterbox, + * pillarbox, or an OBS scene drawing the source slightly smaller than the + * canvas — 1920x1080 with a 16px/29px frame of black seen live). Walks each + * edge inward while the whole row/column stays dark at the first line's + * level, keeps a side only when the run is deep enough to be a bar, and + * accepts the box only when the remaining picture is 16:9 and no bar is + * absurdly deep. Returns null when there is nothing to crop. `rgba` is the + * frame's pixel data (ImageData layout); cost is proportional to the bars' + * depth, since a lit outermost line ends the walk at once. + */ +export function detectContentBox( + width: number, + height: number, + rgba: Uint8Array | Uint8ClampedArray, +): Roi | null { + const rowMean = (y: number) => { + let sum = 0; + const end = (y * width + width) * 4; + for (let i = y * width * 4; i < end; i += 4) { + sum += rgba[i]! + rgba[i + 1]! + rgba[i + 2]!; + } + return sum / (width * 3); + }; + const colMean = (x: number) => { + let sum = 0; + for (let i = x * 4; i < rgba.length; i += width * 4) { + sum += rgba[i]! + rgba[i + 1]! + rgba[i + 2]!; + } + return sum / (height * 3); + }; + const barDepth = ( + lineMean: (line: number) => number, + lineAt: (depth: number) => number, + maxDepth: number, + minDepth: number, + ): number => { + const level = lineMean(lineAt(0)); + if (level > BORDER_LINE_MAX_MEAN) return 0; + let depth = 1; + while ( + depth < maxDepth && + Math.abs(lineMean(lineAt(depth)) - level) <= BORDER_LEVEL_TOLERANCE + ) { + depth++; + } + return depth >= minDepth ? depth : 0; + }; + const maxDepthY = Math.floor(height * BORDER_MAX_FRACTION); + const maxDepthX = Math.floor(width * BORDER_MAX_FRACTION); + const minDepthY = Math.ceil(height * BORDER_MIN_FRACTION); + const minDepthX = Math.ceil(width * BORDER_MIN_FRACTION); + + const top = barDepth(rowMean, (d) => d, maxDepthY, minDepthY); + const bottom = barDepth(rowMean, (d) => height - 1 - d, maxDepthY, minDepthY); + const left = barDepth(colMean, (d) => d, maxDepthX, minDepthX); + const right = barDepth(colMean, (d) => width - 1 - d, maxDepthX, minDepthX); + + if (top + bottom + left + right === 0) return null; + if ( + top === maxDepthY || + bottom === maxDepthY || + left === maxDepthX || + right === maxDepthX + ) { + return null; + } + const box = { + x: left, + y: top, + w: width - left - right, + h: height - top - bottom, + }; + const canonicalAspect = CANONICAL_WIDTH / CANONICAL_HEIGHT; + const aspectError = + Math.abs(box.w / box.h - canonicalAspect) / canonicalAspect; + return aspectError <= CONTENT_ASPECT_TOLERANCE ? box : null; +} diff --git a/app/features/scanner/core/detectors/minimap/index.ts b/app/features/scanner/core/detectors/minimap/index.ts index 3b0e6dcda..2c8e96657 100644 --- a/app/features/scanner/core/detectors/minimap/index.ts +++ b/app/features/scanner/core/detectors/minimap/index.ts @@ -42,7 +42,6 @@ import type { DetectedEvent, Detector, GateResult } from "../types"; import { badgeRoi, CARD_LAYOUTS, - type CardSlot, CROSS_MIN_FRACTION, CROSS_MIN_LAPLACIAN, CROSS_SATURATION_MIN, @@ -61,6 +60,8 @@ import { GATE_SPAWN_DARK_PROBES, GATE_SPECTATOR_X_BRIGHT, GATE_SPECTATOR_X_DARK, + GATE_SPECTATOR_X_MIRRORED_BRIGHT, + GATE_SPECTATOR_X_MIRRORED_DARK, MINIMAP_ABILITY_INK_THRESHOLD, MINIMAP_WEAPON_INK_THRESHOLD, NAME_BIN_THRESHOLD, @@ -72,7 +73,6 @@ import { SPECIAL_READY_WEAPON_MIN_SCORE, SPECTATOR_ENEMY_DX, SPECTATOR_NAME_TEXT_HEIGHTS, - SPECTATOR_SLOTS, spectatorCardLayout, WEAPON_BLEED_MIN_CORNER_MEAN, WEAPON_MIN_SCORE, @@ -80,8 +80,8 @@ import { import { matchStage, plannerSignature, type StageMatch } from "./stage"; export interface MinimapTeammate { - /** which callout card: super-jump slot, or the POV player's own card */ - slot: CardSlot; + /** the POV player's own card (bottom-left on the overlay); never on the spectator screen */ + self: boolean; /** card name; null when covered by a respawn cross-out or unreadable */ name: string | null; /** sendou main-weapon id; null when unreadable/covered */ @@ -114,7 +114,7 @@ export interface MinimapData { * column reported as teammates, bravo (right) as enemy rows, both with names */ spectator: boolean; - /** own-team callout cards; a slot missing from the frame is omitted */ + /** own-team callout cards in drawn order; a card missing from the frame is omitted */ teammates: MinimapTeammate[]; /** enemy panel rows, top to bottom */ enemies: MinimapEnemy[]; @@ -264,9 +264,20 @@ export function createMinimapDetector( ); } - /** Spectator screen: the X jump-button disc beside the 8th player card. */ + /** Spectator screen: the X jump-button disc beside the 8th player card, in whichever column carries the face buttons. */ function spectatorGate(gray: Mat): GateResult { - return probeGate(gray, GATE_SPECTATOR_X_DARK, GATE_SPECTATOR_X_BRIGHT); + const right = probeGate( + gray, + GATE_SPECTATOR_X_DARK, + GATE_SPECTATOR_X_BRIGHT, + ); + if (right.pass) return right; + const left = probeGate( + gray, + GATE_SPECTATOR_X_MIRRORED_DARK, + GATE_SPECTATOR_X_MIRRORED_BRIGHT, + ); + return left.score > right.score ? left : right; } function gate(frame: Mat): GateResult { @@ -395,6 +406,7 @@ export function createMinimapDetector( const sideSubTiles: [Roi[], Roi[]] = [[], []]; const cardDebug: Record[] = []; for (const dx of [0, SPECTATOR_ENEMY_DX]) { + const isTeammate = dx === 0; for (let row = 0; row < 4; row++) { const layout = spectatorCardLayout(row, dx); const presence = meanBrightness(lap, layout.name); @@ -402,7 +414,7 @@ export function createMinimapDetector( cardDebug.push({ dx, row, presence, skipped: true }); continue; } - sideSubTiles[dx === 0 ? 0 : 1].push(layout.subTile); + sideSubTiles[isTeammate ? 0 : 1].push(layout.subTile); const crossFraction = saturatedFraction(hsv, layout.cross); const crossLap = meanBrightness(lap, layout.cross); const occluded = @@ -469,8 +481,8 @@ export function createMinimapDetector( dead: occluded, specialReady: lightSurface, }; - if (dx === 0) { - teammates.push({ slot: SPECTATOR_SLOTS[row]!, ...fields }); + if (isTeammate) { + teammates.push({ self: false, ...fields }); } else { enemies.push(fields); } @@ -543,10 +555,10 @@ export function createMinimapDetector( const sideSubTiles: [Roi[], Roi[]] = [[], []]; const cardDebug: Record[] = []; for (const layout of CARD_LAYOUTS) { - // presence: the card is crisp UI, absent slots show blurred scene + // presence: the card is crisp UI, an absent card shows blurred scene const presence = meanBrightness(lap, layout.name); if (presence < PRESENCE_MIN_LAPLACIAN) { - cardDebug.push({ slot: layout.slot, presence, skipped: true }); + cardDebug.push({ self: layout.self, presence, skipped: true }); continue; } const crossFraction = saturatedFraction(hsv, layout.cross); @@ -599,7 +611,7 @@ export function createMinimapDetector( ); } cardDebug.push({ - slot: layout.slot, + self: layout.self, presence, crossFraction, crossLap, @@ -623,7 +635,7 @@ export function createMinimapDetector( if (!hasEvidence) continue; sideSubTiles[0].push(layout.subTile); teammates.push({ - slot: layout.slot, + self: layout.self, name, weaponId: matched ? toMainWeaponId(matched.id) : null, abilities, diff --git a/app/features/scanner/core/detectors/minimap/rois.ts b/app/features/scanner/core/detectors/minimap/rois.ts index 4b88f3c47..8ee942655 100644 --- a/app/features/scanner/core/detectors/minimap/rois.ts +++ b/app/features/scanner/core/detectors/minimap/rois.ts @@ -8,12 +8,9 @@ */ import type { Roi } from "../../canonical"; -/** "self" and "down" never coexist: the POV overlay has no down slot (the - * player's own card replaces it), the spectator grid has no self card. */ -export type CardSlot = "up" | "left" | "right" | "self" | "down"; - export interface CardLayout { - slot: CardSlot; + /** the POV player's own card (bottom-left, replacing the down jump slot); the spectator grid has none */ + self: boolean; /** name text band (BlitzMain caps ~29px plus outline/descender margin) */ name: Roi; /** main-weapon silhouette box (icons render ~31-48px tall) */ @@ -32,7 +29,7 @@ export interface CardLayout { */ export const CARD_LAYOUTS: readonly CardLayout[] = [ { - slot: "up", + self: false, name: { x: 872, y: 46, w: 300, h: 44 }, weapon: { x: 860, y: 83, w: 84, h: 54 }, subTile: { x: 932, y: 98, w: 38, h: 41 }, @@ -44,7 +41,7 @@ export const CARD_LAYOUTS: readonly CardLayout[] = [ cross: { x: 925, y: 78, w: 60, h: 32 }, }, { - slot: "left", + self: false, name: { x: 198, y: 492, w: 300, h: 44 }, weapon: { x: 193, y: 529, w: 84, h: 54 }, subTile: { x: 265, y: 544, w: 38, h: 41 }, @@ -56,7 +53,7 @@ export const CARD_LAYOUTS: readonly CardLayout[] = [ cross: { x: 258, y: 524, w: 60, h: 32 }, }, { - slot: "right", + self: false, name: { x: 1550, y: 492, w: 300, h: 44 }, weapon: { x: 1545, y: 529, w: 84, h: 54 }, subTile: { x: 1617, y: 544, w: 38, h: 41 }, @@ -68,7 +65,7 @@ export const CARD_LAYOUTS: readonly CardLayout[] = [ cross: { x: 1610, y: 524, w: 60, h: 32 }, }, { - slot: "self", + self: true, name: { x: 126, y: 942, w: 300, h: 46 }, weapon: { x: 118, y: 985, w: 94, h: 55 }, subTile: { x: 193, y: 995, w: 38, h: 36 }, @@ -197,7 +194,10 @@ export const GATE_BRIGHT_MIN_MAX = 210; /** * Spectator gate: casts often cover the overlay's corner chrome, so gate on the * X jump-button disc beside the 8th card (center (1424,712) ±4px). Measured - * bright>=249 / dark<=65 against the shared 210/85 thresholds. + * bright>=249 / dark<=65 against the shared 210/85 thresholds. The button + * glyphs also come mirrored — face buttons down the left column, D-pad down + * the right — with the card grid unchanged, so the disc is probed one column + * over as well (GATE_SPECTATOR_X_MIRRORED_*; bright 255 / dark<=56 there). */ export const GATE_SPECTATOR_X_BRIGHT: readonly Roi[] = [ { x: 1418, y: 706, w: 12, h: 12 }, @@ -218,19 +218,20 @@ export const GATE_SPECTATOR_X_DARK: readonly Roi[] = [ * left is alpha, right bravo. No struck/special-ready fixture attested yet, so * those probes reuse overlay thresholds untuned. */ -export const SPECTATOR_SLOTS: readonly CardSlot[] = [ - "up", - "right", - "down", - "left", -]; export const SPECTATOR_ENEMY_DX = 1348; +export const GATE_SPECTATOR_X_MIRRORED_BRIGHT: readonly Roi[] = + GATE_SPECTATOR_X_BRIGHT.map(mirrorToLeftColumn); +export const GATE_SPECTATOR_X_MIRRORED_DARK: readonly Roi[] = + GATE_SPECTATOR_X_DARK.map(mirrorToLeftColumn); +function mirrorToLeftColumn(roi: Roi): Roi { + return { ...roi, x: roi.x - SPECTATOR_ENEMY_DX }; +} const SPECTATOR_ROW_PITCH = 120; export function spectatorCardLayout( row: number, dx: number, -): Omit { +): Omit { const dy = SPECTATOR_ROW_PITCH * row; return { name: { x: 198 + dx, y: 306 + dy, w: 310, h: 44 }, diff --git a/app/features/scanner/core/image.ts b/app/features/scanner/core/image.ts index 2efe54fdc..45262f5ee 100644 --- a/app/features/scanner/core/image.ts +++ b/app/features/scanner/core/image.ts @@ -3,7 +3,12 @@ * same layout as browser ImageData (Node builds it from @napi-rs/canvas). */ -import { CANONICAL_HEIGHT, CANONICAL_WIDTH, type Roi } from "./canonical"; +import { + CANONICAL_HEIGHT, + CANONICAL_WIDTH, + detectContentBox, + type Roi, +} from "./canonical"; import { getCV, type Mat, meanOf, minMaxLoc } from "./cv"; export type { Roi }; @@ -21,24 +26,32 @@ export function toMat(frame: FrameData): Mat { return cv.matFromImageData(frame as unknown as ImageData); } -/** Normalizes any frame to the canonical 1920x1080 RGBA mat all ROI constants assume. New mat; caller owns both. */ +/** + * Normalizes any frame to the canonical 1920x1080 RGBA mat all ROI constants + * assume: black bars around the picture are cropped away first + * (detectContentBox), then the picture is resized. New mat; caller owns both. + * `src` must be continuous (a fresh mat, not a ROI view). + */ export function normalizeFrame(src: Mat): Mat { const cv = getCV(); const dst = new cv.Mat(); - if (src.cols === CANONICAL_WIDTH && src.rows === CANONICAL_HEIGHT) { - src.copyTo(dst); - return dst; + const box = detectContentBox(src.cols, src.rows, src.data as Uint8Array); + const picture = box ? cropRoi(src, box) : src; + if (picture.cols === CANONICAL_WIDTH && picture.rows === CANONICAL_HEIGHT) { + picture.copyTo(dst); + } else { + const interpolation = + picture.cols > CANONICAL_WIDTH ? cv.INTER_AREA : cv.INTER_CUBIC; + cv.resize( + picture, + dst, + new cv.Size(CANONICAL_WIDTH, CANONICAL_HEIGHT), + 0, + 0, + interpolation, + ); } - const interpolation = - src.cols > CANONICAL_WIDTH ? cv.INTER_AREA : cv.INTER_CUBIC; - cv.resize( - src, - dst, - new cv.Size(CANONICAL_WIDTH, CANONICAL_HEIGHT), - 0, - 0, - interpolation, - ); + if (box) picture.delete(); return dst; } diff --git a/app/features/scanner/node/fixtures.ts b/app/features/scanner/node/fixtures.ts index d850db193..15bd20e97 100644 --- a/app/features/scanner/node/fixtures.ts +++ b/app/features/scanner/node/fixtures.ts @@ -34,7 +34,8 @@ interface ExpectedPlayer { } interface ExpectedMinimapTeammate { - slot?: "up" | "left" | "right" | "self" | "down"; + /** the POV player's own card */ + self?: boolean; name?: string | null; /** informational for the human corrector; tests compare weaponId */ weaponLabel?: string | null; @@ -119,7 +120,7 @@ interface ExpectedScoreboard { weaponLabels?: [(string | null)[], (string | null)[]]; /** Minimap only: casted 8-player spectator map screen (not parsed yet) */ spectator?: boolean; - /** Minimap only: own-team callout cards in slot order */ + /** Minimap only: own-team callout cards in drawn order */ teammates?: ExpectedMinimapTeammate[]; /** Minimap only: enemy panel rows, top to bottom */ enemies?: ExpectedMinimapEnemy[]; diff --git a/app/features/scanner/tests/fixtures/minimap/pov-umami-map-open-phantom-splats/expected.json b/app/features/scanner/tests/fixtures/minimap/pov-umami-map-open-phantom-splats/expected.json index 8d02edd18..6ec77f685 100644 --- a/app/features/scanner/tests/fixtures/minimap/pov-umami-map-open-phantom-splats/expected.json +++ b/app/features/scanner/tests/fixtures/minimap/pov-umami-map-open-phantom-splats/expected.json @@ -6,36 +6,52 @@ "spectator": false, "teammates": [ { - "slot": "up", + "self": false, "name": "Bread-Chan", - "abilities": ["LDE", "SCU", "SPU"], + "abilities": [ + "LDE", + "SCU", + "SPU" + ], "weaponLabel": "Clawz .96 Gal", "weaponId": 82, "dead": false, "specialReady": false }, { - "slot": "left", + "self": false, "name": "amo", - "abilities": ["LDE", "RP", "OS"], + "abilities": [ + "LDE", + "RP", + "OS" + ], "weaponLabel": "E-liter 4K", "weaponId": 2030, "dead": false, "specialReady": false }, { - "slot": "right", + "self": false, "name": "mr 2", - "abilities": ["CB", "SSU", "SJ"], + "abilities": [ + "CB", + "SSU", + "SJ" + ], "weaponLabel": "Splatana Stamper", "weaponId": 8000, "dead": false, "specialReady": false }, { - "slot": "self", + "self": true, "name": "Sendou", - "abilities": ["CB", "QR", "SJ"], + "abilities": [ + "CB", + "QR", + "SJ" + ], "weaponLabel": "Custom Blaster", "weaponId": 211, "dead": false, @@ -44,28 +60,44 @@ ], "enemies": [ { - "abilities": ["CB", "QR", "SJ"], + "abilities": [ + "CB", + "QR", + "SJ" + ], "weaponLabel": "Custom Blaster", "weaponId": 211, "dead": false, "specialReady": false }, { - "abilities": ["LDE", "RP", "OS"], + "abilities": [ + "LDE", + "RP", + "OS" + ], "weaponLabel": "E-liter 4K", "weaponId": 2030, "dead": false, "specialReady": false }, { - "abilities": ["LDE", "SCU", "SJ"], + "abilities": [ + "LDE", + "SCU", + "SJ" + ], "weaponLabel": "N-ZAP '85", "weaponId": 60, "dead": false, "specialReady": false }, { - "abilities": ["OG", "SSU", "SJ"], + "abilities": [ + "OG", + "SSU", + "SJ" + ], "weaponLabel": ".52 Gal", "weaponId": 50, "dead": false, @@ -74,7 +106,9 @@ ] }, "options": { - "skipFields": ["teammates.3.weapon"], + "skipFields": [ + "teammates.3.weapon" + ], "notes": "Sendou POV, Um'ami Ruins SZ at ~4:55 (t=18 in the 2026-08-11 VoD) — fully rendered POV map screen, everyone alive on both teams. The scan's read off this moment claimed mr 2 (right card) and enemy rows 1–2 splatted, amo holding special, and Sendou on Octobrush — four phantoms in one read, seconds into the match. Teammate abilities transcribed from adjacent clean reads (t=12/t=40/t=42.5) that agree with the card icons. The phantom splats (bright ink bled through the translucent surfaces) are fenced by the cross probe's crispness gate, the phantom special by the camo probe's corner-saturation cap, and the bright-bleed surfaces get a dual template-set match with the camo floor. Sendou's own weapon stays skipped: the self-card render is soft and half-covered by the yellow freshness tag badge, and the blaster family trails Octobrush by 0.006 NCC — no robust margin to assert." } } 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 5103b084b..86dbab134 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 @@ -4,7 +4,7 @@ "stage": 3, "teammates": [ { - "slot": "up", + "self": false, "name": "こむぎこをこねたユウ", "abilities": [ "CB", @@ -17,7 +17,7 @@ "specialReady": false }, { - "slot": "left", + "self": false, "name": "Snix", "abilities": [ "CB", @@ -30,7 +30,7 @@ "specialReady": true }, { - "slot": "right", + "self": false, "name": "スライムLv.13", "abilities": [ "ISS", @@ -43,7 +43,7 @@ "specialReady": false }, { - "slot": "self", + "self": true, "name": "Sendou", "abilities": [ "CB", 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 822c476a0..867e99ad8 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 @@ -5,7 +5,6 @@ "spectator": true, "teammates": [ { - "slot": "up", "name": "リビア", "abilities": [ "SSU", @@ -18,7 +17,6 @@ "specialReady": false }, { - "slot": "right", "name": "がらっこ", "abilities": [ "LDE", @@ -31,7 +29,6 @@ "specialReady": false }, { - "slot": "down", "name": "ーロたべる", "abilities": [ "OG", @@ -44,7 +41,6 @@ "specialReady": false }, { - "slot": "left", "name": "ももドラグーン", "abilities": [ "LDE", 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 8e6b2e75a..84e872366 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 @@ -5,7 +5,6 @@ "spectator": true, "teammates": [ { - "slot": "up", "name": "ももドラグーン", "abilities": [ "LDE", @@ -18,7 +17,6 @@ "specialReady": false }, { - "slot": "right", "name": "れいまるがんばれや!", "abilities": [ "SCU", @@ -31,7 +29,6 @@ "specialReady": false }, { - "slot": "down", "name": "ひまじん", "abilities": [ "OG", @@ -44,7 +41,6 @@ "specialReady": false }, { - "slot": "left", "name": "!のりしお!", "abilities": [ "CB", diff --git a/app/features/scanner/tests/fixtures/minimap/spectator-mirrored-buttons-black-borders/expected.json b/app/features/scanner/tests/fixtures/minimap/spectator-mirrored-buttons-black-borders/expected.json new file mode 100644 index 000000000..a0414284c --- /dev/null +++ b/app/features/scanner/tests/fixtures/minimap/spectator-mirrored-buttons-black-borders/expected.json @@ -0,0 +1,114 @@ +{ + "event": "Minimap", + "data": { + "stage": 21, + "stageLabel": "Robo ROM-en", + "spectator": true, + "teammates": [ + { + "name": "mukα", + "abilities": [ + "LDE", + "SSU", + "SJ" + ], + "weaponLabel": "Forge Splattershot Pro", + "weaponId": 71, + "dead": false, + "specialReady": false + }, + { + "name": "BoogasWife", + "abilities": [ + "CB", + "RSU", + "SJ" + ], + "weaponLabel": "Mint Decavitator", + "weaponId": 8020, + "dead": false, + "specialReady": false + }, + { + "name": "Waltsu", + "abilities": [ + "CB", + "SSU", + "ISS" + ], + "weaponLabel": "Order Splatana Replica", + "weaponId": 8005, + "dead": false, + "specialReady": false + }, + { + "name": "Patfromwii", + "abilities": [ + "ISS", + "ISS", + "ISS" + ], + "weaponLabel": "Custom Splattershot Jr.", + "weaponId": 11, + "dead": false, + "specialReady": false + } + ], + "enemies": [ + { + "name": "TUNAROLL", + "abilities": [ + "CB", + "ISM", + "SJ" + ], + "weaponLabel": "Gold Dynamo Roller", + "weaponId": 1021, + "dead": false, + "specialReady": false + }, + { + "name": "vaporeon ★", + "abilities": [ + "OG", + "SSU", + "SJ" + ], + "weaponLabel": ".52 Gal", + "weaponId": 50, + "dead": false, + "specialReady": false + }, + { + "name": ">_<", + "abilities": [ + "ISM", + "RP", + "OS" + ], + "weaponLabel": "E-liter 4K", + "weaponId": 2030, + "dead": false, + "specialReady": false + }, + { + "name": "ANGEL☆CARE", + "abilities": [ + "SCU", + "SSU", + "SJ" + ], + "weaponLabel": "Splash-o-matic", + "weaponId": 20, + "dead": false, + "specialReady": false + } + ] + }, + "options": { + "skipFields": [ + "enemies.2.name" + ], + "notes": "8-player spectator map screen, Robo ROM-en, captured as a 1920x1080 screenshot with the game drawn ~97% size inside a black frame (16px top/bottom, 29px left/right) — the case that added black-bar cropping to frame normalization: untouched, every ROI lands ~30px off and no gate fires. Also the first frame with the button glyphs mirrored: A/B/Y/X face buttons down the left column, D-pad down the right (every earlier spectator fixture has the reverse), so the spectator gate probes the X jump-button disc in either column. The card grid is unchanged and the left column is reported as teammates like the other spectator fixtures; whether the mirrored buttons say anything about which team is alpha is unverified. Labels transcribed from the visible cards. enemies.2.name skipped: >_< reads >ー< — the underscore is matched as a long dash at this name height." + } +} diff --git a/app/features/scanner/tests/fixtures/minimap/spectator-mirrored-buttons-black-borders/frame.png b/app/features/scanner/tests/fixtures/minimap/spectator-mirrored-buttons-black-borders/frame.png new file mode 100644 index 000000000..0fd8eb181 Binary files /dev/null and b/app/features/scanner/tests/fixtures/minimap/spectator-mirrored-buttons-black-borders/frame.png differ 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 6af9551f5..67cdd9528 100644 --- a/app/features/scanner/tests/fixtures/minimap/spectator-pxg/expected.json +++ b/app/features/scanner/tests/fixtures/minimap/spectator-pxg/expected.json @@ -5,7 +5,6 @@ "spectator": true, "teammates": [ { - "slot": "up", "name": "kyutpie<3", "abilities": [ "SSU", @@ -18,7 +17,6 @@ "specialReady": false }, { - "slot": "right", "name": "Grey", "abilities": [ "LDE", @@ -31,7 +29,6 @@ "specialReady": false }, { - "slot": "down", "name": "JORDANYAGI", "abilities": [ "CB", @@ -44,7 +41,6 @@ "specialReady": false }, { - "slot": "left", "name": "f(x)= x²-8", "abilities": [ "OG", 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 54926a645..7aac22e5c 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 @@ -5,7 +5,6 @@ "spectator": true, "teammates": [ { - "slot": "up", "name": "King Inate", "abilities": [ "LDE", @@ -18,7 +17,6 @@ "specialReady": false }, { - "slot": "right", "name": "todo", "abilities": [ "CB", @@ -31,7 +29,6 @@ "specialReady": false }, { - "slot": "down", "name": "tanaha", "abilities": [ "RSU", @@ -44,7 +41,6 @@ "specialReady": false }, { - "slot": "left", "name": "soph", "abilities": [ "RSU", 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 8d454070f..cd4d1cee5 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 @@ -4,7 +4,7 @@ "stage": 6, "teammates": [ { - "slot": "up", + "self": false, "name": "STONKS", "abilities": [ "IRU", @@ -17,7 +17,7 @@ "specialReady": false }, { - "slot": "left", + "self": false, "name": "Player", "abilities": [ "IRU", @@ -30,7 +30,7 @@ "specialReady": false }, { - "slot": "right", + "self": false, "name": null, "abilities": [], "weaponLabel": null, @@ -39,7 +39,7 @@ "specialReady": false }, { - "slot": "self", + "self": true, "name": "Edd", "abilities": [ "IRU", diff --git a/app/features/scanner/tests/logic/content-box.test.ts b/app/features/scanner/tests/logic/content-box.test.ts new file mode 100644 index 000000000..8b8b4d7fd --- /dev/null +++ b/app/features/scanner/tests/logic/content-box.test.ts @@ -0,0 +1,104 @@ +/** + * Tests for the black-bar crop that precedes frame normalization: which + * captures count as a bordered game picture, and which dark edges are the + * game's own UI and must stay untouched. + */ + +import assert from "node:assert/strict"; +import { detectContentBox, type Roi } from "../../core/canonical"; +import { test } from "../node-test-compat"; + +interface Band { + rows?: (y: number) => number; + cols?: (x: number) => number; +} + +/** RGBA frame of `fill` grey with everything outside `picture` painted `barLevel`; `band` overrides per-row/column levels. */ +function frame( + width: number, + height: number, + picture: Roi | null, + { + fill = 120, + barLevel = 0, + band, + }: { fill?: number; barLevel?: number; band?: Band } = {}, +): Uint8ClampedArray { + const data = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const inside = + picture !== null && + x >= picture.x && + x < picture.x + picture.w && + y >= picture.y && + y < picture.y + picture.h; + let level = inside ? fill : barLevel; + if (band?.rows && !inside) level = band.rows(y); + if (band?.cols && !inside) level = band.cols(x); + const i = (y * width + x) * 4; + data[i] = level; + data[i + 1] = level; + data[i + 2] = level; + data[i + 3] = 255; + } + } + return data; +} + +test("a bar-less frame is left alone", () => { + assert.equal( + detectContentBox( + 1920, + 1080, + frame(1920, 1080, { x: 0, y: 0, w: 1920, h: 1080 }), + ), + null, + ); +}); + +test("a picture drawn smaller than its 1920x1080 canvas is found inside the black frame", () => { + const picture = { x: 28, y: 16, w: 1864, h: 1048 }; + assert.deepEqual( + detectContentBox(1920, 1080, frame(1920, 1080, picture)), + picture, + ); +}); + +test("a letterboxed 4:3 capture yields the 16:9 picture", () => { + const picture = { x: 0, y: 135, w: 1440, h: 810 }; + assert.deepEqual( + detectContentBox(1440, 1080, frame(1440, 1080, picture)), + picture, + ); +}); + +test("video-range black bars count as bars", () => { + const picture = { x: 28, y: 16, w: 1864, h: 1048 }; + assert.deepEqual( + detectContentBox(1920, 1080, frame(1920, 1080, picture, { barLevel: 16 })), + picture, + ); +}); + +test("a black screen is not cropped", () => { + assert.equal(detectContentBox(1920, 1080, frame(1920, 1080, null)), null); +}); + +test("a shallow dark edge is UI, not a bar", () => { + const picture = { x: 0, y: 5, w: 1920, h: 1075 }; + assert.equal(detectContentBox(1920, 1080, frame(1920, 1080, picture)), null); +}); + +test("a dark edge that breaks the 16:9 picture is scenery, not a bar", () => { + const picture = { x: 0, y: 100, w: 1920, h: 980 }; + assert.equal(detectContentBox(1920, 1080, frame(1920, 1080, picture)), null); +}); + +test("a scanline-textured dark edge is not level enough to be a bar", () => { + const picture = { x: 0, y: 12, w: 1920, h: 1068 }; + const scanlines = frame(1920, 1080, picture, { + band: { rows: (y) => (y % 2 === 0 ? 1 : 12) }, + }); + assert.equal(detectContentBox(1920, 1080, scanlines), null); +}); diff --git a/app/features/scanner/tests/logic/dedupe-events.test.ts b/app/features/scanner/tests/logic/dedupe-events.test.ts index ce5d779d5..c11b806c7 100644 --- a/app/features/scanner/tests/logic/dedupe-events.test.ts +++ b/app/features/scanner/tests/logic/dedupe-events.test.ts @@ -11,7 +11,6 @@ import type { MinimapEnemy, MinimapTeammate, } from "../../core/detectors/minimap/index"; -import { SPECTATOR_SLOTS } from "../../core/detectors/minimap/rois"; import type { DetectedEvent } from "../../core/detectors/types"; import { test } from "../node-test-compat"; @@ -20,7 +19,6 @@ const BRAVO: MainWeaponId[] = [50, 210, 4010, 8000]; function teammate( weaponId: MainWeaponId | null, - i: number, { name = null as string | null, abilities = [] as (AbilityWithUnknown | null)[], @@ -28,7 +26,7 @@ function teammate( } = {}, ): MinimapTeammate { return { - slot: SPECTATOR_SLOTS[i]!, + self: false, name, weaponId, abilities, @@ -48,7 +46,7 @@ function minimap( t: number, { stage = 0 as StageId | null, - teammates = ALPHA.map((id, i) => teammate(id, i)), + teammates = ALPHA.map((id) => teammate(id)), enemies = BRAVO.map((id) => enemy(id)), } = {}, ): DetectedEvent { @@ -91,7 +89,7 @@ test("a changed ability read keeps both minimaps", () => { minimap(70), minimap(73, { teammates: ALPHA.map((id, i) => - teammate(id, i, { abilities: i === 0 ? ["ISM"] : [] }), + teammate(id, { abilities: i === 0 ? ["ISM"] : [] }), ), }), ]); @@ -102,7 +100,7 @@ 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 })), + teammates: ALPHA.map((id, i) => teammate(id, { dead: i === 0 })), }), ]); assert.equal(kept.length, 2); diff --git a/app/features/scanner/tests/logic/match-builder.test.ts b/app/features/scanner/tests/logic/match-builder.test.ts index 56948d0d7..47878f539 100644 --- a/app/features/scanner/tests/logic/match-builder.test.ts +++ b/app/features/scanner/tests/logic/match-builder.test.ts @@ -11,7 +11,6 @@ import type { MinimapEnemy, MinimapTeammate, } from "../../core/detectors/minimap/index"; -import { SPECTATOR_SLOTS } from "../../core/detectors/minimap/rois"; import type { ObjectiveData } from "../../core/detectors/objective/index"; import type { PlayerStatusData } from "../../core/detectors/objective/player-status"; import type { StripWeaponsData } from "../../core/detectors/objective/strip-weapons"; @@ -135,9 +134,9 @@ function battleLogScoreboard( return { type: "ScoreboardBattleLog", t, confidence: 0.9, data }; } -function teammate(weaponId: MainWeaponId | null, i: number): MinimapTeammate { +function teammate(weaponId: MainWeaponId | null): MinimapTeammate { return { - slot: SPECTATOR_SLOTS[i]!, + self: false, name: null, weaponId, abilities: [], @@ -172,7 +171,7 @@ function minimap( stage, spectator, teammates: alpha.map((id, i) => ({ - ...teammate(id, i), + ...teammate(id), dead: dead[0].includes(i), specialReady: specialReady[0].includes(i), })), @@ -1276,10 +1275,10 @@ test("minimap enemy-card weapons vote the strip assignment too", () => { test("pov diamond cards map to scoreboard rows by name", () => { const cards = [ - { ...teammate(ALPHA[1]!, 0), name: "w2", dead: true }, - { ...teammate(ALPHA[0]!, 1), name: "w1" }, - { ...teammate(ALPHA[3]!, 2), name: "w4" }, - { ...teammate(ALPHA[2]!, 3), name: "w3" }, + { ...teammate(ALPHA[1]!), name: "w2", dead: true }, + { ...teammate(ALPHA[0]!), name: "w1" }, + { ...teammate(ALPHA[3]!), name: "w4" }, + { ...teammate(ALPHA[2]!), name: "w3" }, ]; const data: MinimapData = { stage: 0 as StageId, diff --git a/app/features/scanner/tests/minimap.test.ts b/app/features/scanner/tests/minimap.test.ts index 08cbd8960..b1eda204e 100644 --- a/app/features/scanner/tests/minimap.test.ts +++ b/app/features/scanner/tests/minimap.test.ts @@ -94,7 +94,7 @@ for (const fixture of fixtures) { for (const [i, want] of (expected.teammates ?? []).entries()) { await t.test( - `teammate ${i} (${want.slot ?? "?"})`, + `teammate ${i}`, { skip: skip(fixture, `teammates.${i}`) }, () => { const got = event.data.teammates[i]; @@ -105,7 +105,7 @@ for (const fixture of fixtures) { const cardDebug = JSON.stringify( (event.debug?.cards as unknown[])?.[i], ); - if (want.slot !== undefined) assert.equal(got.slot, want.slot); + if (want.self !== undefined) assert.equal(got.self, want.self); if ( want.name !== undefined && !isFieldSkipped(fixture, `teammates.${i}.name`) diff --git a/app/features/scanner/tests/sendou-upload.test.ts b/app/features/scanner/tests/sendou-upload.test.ts index 19dab721b..3659d35ad 100644 --- a/app/features/scanner/tests/sendou-upload.test.ts +++ b/app/features/scanner/tests/sendou-upload.test.ts @@ -55,10 +55,10 @@ test("weapons are padded to 4 slots per team so uneven rosters keep the team spl const data: MinimapData = { stage: 0, spectator: true, - teammates: (["up", "left", "right"] as const).map((slot, i) => ({ - slot, + teammates: ALPHA.slice(0, 3).map((weaponId) => ({ + self: false, name: null, - weaponId: ALPHA[i]!, + weaponId, abilities: [], dead: false, specialReady: false, diff --git a/scripts/scanner/status-audit.ts b/scripts/scanner/status-audit.ts index 0cdd7af2f..9583e4bad 100644 --- a/scripts/scanner/status-audit.ts +++ b/scripts/scanner/status-audit.ts @@ -41,7 +41,6 @@ import { type MinimapEnemy, type MinimapTeammate, } from "../../app/features/scanner/core/detectors/minimap/index"; -import type { CardSlot } from "../../app/features/scanner/core/detectors/minimap/rois"; import { OBJECTIVE_EVENT_TYPE, type ObjectiveData, @@ -95,8 +94,6 @@ const SCOREBOARD_TYPES = new Set([ SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE, ]); -const CARD_SLOTS = new Set(["up", "left", "right", "self", "down"]); - const MODE_BY_LABEL = new Map( modesShort.map((mode) => [modeLabel(mode) ?? mode, mode]), ); @@ -358,8 +355,8 @@ function parseMinimapCell(cell: string, stageCell: string): MinimapData { const rawName = spaceAt === -1 ? "" : head.slice(spaceAt + 1); const name = rawName === "?" || rawName === "" ? null : rawName; const player = { name, weaponId, abilities, dead, specialReady }; - if (CARD_SLOTS.has(label as CardSlot)) { - teammates.push({ slot: label as CardSlot, ...player }); + if (label === "self" || /^ally[1-4]$/.test(label)) { + teammates.push({ self: label === "self", ...player }); } else if (/^enemy[1-4]$/.test(label)) { enemies.push(player); } else {