diff --git a/app/features/scanner-ingest/core/Matches.ts b/app/features/scanner-ingest/core/Matches.ts index 5ab13c041..299a2afe7 100644 --- a/app/features/scanner-ingest/core/Matches.ts +++ b/app/features/scanner-ingest/core/Matches.ts @@ -46,6 +46,14 @@ export function canonicalMatch(match: ScannerMatch): ScannerMatch { match.playerStatus == null ? null : canonicalPlayerStatus(match.playerStatus), + kills: + match.kills == null + ? null + : match.kills.map((kill) => ({ + t: kill.t, + time: kill.time, + name: kill.name, + })), teams: [canonicalTeam(match.teams[0]), canonicalTeam(match.teams[1])], winner: match.winner, pov: @@ -118,6 +126,7 @@ export function mergeMatches( // series from different scans is not attempted objective: existing.objective ?? oriented.objective, playerStatus: existing.playerStatus ?? oriented.playerStatus, + kills: existing.kills ?? oriented.kills, teams: [ mergeTeam(existing.teams[0], oriented.teams[0]), mergeTeam(existing.teams[1], oriented.teams[1]), diff --git a/app/features/scanner-ingest/core/Scoreboards.test.ts b/app/features/scanner-ingest/core/Scoreboards.test.ts index 68143b709..ccde711e8 100644 --- a/app/features/scanner-ingest/core/Scoreboards.test.ts +++ b/app/features/scanner-ingest/core/Scoreboards.test.ts @@ -98,6 +98,7 @@ function testMatch({ cast: false, objective, playerStatus, + kills: null, teams: [{ players: players.slice(0, 4) }, { players: players.slice(4) }], winner: 0, pov: diff --git a/app/features/scanner-ingest/core/tests/fixtures.ts b/app/features/scanner-ingest/core/tests/fixtures.ts index cdcb6132d..cb1095922 100644 --- a/app/features/scanner-ingest/core/tests/fixtures.ts +++ b/app/features/scanner-ingest/core/tests/fixtures.ts @@ -43,6 +43,7 @@ export function scannerMatch( cast: false, objective: null, playerStatus: null, + kills: null, teams: [ { players: NAMES.slice(0, 4).map((name, i) => diff --git a/app/features/scanner-ingest/tests/harness.ts b/app/features/scanner-ingest/tests/harness.ts index fed541e29..6f30ed670 100644 --- a/app/features/scanner-ingest/tests/harness.ts +++ b/app/features/scanner-ingest/tests/harness.ts @@ -317,6 +317,7 @@ export function scannedGame( cast, objective: null, playerStatus: null, + kills: null, teams: seenFrom === "loser" ? [losers, winners] : [winners, losers], winner: seenFrom === "loser" ? 1 : 0, pov: cast ? null : { team: 0, index: 0 }, diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index 0884da87b..2189e245c 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -80,14 +80,32 @@ sequenceDiagram - The route (`routes/scanner.tsx`) is SSR-guarded: the client tree loads via `React.lazy` after `useHydrated`; nothing from `core/worker/capture/store` may be imported at route-module top level. -- Eight detectors: `scoreboard` (results screen), +- Nine detectors: `scoreboard` (results screen), `scoreboard-battle-log-replay` (replay-browser detail), `scoreboard-battle-log` (Recent Battles detail — same data sans replay code, panels stacked), `scoreboard-own` (personal results), `death` (respawn overlay), `map-start` (match intro), `minimap` (in-match overlay + casted 8-player spectator variant), `objective` (ranked counter overlay: counts, penalties, holder, match timer — a mode-discriminated union with - only the SZ member so far). The objective parse also emits a second + only the SZ member so far), `kill` (the "Splatted !" feed + bottom-center). The feed is the POV player's — on the SWS26 broadcast the + specced player's, so a cast's kills follow camera swaps. One `Kill` event + per frame carries the whole visible stack newest-first, up to four rows, + each read as one line against every language's row template + (`core/detectors/kill/localized-messages.ts`, generated) with the leftover + as the name, plus the match timer off the same frame (`objective/timer.ts`, + shared with the counter) so kills land on the game clock in every mode. The + builder reduces the stack reads to one kill per row entering the feed + (`deriveKills`: rows expire oldest-first and a blurred inner row can drop + out of a single read, so each read is matched newest-first as a + subsequence of the rows still remembered within + `KILL_ROW_LIFETIME_SECONDS`), on the same replay-wipe anchor as the + counter series; how long a row stays up is unattested, so a row outliving + that lifetime would count twice. Row text reads through the `kill-feed` + atlas, BlitzMain at the row's ~24px caps with the scoreboard-names + charset, under `parseName`'s opt-in plain-tie rule (at that size an i's + dot alone ranks the accented glyphs level with the plain one). The + objective parse also emits a second event type per read: `PlayerStatus` (`core/detectors/objective/player-status.ts`), per-player special/dead flags off the icon strip flanking the timer (three geometries named by @@ -263,7 +281,11 @@ new tests there whenever they can be written without a frame. A test case is a directory `tests/fixtures///` with `frame.png|jpg` (raw capture, never re-encoded) and `expected.json` (partial expectations, sendou ids; `stageLabel`/`weaponLabel` are informational for -the human corrector — tests compare only ids). Negative cases +the human corrector — tests compare only ids). A frame that already serves +another detector's fixture (a kill feed caught in an objective frame) is +symlinked (`ln -s ../../objective//frame.png frame.png`), not copied, +and the kill suite's cross-negative sweep skips shared frames by real path. +Negative cases (`{ "event": "none" }`) go in the shared `tests/fixtures/negative/`; every detector's suite sweeps them. Every live misread should become a fixture — the live app's "Save fixture" button exports the byte-exact analyzed frame diff --git a/app/features/scanner/components/EventCard.tsx b/app/features/scanner/components/EventCard.tsx index 475f80e2e..122d6ee71 100644 --- a/app/features/scanner/components/EventCard.tsx +++ b/app/features/scanner/components/EventCard.tsx @@ -13,6 +13,7 @@ import { DEATH_EVENT_TYPE, type DeathData, } from "../core/detectors/death/index"; +import { KILL_EVENT_TYPE, type KillData } from "../core/detectors/kill/index"; import { MAP_START_EVENT_TYPE, type MapStartData, @@ -45,6 +46,7 @@ import { DeathCard } from "./DeathCard"; import styles from "./EventCard.module.css"; import type { FixtureData } from "./fixture-export"; import { useEventTimeFormatter } from "./format"; +import { KillCard } from "./KillCard"; import { MapStartCard } from "./MapStartCard"; import { MinimapCard } from "./MinimapCard"; import { ObjectiveCard } from "./ObjectiveCard"; @@ -162,6 +164,8 @@ function renderCard( ) { return type === DEATH_EVENT_TYPE ? ( + ) : type === KILL_EVENT_TYPE ? ( + ) : type === MAP_START_EVENT_TYPE ? ( ) : type === SCOREBOARD_OWN_EVENT_TYPE ? ( diff --git a/app/features/scanner/components/EventTypeIcon.tsx b/app/features/scanner/components/EventTypeIcon.tsx index 81aeee04f..ae68d8544 100644 --- a/app/features/scanner/components/EventTypeIcon.tsx +++ b/app/features/scanner/components/EventTypeIcon.tsx @@ -5,6 +5,7 @@ import { CircleHelp, + Crosshair, History, type LucideIcon, Map as MapIcon, @@ -16,6 +17,7 @@ import { User, } from "lucide-react"; import { DEATH_EVENT_TYPE } from "../core/detectors/death/index"; +import { KILL_EVENT_TYPE } from "../core/detectors/kill/index"; import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start/index"; import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index"; import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index"; @@ -27,6 +29,7 @@ import { SCOREBOARD_OWN_EVENT_TYPE } from "../core/detectors/scoreboard-own/inde const EVENT_TYPE_ICONS: Record = { [MAP_START_EVENT_TYPE]: Play, [DEATH_EVENT_TYPE]: Skull, + [KILL_EVENT_TYPE]: Crosshair, [MINIMAP_EVENT_TYPE]: MapIcon, [OBJECTIVE_EVENT_TYPE]: Target, [SCOREBOARD_EVENT_TYPE]: Trophy, diff --git a/app/features/scanner/components/EventsSummary.tsx b/app/features/scanner/components/EventsSummary.tsx index 0478c13b6..fb31e9b62 100644 --- a/app/features/scanner/components/EventsSummary.tsx +++ b/app/features/scanner/components/EventsSummary.tsx @@ -5,6 +5,7 @@ import * as R from "remeda"; import { DEATH_EVENT_TYPE } from "../core/detectors/death/index"; +import { KILL_EVENT_TYPE } from "../core/detectors/kill/index"; import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start/index"; import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index"; import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index"; @@ -18,6 +19,7 @@ import { EventTypeIcon } from "./EventTypeIcon"; const EVENT_TYPE_LABELS: Record = { [MAP_START_EVENT_TYPE]: "map start", [DEATH_EVENT_TYPE]: "death", + [KILL_EVENT_TYPE]: "kill", [MINIMAP_EVENT_TYPE]: "minimap", [OBJECTIVE_EVENT_TYPE]: "objective", [SCOREBOARD_EVENT_TYPE]: "scoreboard", diff --git a/app/features/scanner/components/KillCard.tsx b/app/features/scanner/components/KillCard.tsx new file mode 100644 index 000000000..8f6f79602 --- /dev/null +++ b/app/features/scanner/components/KillCard.tsx @@ -0,0 +1,45 @@ +import { KILL_EVENT_TYPE, type KillData } from "../core/detectors/kill/index"; +import { EventCardMeta, EventCardShell } from "./EventCardShell"; +import { FrameThumb } from "./FrameThumb"; +import { formatClock, useEventTimeFormatter } from "./format"; +import { MetaPills } from "./MetaChips"; + +export function KillCard(props: { + t: number; + confidence: number; + data: KillData; + thumbnail?: string; + detectedAt?: number; + /** lazy loader for the exact analyzed frame — enables fixture export */ + getFrame?: () => Promise; + onInspect?: () => void; +}) { + const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } = + props; + const formatDetectedAt = useEventTimeFormatter(); + // the feed reads newest-first; listing oldest-first reads as the order they happened + const names = data.names.toReversed().map((name) => name ?? "?"); + return ( + + + + + {data.time !== null ? `${formatClock(data.time)} · ` : null} + splatted {names.join(", ")} + + {detectedAt ? {formatDetectedAt(detectedAt)} : null} + + + + ); +} diff --git a/app/features/scanner/components/LivePage.tsx b/app/features/scanner/components/LivePage.tsx index 812c680ae..70d72df24 100644 --- a/app/features/scanner/components/LivePage.tsx +++ b/app/features/scanner/components/LivePage.tsx @@ -9,6 +9,7 @@ import { } from "../capture/sampler"; import { connectAbilities } from "../core/ability-harvest"; import { DEATH_EVENT_TYPE } from "../core/detectors/death/index"; +import { KILL_EVENT_TYPE } from "../core/detectors/kill/index"; import { MAP_START_EVENT_TYPE, type MapStartData, @@ -83,6 +84,7 @@ const SCANNER_TEAM_LABELS = ["Alpha", "Bravo"] as const; const INGESTABLE_TYPES = [ MAP_START_EVENT_TYPE, DEATH_EVENT_TYPE, + KILL_EVENT_TYPE, MINIMAP_EVENT_TYPE, ...SCOREBOARD_EVENT_TYPES, ]; diff --git a/app/features/scanner/components/MatchCard.module.css b/app/features/scanner/components/MatchCard.module.css index b768b9992..27da672b3 100644 --- a/app/features/scanner/components/MatchCard.module.css +++ b/app/features/scanner/components/MatchCard.module.css @@ -386,6 +386,44 @@ button.expand { animation: scanner-detail-in 0.25s ease both; } +/* the match's kills as one wrapping line of clock + name chips */ +.kills { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--s-1) var(--s-1-5); + font-size: var(--font-2xs); +} + +.killsLabel { + display: inline-flex; + align-items: center; + gap: var(--s-1); + margin-inline-end: var(--s-1); + color: var(--color-text-high); + font-weight: var(--weight-semi); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.kill { + display: inline-flex; + align-items: center; + gap: var(--s-1); + padding: var(--s-0-5) var(--s-2); + border-radius: var(--radius-full); + border: var(--border-style); + background-color: var(--color-bg); + font-weight: var(--weight-bold); + white-space: nowrap; +} + +.killClock { + color: var(--color-text-high); + font-weight: var(--weight-semi); + font-variant-numeric: tabular-nums; +} + @keyframes scanner-detail-in { from { opacity: 0; diff --git a/app/features/scanner/components/MatchCard.tsx b/app/features/scanner/components/MatchCard.tsx index 8386a38c6..823e306ea 100644 --- a/app/features/scanner/components/MatchCard.tsx +++ b/app/features/scanner/components/MatchCard.tsx @@ -1,11 +1,11 @@ /** * Glanceable card for one ScannerMatch in the live feed: stage banner, mode + - * stage, score, team weapons and /ingest status. Expanding reveals the source - * event cards below it. + * stage, score, team weapons and /ingest status. Expanding reveals the + * match's kills (from the feed) and the source event cards below it. */ import clsx from "clsx"; -import { ChevronDown } from "lucide-react"; +import { ChevronDown, Crosshair } from "lucide-react"; import type * as React from "react"; import { useState } from "react"; import { Ability } from "~/components/Ability"; @@ -17,18 +17,30 @@ import type { IngestedMatchLink } from "~/features/scanner-ingest/scanner-ingest import type { AbilityWithUnknown, MainWeaponId, + ModeShort, } from "~/modules/in-game-lists/types"; import { sendouQMatchPage, tournamentMatchPage } from "~/utils/urls"; import type { IngestSkipReason } from "../core/match-builder"; -import type { ScannerMatch, ScannerMatchPlayer } from "../core/scanner-match"; +import type { + ScannerMatch, + ScannerMatchKill, + ScannerMatchPlayer, +} from "../core/scanner-match"; import type { SendStatus } from "../store/events"; -import { formatTime, useEventTimeFormatter } from "./format"; +import { formatClock, formatTime, useEventTimeFormatter } from "./format"; import { lobbyLabel, modeLabel, stageLabel } from "./labels"; import styles from "./MatchCard.module.css"; /** the game score a knockout wins at */ const KO_MATCH_SCORE = 100; +/** + * Where the match clock starts, to turn a time-left read into time elapsed: + * Turf War runs 3:00, the ranked modes 5:00 (overtime reads clamp to the end). + */ +const MATCH_CLOCK_SECONDS: Partial> = { TW: 180 }; +const DEFAULT_MATCH_CLOCK_SECONDS = 300; + /** one per gear slot: [head, clothes, shoes], the arc's left-to-right order */ const UNKNOWN_MAIN_ABILITIES: AbilityWithUnknown[] = [ "UNKNOWN", @@ -186,7 +198,45 @@ export function MatchCard({ return (
{card} - {expanded ?
{children}
: null} + {expanded ? ( +
+ {match.kills ? ( + + ) : null} + {children} +
+ ) : null} +
+ ); +} + +/** The POV player's splats off the kill feed, in the order they happened, stamped with match time elapsed. */ +function MatchKills({ + kills, + mode, +}: { + kills: readonly ScannerMatchKill[]; + mode: ModeShort | null; +}) { + const clockStart = + (mode !== null ? MATCH_CLOCK_SECONDS[mode] : undefined) ?? + DEFAULT_MATCH_CLOCK_SECONDS; + return ( +
+ + + kills · {kills.length} + + {kills.map((kill, i) => ( + + + {kill.time !== null + ? formatClock(Math.max(0, clockStart - kill.time)) + : "?:??"} + + {kill.name ?? "?"} + + ))}
); } diff --git a/app/features/scanner/components/ScreenshotPage.tsx b/app/features/scanner/components/ScreenshotPage.tsx index b24ee8657..d9dc14bc0 100644 --- a/app/features/scanner/components/ScreenshotPage.tsx +++ b/app/features/scanner/components/ScreenshotPage.tsx @@ -12,6 +12,8 @@ import { mainWeaponImageUrl } from "~/utils/urls"; import { CANONICAL_HEIGHT, CANONICAL_WIDTH, type Roi } from "../core/canonical"; import type { DeathData } from "../core/detectors/death/index"; import * as death from "../core/detectors/death/rois"; +import type { KillData } from "../core/detectors/kill/index"; +import * as kill from "../core/detectors/kill/rois"; import type { MapStartData } from "../core/detectors/map-start/index"; import * as mapStart from "../core/detectors/map-start/rois"; import type { MinimapData } from "../core/detectors/minimap/index"; @@ -224,6 +226,10 @@ function gateSummary(result: Result): string | null { const data = event.data as unknown as ObjectiveData; return `${confidence} · ${formatTimer(data.time)} · score ${data.score[0] ?? "?"}–${data.score[1] ?? "?"}`; } + case "kill": { + const data = event.data as unknown as KillData; + return `${confidence} · ${formatTimer(data.time)} · splatted ${data.names.map((name) => name ?? "?").join(", ")}`; + } default: { const data = event.data as CardData; return `${confidence} · scores ${JSON.stringify(data.matchScores)} · ${[lobbyLabel(data.lobby), modeLabel(data.mode), stageLabel(data.stage)].map((v) => v ?? "?").join(" · ")}`; @@ -292,6 +298,15 @@ function drawOverlay(ctx: CanvasRenderingContext2D, detector: string) { } return; } + if (detector === "kill") { + for (let row = 0; row < kill.MAX_ROWS; row++) { + rect(kill.textRoi(row), "#34d399"); + rect(kill.skullRoi(row), "#60a5fa"); + for (const roi of kill.darkProbes(row)) rect(roi, "#facc15"); + } + rect(objective.TIMER_DIGIT_ROI, "#f87171"); + return; + } if (detector === "map-start") { rect(mapStart.MODE_LABEL_ROI, "#34d399"); rect(mapStart.MODE_BLOCK_ROI, "#f87171"); @@ -501,6 +516,7 @@ export function ScreenshotPage() { const isOwn = activeDetector === "scoreboard-own"; const isMinimap = activeDetector === "minimap"; const isObjective = activeDetector === "objective"; + const isKill = activeDetector === "kill"; const winnerSide = String(event?.debug?.winnerSide ?? "left"); const rowRois = isReplay ? replayRows(winnerSide) @@ -857,13 +873,52 @@ export function ScreenshotPage() { ) : null} + {frame && event && isKill ? ( +
+ {(() => { + const data = event.data as unknown as KillData; + const rows = (event.debug?.rows ?? []) as { raw?: string }[]; + return ( + <> +
+ + {formatTimer(data.time)} + + {data.names.map((name, row) => ( + + {name ?? "?"} + + ))} +
+
+ {data.names.map((_, row) => ( + + ))} + +
+ + ); + })()} +
+ ) : null} + {frame && event && !isDeath && !isMapStart && !isOwn && !isMinimap && - !isObjective ? ( + !isObjective && + !isKill ? ( diff --git a/app/features/scanner/components/events-csv.ts b/app/features/scanner/components/events-csv.ts index 813a7ba02..169dc0404 100644 --- a/app/features/scanner/components/events-csv.ts +++ b/app/features/scanner/components/events-csv.ts @@ -9,6 +9,7 @@ import { DEATH_EVENT_TYPE, type DeathData, } from "../core/detectors/death/index"; +import { KILL_EVENT_TYPE, type KillData } from "../core/detectors/kill/index"; import { MAP_START_EVENT_TYPE, type MapStartData, @@ -211,6 +212,29 @@ function eventCells(event: CsvEvent): Cell[] { "", ]; } + case KILL_EVENT_TYPE: { + const d = event.data as KillData; + const clock = d.time === null ? "" : `${formatClock(d.time)} · `; + return [ + ...base, + "", + "", + "", + "", + "", + "", + "", + // oldest first, the order the splats happened + `${clock}${d.names + .toReversed() + .map((name) => name ?? "?") + .join(" | ")}`, + "", + "", + "", + "", + ]; + } case OBJECTIVE_EVENT_TYPE: { const d = event.data as ObjectiveData; const sideText = (side: 0 | 1) => diff --git a/app/features/scanner/components/fixture-export.ts b/app/features/scanner/components/fixture-export.ts index b8f0a3b47..55a90daba 100644 --- a/app/features/scanner/components/fixture-export.ts +++ b/app/features/scanner/components/fixture-export.ts @@ -7,6 +7,7 @@ import { DEATH_EVENT_TYPE, type DeathData, } from "../core/detectors/death/index"; +import { KILL_EVENT_TYPE, type KillData } from "../core/detectors/kill/index"; import { MAP_START_EVENT_TYPE, type MapStartData, @@ -48,7 +49,8 @@ export type FixtureData = | MinimapData | ObjectiveData | PlayerStatusData - | StripWeaponsData; + | StripWeaponsData + | KillData; function isDeath(_data: FixtureData, eventType: string): _data is DeathData { return eventType === DEATH_EVENT_TYPE; @@ -201,6 +203,14 @@ function buildExpectedJson( 2, )}\n`; } + if (eventType === KILL_EVENT_TYPE) { + const kill = data as KillData; + return `${JSON.stringify( + { event: eventType, data: { time: kill.time, names: kill.names } }, + null, + 2, + )}\n`; + } // NB: not a type-predicate helper — CardData is structurally assignable to // MapStartData, so a predicate would narrow the fall-through case to never if (eventType === MAP_START_EVENT_TYPE) { diff --git a/app/features/scanner/core/detectors/kill/index.ts b/app/features/scanner/core/detectors/kill/index.ts new file mode 100644 index 000000000..5f4007e51 --- /dev/null +++ b/app/features/scanner/core/detectors/kill/index.ts @@ -0,0 +1,193 @@ +/** + * KillDetector: parses the kill feed — the "Splatted !" pills stacked + * bottom-center whenever the POV player splats someone (on the SWS26 + * broadcast: the specced player, so a cast's feed follows camera swaps). + * Each pill reads as one text line snapped + * against every language's row template (message.ts): the constant text + * doubles as confirmation, so a lookalike gate hit emits nothing, and what it + * leaves over is the splatted player's name. Rows are read bottom-up (newest + * first) until one is missing, and the top-center match timer is read off the + * same frame (objective/timer.ts) so kills plot on the game clock in every + * mode, not only where the counter is parsed. + */ +import { getCV, type Mat } from "../../cv"; +import { type GlyphSet, scaleGlyphSet } from "../../glyphs"; +import { copyRoi, maxBrightness, meanBrightness, type Roi } from "../../image"; +import { + readMatchTimer, + timerBoxChecks, + timerGlyphSets, +} from "../objective/timer"; +import type { ScoreboardResources } from "../scoreboard/index"; +import { type ParsedName, parseName } from "../scoreboard/names"; +import type { DetectedEvent, Detector, GateResult } from "../types"; +import { matchKillMessage } from "./message"; +import { + darkProbes, + GATE_DARK_MAX_MEAN, + GATE_SKULL_MAX_MEAN, + GATE_SKULL_MIN_MAX, + GATE_SKULL_MIN_MEAN, + GATE_TEXT_MAX_FRACTION, + GATE_TEXT_MIN_FRACTION, + GATE_TEXT_MIN_MAX, + KILL_TEXT_BIN_THRESHOLD, + KILL_TEXT_READ_HEIGHT, + MAX_ROWS, + skullRoi, + textRoi, +} from "./rois"; + +export interface KillData { + /** match timer seconds ("3:06" = 186) off the top-center clock; null when unreadable */ + time: number | null; + /** + * every feed row visible in the frame, bottom row first (index 0 = newest + * splat; older rows shift upward), up to MAX_ROWS. A row whose name is + * unreadable stays as null so it still counts as a splat. + */ + names: (string | null)[]; +} + +export const KILL_EVENT_TYPE = "Kill"; + +/** + * Template fit below this is a lookalike row (fixtures read 0.89-1.0; the + * garbled reads of a mis-scaled atlas peaked at 0.67). + */ +const MESSAGE_MIN_SCORE = 0.75; + +/** + * parseName's plain-tie margin at feed size: the dot of 'i' alone lands 'í'/ + * 'ì' 0.00-0.03 over 'i' (datkid, Burstie), a blurred 'l' lands 'í' 0.03 over + * the bar glyphs (leafi), a bracket lands 【 0.015 over '[' ([K]yo) — while a + * real accent or double stroke ranks the plain form well lower. + */ +const PLAIN_TIE_MARGIN = 0.05; + +/** Timeline content guard: repeat frames of one stack merge, a row entering or leaving keeps its own event. */ +export function sameKillData(a: unknown, b: unknown): boolean { + const da = a as KillData; + const db = b as KillData; + return ( + da.names.length === db.names.length && + da.names.every((name, i) => name === db.names[i]) + ); +} + +export function createKillDetector( + resources: ScoreboardResources, +): Detector { + const cv = getCV(); + const glyphs: GlyphSet | null = resources.killFeedGlyphs + ? scaleGlyphSet( + resources.killFeedGlyphs, + KILL_TEXT_READ_HEIGHT / resources.killFeedGlyphs.height, + ) + : null; + const timerSets = timerGlyphSets(resources); + + function whiteFraction(gray: Mat, roi: Roi): number { + const crop = copyRoi(gray, roi); + const bin = new cv.Mat(); + cv.threshold(crop, bin, GATE_TEXT_MIN_MAX, 255, cv.THRESH_BINARY); + crop.delete(); + const fraction = cv.countNonZero(bin) / (bin.rows * bin.cols); + bin.delete(); + return fraction; + } + + /** One row's presence checks: pill interior dark around the line, white skull art, one line of white text. */ + function rowChecks(gray: Mat, row: number): boolean[] { + const skull = skullRoi(row); + const skullMean = meanBrightness(gray, skull); + const text = textRoi(row); + const textFraction = whiteFraction(gray, text); + return [ + ...darkProbes(row).map( + (roi) => meanBrightness(gray, roi) <= GATE_DARK_MAX_MEAN, + ), + skullMean >= GATE_SKULL_MIN_MEAN && + skullMean <= GATE_SKULL_MAX_MEAN && + maxBrightness(gray, skull) >= GATE_SKULL_MIN_MAX, + maxBrightness(gray, text) >= GATE_TEXT_MIN_MAX && + textFraction >= GATE_TEXT_MIN_FRACTION && + textFraction <= GATE_TEXT_MAX_FRACTION, + ]; + } + + function gate(frame: Mat): GateResult { + const gray = new cv.Mat(); + cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); + const checks = rowChecks(gray, 0); + gray.delete(); + const passed = checks.filter(Boolean).length; + return { pass: passed === checks.length, score: passed / checks.length }; + } + + function readRow(gray: Mat, row: number): ParsedName { + const band = copyRoi(gray, textRoi(row)); + const parsed = parseName(band, glyphs!, { + binThreshold: KILL_TEXT_BIN_THRESHOLD, + spaceGap: Math.max(6, Math.round(glyphs!.medianWidth * 0.55)), + plainTieMargin: PLAIN_TIE_MARGIN, + }); + band.delete(); + return parsed; + } + + function parse(frame: Mat, t: number): DetectedEvent[] { + if (!glyphs) return []; + const gray = new cv.Mat(); + cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); + + const names: (string | null)[] = []; + const confidences: number[] = []; + const rows: Record[] = []; + for (let row = 0; row < MAX_ROWS; row++) { + if (row > 0 && !rowChecks(gray, row).every(Boolean)) break; + const parsed = readRow(gray, row); + const message = matchKillMessage(parsed.name); + rows.push({ + raw: parsed.raw.text, + read: parsed.name, + readScore: parsed.confidence, + messageLangs: message?.template.langs, + messageScore: message?.score, + }); + if (!message || message.score < MESSAGE_MIN_SCORE) break; + names.push(message.name); + confidences.push((message.score + parsed.confidence) / 2); + } + if (names.length === 0) { + gray.delete(); + return []; + } + + const timer = timerBoxChecks(gray).every(Boolean) + ? readMatchTimer(gray, timerSets) + : { value: null, reading: "" }; + gray.delete(); + + return [ + { + type: KILL_EVENT_TYPE, + t, + confidence: + confidences.reduce((sum, c) => sum + c, 0) / confidences.length, + data: { time: timer.value, names }, + debug: { rows, timerRaw: timer.reading }, + }, + ]; + } + + // rows live a few seconds and a stack grows while it shows, so the feed is + // a long-lived screen with changing content: a fixed cadence instead of + // steady-frame suppression (a row samples several times before expiring) + return { + id: "kill", + checkIntervalS: 0.5, + gate, + parse, + }; +} diff --git a/app/features/scanner/core/detectors/kill/localized-messages.ts b/app/features/scanner/core/detectors/kill/localized-messages.ts new file mode 100644 index 000000000..71d4f760e --- /dev/null +++ b/app/features/scanner/core/detectors/kill/localized-messages.ts @@ -0,0 +1,84 @@ +/** + * GENERATED by scripts/scanner/build-localized-entries.ts from the splat3 repo's + * language dumps — do not edit by hand; regenerate when the game adds + * content. Per-language kill-feed row templates: the "Splatted !" row + * wraps the splatted player's name in language-specific text on either side + * (spaces kept as rendered; either side may be empty). + */ + +export interface KillMessageTemplate { + /** languages sharing this exact template */ + langs: readonly string[]; + /** constant text before the name (may end with a space, or be empty) */ + pre: string; + /** constant text after the name (may start with a space, or be empty) */ + post: string; +} + +export const KILL_MESSAGE_TEMPLATES: readonly KillMessageTemplate[] = [ + { + langs: ["CNzh"], + pre: "击倒了", + post: "!", + }, + { + langs: ["EUde"], + pre: "", + post: " erledigt!", + }, + { + langs: ["EUen", "USen"], + pre: "Splatted ", + post: "!", + }, + { + langs: ["EUes"], + pre: "¡Has eliminado a ", + post: "!", + }, + { + langs: ["EUfr"], + pre: "Tu as liquidé ", + post: " !", + }, + { + langs: ["EUit"], + pre: "Hai splattato ", + post: "!", + }, + { + langs: ["EUnl"], + pre: "", + post: " uitgeschakeld!", + }, + { + langs: ["EUru"], + pre: "Плюхнут игрок ", + post: "!", + }, + { + langs: ["JPja"], + pre: "", + post: " をたおした!", + }, + { + langs: ["KRko"], + pre: "", + post: " 쓰러뜨렸다!", + }, + { + langs: ["TWzh"], + pre: "將", + post: "擊倒了!", + }, + { + langs: ["USes"], + pre: "¡Reventaste a ", + post: "!", + }, + { + langs: ["USfr"], + pre: "Éclaboussé ", + post: "!", + }, +]; diff --git a/app/features/scanner/core/detectors/kill/message.ts b/app/features/scanner/core/detectors/kill/message.ts new file mode 100644 index 000000000..cb8c7467f --- /dev/null +++ b/app/features/scanner/core/detectors/kill/message.ts @@ -0,0 +1,132 @@ +/** + * Kill-feed row text → splatted name. A row reads as one line ("Splatted + * nwrm!"); the language template whose constant text fits the line's head + * and tail best wins, and whatever sits between is the name. Characters are + * folded (case, diacritics) one-to-one so the cut points map back onto the + * original read; spaces only drop out inside the comparisons, since OCR word + * gaps wobble. + */ +import { editDistance } from "../../text"; +import { + KILL_MESSAGE_TEMPLATES, + type KillMessageTemplate, +} from "./localized-messages"; + +export interface KillMessageMatch { + template: KillMessageTemplate; + /** 0..1 fit of the constant text, pre and post weighted by their length */ + score: number; + /** what the constant parts leave over; null when nothing does */ + name: string | null; +} + +/** A cut may land this many characters off the constant text's length (dropped/doubled glyphs). */ +const CUT_SLACK = 2; + +/** + * Handicap of a cut inside a word where the constant text meets the name at a + * space: more than one glyph's share of the shortest such constant text + * (をたおした, 1/6), so a gap-aligned cut off by one glyph still wins. + */ +const WORD_SPLIT_PENALTY = 0.2; + +/** At feed size the row's "!" reads as any of these; the tail comparison treats them as one. */ +const EXCLAMATION_LOOKALIKES = new Set([ + "!", + "|", + "l", + "i", + "1", + "「", + "¡", + "!", +]); + +/** The best-fitting template for a row read; null only when no template has constant text. */ +export function matchKillMessage( + read: string, + templates: readonly KillMessageTemplate[] = KILL_MESSAGE_TEMPLATES, +): KillMessageMatch | null { + const chars = [...read]; + const folded = chars.map(foldChar); + let best: KillMessageMatch | null = null; + for (const template of templates) { + const pre = foldConstant(template.pre); + const post = foldConstant(template.post); + if (pre.length + post.length === 0) continue; + const head = bestCut(folded, pre, "head", template.pre.endsWith(" ")); + const tail = bestCut(folded, post, "tail", template.post.startsWith(" ")); + if (head.length + tail.length > chars.length) continue; + const score = + (head.score * pre.length + tail.score * post.length) / + (pre.length + post.length); + if (best && score <= best.score) continue; + const name = chars + .slice(head.length, chars.length - tail.length) + .join("") + .trim(); + best = { template, score, name: name.length > 0 ? name : null }; + } + return best; +} + +function foldChar(ch: string): string { + const base = ch.normalize("NFD").replace(/[̀-ͯ]/g, ""); + return (base.length > 0 ? base : ch).toLowerCase()[0]!; +} + +function foldConstant(text: string): string[] { + return [...text].filter((ch) => ch !== " ").map(foldChar); +} + +function foldExclamations(chars: readonly string[]): string { + return chars + .map((ch) => (EXCLAMATION_LOOKALIKES.has(ch) ? "!" : ch)) + .join(""); +} + +/** + * How many characters of the read's head or tail the constant text covers: + * every length within CUT_SLACK of the constant's is scored (spaces + * excluded), the best fit wins, ties go to the length nearest the constant's. + * Where the constant text meets the name at a space, a cut inside a word is + * handicapped (WORD_SPLIT_PENALTY): "Splatte datkid" must lose the 'd' of + * its constant, not the first letter of the name. Nothing is cut when nothing + * fits at all — better a name with a stray character than one missing its last. + */ +function bestCut( + folded: readonly string[], + constant: readonly string[], + side: "head" | "tail", + gapped: boolean, +): { length: number; score: number } { + if (constant.length === 0) return { length: 0, score: 1 }; + const target = + side === "tail" ? foldExclamations(constant) : constant.join(""); + let best = { length: 0, score: 0, adjusted: 0 }; + const min = Math.max(0, constant.length - CUT_SLACK); + const max = Math.min(folded.length, constant.length + CUT_SLACK); + for (let length = min; length <= max; length++) { + const cut = side === "head" ? length : folded.length - length; + const segment = ( + side === "head" ? folded.slice(0, cut) : folded.slice(cut) + ).filter((ch) => ch !== " "); + const text = side === "tail" ? foldExclamations(segment) : segment.join(""); + const score = + 1 - editDistance(text, target) / Math.max(text.length, target.length, 1); + const atWordGap = + cut === 0 || + cut === folded.length || + folded[cut] === " " || + folded[cut - 1] === " "; + const adjusted = gapped && !atWordGap ? score - WORD_SPLIT_PENALTY : score; + const closer = + adjusted === best.adjusted && + Math.abs(length - constant.length) < + Math.abs(best.length - constant.length); + if (adjusted > best.adjusted || closer) best = { length, score, adjusted }; + } + return best.score > 0 + ? { length: best.length, score: best.score } + : { length: 0, score: 0 }; +} diff --git a/app/features/scanner/core/detectors/kill/rois.ts b/app/features/scanner/core/detectors/kill/rois.ts new file mode 100644 index 000000000..3a2453bbb --- /dev/null +++ b/app/features/scanner/core/detectors/kill/rois.ts @@ -0,0 +1,74 @@ +/** + * Kill-feed ROIs in canonical 1920x1080 space, calibrated against the kill/ + * fixtures (row/column brightness profiling). Every splat by the POV player + * draws one dark pill bottom-center (x703..1217, 50px tall) holding a white + * skull, a team-ink squid and the "Splatted !" line — BlitzMain, caps + * y1004..1028, centered in the text area right of the icons. A further splat + * adds a pill at the bottom and shifts the older ones up by ROW_PITCH (a + * ~15px gap of gameplay shows between pills); the bottom row is the newest. + */ +import type { Roi } from "../../canonical"; + +/** A wipeout is four rows; a respawned enemy can't re-enter before the oldest row expires. */ +export const MAX_ROWS = 4; + +/** Text caps sit at y1004 on the bottom row and y938 on the one above. */ +const ROW_PITCH = 66; + +/** Bottom row's text band: from the squid icon's right edge to the pill's plain right end, with drift headroom. */ +const TEXT_ROI_BOTTOM: Roi = { x: 796, y: 997, w: 412, h: 38 }; + +/** Tight cap height of the row text (atlas nominal height). */ +export const KILL_TEXT_HEIGHT = 24; + +/** + * The atlas scaled up one pixel reads both the native-1080p and the + * 720p-upscaled fixture best (0.81-0.88 vs 0.73-0.76 at nominal): the game's + * compositing fattens strokes the way the cubic upscale does. + */ +export const KILL_TEXT_READ_HEIGHT = 25; + +/** White text on the near-black pill; the squid icon's tint stays well under. */ +export const KILL_TEXT_BIN_THRESHOLD = 150; + +/** + * Pill-interior dark probes of the bottom row: the margin left of the skull, + * the plain right end (the barcode decoration there reads ~30 too), and the + * bands above and below the text line. Fixtures read 25..34 on each. + */ +const DARK_PROBES_BOTTOM: readonly Roi[] = [ + { x: 706, y: 998, w: 18, h: 34 }, + { x: 1196, y: 998, w: 10, h: 34 }, + { x: 850, y: 992, w: 250, h: 6 }, + { x: 850, y: 1031, w: 250, h: 6 }, +]; +export const GATE_DARK_MAX_MEAN = 70; + +/** The white skull icon left of the squid: bright art on the dark pill. */ +const SKULL_ROI_BOTTOM: Roi = { x: 729, y: 1002, w: 28, h: 28 }; +export const GATE_SKULL_MIN_MAX = 200; +export const GATE_SKULL_MIN_MEAN = 60; +export const GATE_SKULL_MAX_MEAN = 210; + +/** The text band must contain near-white pixels... */ +export const GATE_TEXT_MIN_MAX = 210; +/** ...but not too many: it is one short line, not a white panel. */ +export const GATE_TEXT_MAX_FRACTION = 0.35; +export const GATE_TEXT_MIN_FRACTION = 0.01; + +function shiftedUp(roi: Roi, row: number): Roi { + return { ...roi, y: roi.y - row * ROW_PITCH }; +} + +/** Row 0 is the bottom (newest) pill; each further row sits ROW_PITCH higher. */ +export function textRoi(row: number): Roi { + return shiftedUp(TEXT_ROI_BOTTOM, row); +} + +export function darkProbes(row: number): Roi[] { + return DARK_PROBES_BOTTOM.map((roi) => shiftedUp(roi, row)); +} + +export function skullRoi(row: number): Roi { + return shiftedUp(SKULL_ROI_BOTTOM, row); +} diff --git a/app/features/scanner/core/detectors/objective/index.ts b/app/features/scanner/core/detectors/objective/index.ts index ec197ae46..00e366204 100644 --- a/app/features/scanner/core/detectors/objective/index.ts +++ b/app/features/scanner/core/detectors/objective/index.ts @@ -10,20 +10,8 @@ * the same frame, paired downstream by the shared timer value. */ import { getCV, type Mat, minMaxLoc } from "../../cv"; -import { - type GlyphSet, - type RecognizedChar, - recognizeText, - scaleGlyphSet, -} from "../../glyphs"; -import { - copyRoi, - maxBrightness, - maxChannel, - meanBrightness, - minChannel, - type Roi, -} from "../../image"; +import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs"; +import { copyRoi, maxChannel, minChannel, type Roi } from "../../image"; import { type InkRgb, meanInkColor } from "../../ink-color"; import { type BannerScoreRead, @@ -41,8 +29,6 @@ import { CONTROL_PLATE_MIN_SATURATION, GATE_PLATE_MAX_STD, GATE_SCORE_MIN_MAX_BRIGHTNESS, - GATE_TIMER_MAX_MEAN, - GATE_TIMER_MIN_MAX_BRIGHTNESS, PENALTY_BIN_THRESHOLD, PENALTY_PROBE_MAX_MEAN, PENALTY_PROBE_MAX_STD, @@ -57,14 +43,9 @@ import { SCORE_TEXT_HEIGHTS, STATUS_LAYOUT_STICKY_MAX_GAP_S, STRIP_WEAPON_SAMPLE_INTERVAL, - TIMER_BIN_THRESHOLD, - TIMER_DARK_PROBES, - TIMER_DIGIT_MIN_CONF, - TIMER_DIGIT_MIN_HEIGHT_RATIO, - TIMER_DIGIT_ROI, - TIMER_TEXT_HEIGHTS, } from "./rois"; import { parseStripWeapons, type StripWeaponsData } from "./strip-weapons"; +import { readMatchTimer, timerBoxChecks, timerGlyphSets } from "./timer"; export type ObjectiveData = SplatZonesObjectiveData; @@ -140,14 +121,7 @@ export function createObjectiveDetector( PENALTY_TEXT_HEIGHT / resources.paintDigits.height, ) : null; - const timerSets: GlyphSet[] = resources.paintDigits - ? TIMER_TEXT_HEIGHTS.map((h) => - scaleGlyphSet( - resources.paintDigits!, - h / resources.paintDigits!.height, - ), - ) - : []; + const timerSets = timerGlyphSets(resources); /** Mean and standard deviation of a grayscale ROI. */ function meanStd(gray: Mat, roi: Roi): { mean: number; std: number } { @@ -177,10 +151,7 @@ export function createObjectiveDetector( const gray = new cv.Mat(); cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); const checks = [ - ...TIMER_DARK_PROBES.map( - (roi) => meanBrightness(gray, roi) <= GATE_TIMER_MAX_MEAN, - ), - maxBrightness(gray, TIMER_DIGIT_ROI) >= GATE_TIMER_MIN_MAX_BRIGHTNESS, + ...timerBoxChecks(gray), plateProbeOk(gray, PLATE_PROBE_ROIS[0]), plateProbeOk(gray, PLATE_PROBE_ROIS[1]), scoreInkOk(frame, SCORE_ROIS[0]), @@ -226,50 +197,6 @@ export function createObjectiveDetector( return best; } - /** - * M:SS timer: the colon's dots fall under the digit height floor, so a valid - * read is exactly three full-height digits. Each glyph size is tried (digits - * render bigger on upscaled 720p) and the most confident valid read wins. - */ - function readTimer(gray: Mat): { value: number | null; reading: string } { - const band = copyRoi(gray, TIMER_DIGIT_ROI); - let best: { value: number | null; reading: string; score: number } = { - value: null, - reading: "", - score: 0, - }; - for (const timerSet of timerSets) { - const raw = recognizeText(band, timerSet, { - binThreshold: TIMER_BIN_THRESHOLD, - spaceGap: Number.POSITIVE_INFINITY, - minCharScore: 0.3, - }); - if (!best.reading) best = { ...best, reading: raw.text }; - const isTimerDigit = (c: RecognizedChar) => - c.score >= TIMER_DIGIT_MIN_CONF && - c.y1 - c.y0 >= timerSet.height * TIMER_DIGIT_MIN_HEIGHT_RATIO; - const chars = raw.chars.filter(isTimerDigit); - const digits = chars.map((c) => Number(c.char)); - if (digits.length !== 3 || digits.some(Number.isNaN)) continue; - const [minutes, secondsTens, secondsOnes] = digits as [ - number, - number, - number, - ]; - if (secondsTens >= 6) continue; - const score = chars.reduce((sum, c) => sum + c.score, 0) / chars.length; - if (score > best.score) { - best = { - value: minutes * 60 + secondsTens * 10 + secondsOnes, - reading: raw.text, - score, - }; - } - } - band.delete(); - return { value: best.value, reading: best.reading }; - } - /** * Penalty pill: presence probes at the rounded ends first, then the white * "+N" digits. A nameplate badge can cover one end, so a lone pill-like @@ -351,7 +278,7 @@ export function createObjectiveDetector( ]), }; }) as [SideRead, SideRead]; - const timer = readTimer(gray); + const timer = readMatchTimer(gray, timerSets); gray.delete(); // no readable count on either side = the gate hit a lookalike diff --git a/app/features/scanner/core/detectors/objective/timer.ts b/app/features/scanner/core/detectors/objective/timer.ts new file mode 100644 index 000000000..79cb42c50 --- /dev/null +++ b/app/features/scanner/core/detectors/objective/timer.ts @@ -0,0 +1,94 @@ +/** + * The top-center match timer (M:SS in a near-black box), read by the objective + * counter and the kill feed alike so both land on the same game-clock axis. + * The colon's dots fall under the digit height floor, so a valid read is + * exactly three full-height digits; each glyph size is tried (digits render + * bigger on upscaled 720p) and the most confident valid read wins. + */ +import type { Mat } from "../../cv"; +import { + type GlyphSet, + type RecognizedChar, + recognizeText, + scaleGlyphSet, +} from "../../glyphs"; +import { copyRoi, maxBrightness, meanBrightness } from "../../image"; +import type { ScoreboardResources } from "../scoreboard/index"; +import { + GATE_TIMER_MAX_MEAN, + GATE_TIMER_MIN_MAX_BRIGHTNESS, + TIMER_BIN_THRESHOLD, + TIMER_DARK_PROBES, + TIMER_DIGIT_MIN_CONF, + TIMER_DIGIT_MIN_HEIGHT_RATIO, + TIMER_DIGIT_ROI, + TIMER_TEXT_HEIGHTS, +} from "./rois"; + +export interface TimerRead { + /** match timer seconds ("3:35" = 215); null = unreadable */ + value: number | null; + /** the raw digit-run reading, for debugging */ + reading: string; +} + +/** Paint digits rescaled to each attested timer digit size; empty without the atlas. */ +export function timerGlyphSets(resources: ScoreboardResources): GlyphSet[] { + const digits = resources.paintDigits; + if (!digits) return []; + return TIMER_TEXT_HEIGHTS.map((h) => + scaleGlyphSet(digits, h / digits.height), + ); +} + +/** Per-probe presence checks of the timer box: dark surround, then bright digits. */ +export function timerBoxChecks(gray: Mat): boolean[] { + return [ + ...TIMER_DARK_PROBES.map( + (roi) => meanBrightness(gray, roi) <= GATE_TIMER_MAX_MEAN, + ), + maxBrightness(gray, TIMER_DIGIT_ROI) >= GATE_TIMER_MIN_MAX_BRIGHTNESS, + ]; +} + +export function readMatchTimer( + gray: Mat, + timerSets: readonly GlyphSet[], +): TimerRead { + const band = copyRoi(gray, TIMER_DIGIT_ROI); + let best: TimerRead & { score: number } = { + value: null, + reading: "", + score: 0, + }; + for (const timerSet of timerSets) { + const raw = recognizeText(band, timerSet, { + binThreshold: TIMER_BIN_THRESHOLD, + spaceGap: Number.POSITIVE_INFINITY, + minCharScore: 0.3, + }); + if (!best.reading) best = { ...best, reading: raw.text }; + const isTimerDigit = (c: RecognizedChar) => + c.score >= TIMER_DIGIT_MIN_CONF && + c.y1 - c.y0 >= timerSet.height * TIMER_DIGIT_MIN_HEIGHT_RATIO; + const chars = raw.chars.filter(isTimerDigit); + const digits = chars.map((c) => Number(c.char)); + if (digits.length !== 3 || digits.some(Number.isNaN)) continue; + const [minutes, secondsTens, secondsOnes] = digits as [ + number, + number, + number, + ]; + if (secondsTens >= 6) continue; + const score = chars.reduce((sum, c) => sum + c.score, 0) / chars.length; + if (score > best.score) { + best = { + value: minutes * 60 + secondsTens * 10 + secondsOnes, + reading: raw.text, + score, + }; + } + } + band.delete(); + return { value: best.value, reading: best.reading }; +} diff --git a/app/features/scanner/core/detectors/registry.ts b/app/features/scanner/core/detectors/registry.ts index a12fbd021..f1e281212 100644 --- a/app/features/scanner/core/detectors/registry.ts +++ b/app/features/scanner/core/detectors/registry.ts @@ -1,6 +1,7 @@ /** Every detector that runs on a frame; new event types register here. */ import { createDeathDetector } from "./death/index"; +import { createKillDetector } from "./kill/index"; import { createMapStartDetector } from "./map-start/index"; import { createMinimapDetector } from "./minimap/index"; import { createObjectiveDetector } from "./objective/index"; @@ -39,5 +40,6 @@ export function createAllDetectors( createMapStartDetector(resources) as Detector, createMinimapDetector(resources) as Detector, createObjectiveDetector(resources) as Detector, + createKillDetector(resources) as Detector, ]; } diff --git a/app/features/scanner/core/detectors/scoreboard/index.ts b/app/features/scanner/core/detectors/scoreboard/index.ts index 4cae37774..9c884b233 100644 --- a/app/features/scanner/core/detectors/scoreboard/index.ts +++ b/app/features/scanner/core/detectors/scoreboard/index.ts @@ -132,6 +132,11 @@ export interface ScoreboardResources { */ mapStartModeGlyphs?: GlyphSet | null; mapStartStageGlyphs?: GlyphSet | null; + /** + * Kill-feed row glyphs (BlitzMain at the row's ~24px caps, name charset plus + * every language's row text); without them the kill detector emits nothing. + */ + killFeedGlyphs?: GlyphSet | null; /** * Minimap extras: main-weapon icons on the card-pill background, a * light-background variant for special-ready camo, and ability icons at the diff --git a/app/features/scanner/core/detectors/scoreboard/names.ts b/app/features/scanner/core/detectors/scoreboard/names.ts index fc60c2634..1a2823bbf 100644 --- a/app/features/scanner/core/detectors/scoreboard/names.ts +++ b/app/features/scanner/core/detectors/scoreboard/names.ts @@ -240,16 +240,58 @@ function resolveCaseByDescent(raw: RecognizedText): string { .join(""); } +/** + * Near-tie homoglyphs re-decided toward the plain form before the context + * rules run (opt-in via `plainTieMargin`; the kill feed's ~24px rows need it): + * the dot of 'i' alone ranks 'í'/'ì' level with 'i', a blurred 'l' ranks 'í' + * over the bar glyphs, and a fullwidth bracket lands level with its ASCII + * twin — while a real accent or double stroke ranks the plain form well + * lower. A dotted vowel may also fall to a bar glyph, which the bar rule + * then reads in context. + */ +const PLAIN_TWINS: Record = { "【": "[", "】": "]" }; + +function preferPlainTies(raw: RecognizedText, margin: number): RecognizedText { + const chars = raw.chars.map((c) => { + const twin = PLAIN_TWINS[c.char]; + const base = c.char.normalize("NFD").replace(/[̀-ͯ]/g, ""); + const accented = twin === undefined && base.length === 1 && base !== c.char; + const plain = twin ?? (accented ? base : undefined); + if (plain === undefined || !c.candidates) return c; + const top = c.candidates[0]?.score ?? c.score; + const pick = c.candidates.find( + (k) => + (k.char === plain || (accented && BAR_CHARS.has(k.char))) && + top - k.score <= margin, + ); + return pick ? { ...c, char: pick.char, score: pick.score } : c; + }); + let ci = 0; + const text = [...raw.text] + .map((ch) => (ch === " " ? ch : chars[ci++]!.char)) + .join(""); + return { ...raw, text, chars }; +} + export function parseName( gray: Mat, glyphs: GlyphSet, - options: { spaceGap?: number; binThreshold?: number } = {}, + options: { + spaceGap?: number; + binThreshold?: number; + /** re-decide near-tie homoglyphs toward the plain form (preferPlainTies) */ + plainTieMargin?: number; + } = {}, ): ParsedName { - const raw = recognizeText(gray, glyphs, { + const recognized = recognizeText(gray, glyphs, { spaceGap: options.spaceGap ?? 7, binThreshold: options.binThreshold, minCharScore: 0.35, }); + const raw = + options.plainTieMargin === undefined + ? recognized + : preferPlainTies(recognized, options.plainTieMargin); return { name: normalizeLongBars( normalizeOhs( diff --git a/app/features/scanner/core/match-builder.ts b/app/features/scanner/core/match-builder.ts index 6b9fce1ad..9bf5dcf7a 100644 --- a/app/features/scanner/core/match-builder.ts +++ b/app/features/scanner/core/match-builder.ts @@ -19,6 +19,7 @@ import { harvestCardMains, } from "./ability-harvest"; import { DEATH_EVENT_TYPE, type DeathData } from "./detectors/death/index"; +import { KILL_EVENT_TYPE, type KillData } from "./detectors/kill/index"; import { MAP_START_EVENT_TYPE, type MapStartData, @@ -55,6 +56,7 @@ import { hueDistance, hueOf, type InkRgb } from "./ink-color"; import { parseReplayTimestamp } from "./replay-time"; import type { ScannerMatch, + ScannerMatchKill, ScannerMatchObjective, ScannerMatchObjectiveSample, ScannerMatchPlayer, @@ -69,6 +71,7 @@ import { type SlotRowPermutation, weaponSlotRowPermutation, } from "./slot-row-assignment"; +import { editDistance, matchKey } from "./text"; /** The lobby header value private battles (tournament games) carry. */ const TOURNAMENT_LOBBY = "PRIVATE"; @@ -121,6 +124,17 @@ const ALIVE_RUN_MIN_SECONDS = 2; */ const SPECIAL_REGAIN_MIN_SECONDS = 10; +/** + * Kill-feed stack reads further apart than this show independent rows even + * when the names repeat: a splatted player respawns in ~8.5s, so a repeated + * name inside it is the same row still up. How long a row stays up is + * unattested beyond single frames; a row outliving this would count twice. + */ +const KILL_ROW_LIFETIME_SECONDS = 8; + +/** Name similarity (1 - edits / length) at which two stack reads show the same row. */ +const KILL_SAME_ROW_MIN_SIMILARITY = 0.7; + export interface BuiltMatch { match: ScannerMatch; /** input events the match was built from, chronological — the send-status unit for callers */ @@ -145,6 +159,7 @@ export function buildScannerMatches( let orphanObjectives: E[] = []; let orphanPlayerStatuses: E[] = []; let orphanStripWeapons: E[] = []; + let orphanKills: E[] = []; const finalize = (): void => { if (!open) return; if (open.scoreboard || open.minimaps.length > 0) { @@ -164,6 +179,7 @@ export function buildScannerMatches( orphanObjectives = []; orphanPlayerStatuses = []; orphanStripWeapons = []; + orphanKills = []; } else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) { if (!open) { open = startMatch(); @@ -179,6 +195,9 @@ export function buildScannerMatches( open.stripWeapons = orphanStripWeapons.filter( (read) => event.t - read.t <= FALLBACK_WINDOW_SECONDS, ); + open.kills = orphanKills.filter( + (read) => event.t - read.t <= FALLBACK_WINDOW_SECONDS, + ); } open.scoreboard = event; vote(open.stageVotes, (event.data as ScoreboardData).stage); @@ -187,6 +206,7 @@ export function buildScannerMatches( orphanObjectives = []; orphanPlayerStatuses = []; orphanStripWeapons = []; + orphanKills = []; } else if (event.type === MINIMAP_EVENT_TYPE) { const stage = (event.data as MinimapData).stage; if (open) { @@ -215,6 +235,8 @@ export function buildScannerMatches( (open?.playerStatuses ?? orphanPlayerStatuses).push(event); } else if (event.type === STRIP_WEAPONS_EVENT_TYPE) { (open?.stripWeapons ?? orphanStripWeapons).push(event); + } else if (event.type === KILL_EVENT_TYPE) { + (open?.kills ?? orphanKills).push(event); } } finalize(); @@ -339,6 +361,8 @@ interface OpenMatch { playerStatuses: E[]; /** sampled per-slot weapon evidence for the slot→row assignment */ stripWeapons: E[]; + /** kill-feed stack reads; become the match's `kills` */ + kills: E[]; scoreboard: E | null; /** * per-stage read counts (a MapStart's stage seeds it); the plurality winner @@ -356,6 +380,7 @@ function startMatch(): OpenMatch { objectives: [], playerStatuses: [], stripWeapons: [], + kills: [], scoreboard: null, stageVotes: new Map(), lastMinimapT: null, @@ -407,6 +432,7 @@ function toBuiltMatch( ...open.objectives, ...open.playerStatuses, ...open.stripWeapons, + ...open.kills, ...(open.scoreboard ? [open.scoreboard] : []), ].sort((a, b) => a.t - b.t); @@ -437,18 +463,24 @@ function toBuiltMatch( t: event.t, data: event.data as MinimapData, })); + const killReads = open.kills.map((event) => ({ + t: event.t, + data: event.data as KillData, + })); 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 // lookalike-overlay misreads (statuses ride along with counter reads). - // Minimap card states are mode-agnostic and feed status samples regardless + // Minimap card states and the kill feed are mode-agnostic and feed their + // samples regardless const counterModeValid = mode === null || mode === "SZ"; const progress = buildProgress( counterModeValid ? objectives : [], counterModeValid ? playerStatuses : [], counterModeValid ? stripWeapons : [], minimapReads, + killReads, board, minimapTeamColors(minimaps), ); @@ -483,6 +515,7 @@ function toBuiltMatch( playerStatuses.some((read) => read.data.cast)), objective: progress.objective, playerStatus: progress.playerStatus, + kills: progress.kills, teams: board ? teamsFromScoreboard(board, deaths, minimaps, progress.minimapEnemySide) : teamsFromMinimaps(minimaps, deaths), @@ -529,22 +562,34 @@ function floorOrNull(t: number | undefined): number | null { * column, own column assumed symmetric). The POV diamond follows neither * order, so its flags map by card name and stay as drawn when too few names * resolve. A minimap-grouped match's samples stay as drawn by construction. + * + * Kill-feed stack reads share the replay-wipe anchor (they carry the same + * clock) and reduce to one kill per row entering a stack (deriveKills); the + * feed belongs to the POV (on a cast: specced) player, so they need no side + * orientation. */ function buildProgress( objectives: readonly { t: number; data: ObjectiveData }[], playerStatuses: readonly { t: number; data: PlayerStatusData }[], stripWeapons: readonly { t: number; data: StripWeaponsData }[], minimapReads: readonly { t: number; data: MinimapData }[], + killReads: readonly { t: number; data: KillData }[], board: ScoreboardData | undefined, minimapColors: [InkRgb | null, InkRgb | null] | null, ): { objective: ScannerMatchObjective | null; playerStatus: ScannerMatchPlayerStatus | null; + kills: ScannerMatchKill[] | null; /** the `teams` side the minimap's enemy column is; null with no scoreboard */ minimapEnemySide: 0 | 1 | null; } { - const dominant = dominantAnchorOf([...objectives, ...playerStatuses]); + const dominant = dominantAnchorOf([ + ...objectives, + ...playerStatuses, + ...killReads, + ]); const live = withoutReplayReads(objectives, dominant); + const liveKills = withoutReplayReads(killReads, dominant); const statusReads = [ ...playerStatuses.map( (read): StatusRead => ({ @@ -631,10 +676,83 @@ function buildProgress( return { objective, playerStatus, + kills: liveKills.length === 0 ? null : deriveKills(liveKills), minimapEnemySide: board ? (minimapSwapped ? 0 : 1) : null, }; } +/** + * One kill per feed row entering the feed. Rows expire oldest-first and a + * single read can miss an inner row (a blurred pill ends the bottom-up scan + * early), so each read is matched newest-first as a subsequence of the rows + * still remembered (first seen within KILL_ROW_LIFETIME_SECONDS): a row + * matching a remembered one is carried, anything else is a new kill. + * Remembered rows a read fails to show stay remembered until they age out, + * so the recovered read after a truncated one re-counts nothing. + */ +function deriveKills( + reads: readonly { t: number; data: KillData }[], +): ScannerMatchKill[] { + const kills: ScannerMatchKill[] = []; + // rows believed on screen, oldest first, by the read that first saw them + let known: { name: string | null; t: number }[] = []; + for (const read of reads) { + known = known.filter((row) => read.t - row.t <= KILL_ROW_LIFETIME_SECONDS); + const names = read.data.names.toReversed(); + + // newest-first greedy subsequence match: a row matches the newest + // remembered row not yet claimed, skipping remembered rows this read + // failed to show + const matched = new Map(); + let j = known.length - 1; + for (let i = names.length - 1; i >= 0; i--) { + let k = j; + while (k >= 0 && !sameRowName(names[i]!, known[k]!.name)) k--; + if (k >= 0) { + matched.set(k, i); + j = k - 1; + } + } + + // rebuild the remembered stack in order: unmatched remembered rows stay + // (hidden or expiring), unmatched read rows are new kills + const t = Math.max(0, Math.floor(read.t)); + const next: typeof known = []; + let placed = 0; + const placeNewUpTo = (end: number): void => { + for (; placed < end; placed++) { + const name = names[placed]!; + kills.push({ t, time: read.data.time, name }); + next.push({ name, t: read.t }); + } + }; + for (const [k, row] of known.entries()) { + const i = matched.get(k); + if (i === undefined) { + next.push(row); + continue; + } + placeNewUpTo(i); + next.push(row); + placed = i + 1; + } + placeNewUpTo(names.length); + known = next; + } + // earliest first: the stack walk already emits in feed order, the sort + // pins it as the contract + return kills.toSorted((a, b) => a.t - b.t); +} + +function sameRowName(a: string | null, b: string | null): boolean { + if (a === null || b === null) return true; + const ka = matchKey(a); + const kb = matchKey(b); + const similarity = + 1 - editDistance(ka, kb) / Math.max(ka.length, kb.length, 1); + return similarity >= KILL_SAME_ROW_MIN_SIMILARITY; +} + /** The slot→row permutations of a scoreboard-closed match, per source. */ interface SlotRowPerms { /** per teams side, for strip-seated slots (the strip and card columns) */ diff --git a/app/features/scanner/core/resources.ts b/app/features/scanner/core/resources.ts index 0e0f3ecc1..73c361692 100644 --- a/app/features/scanner/core/resources.ts +++ b/app/features/scanner/core/resources.ts @@ -61,6 +61,7 @@ const ATLASES = { deathTagNameGlyphs: "death-tag-name", mapStartModeGlyphs: "map-start-mode", mapStartStageGlyphs: "map-start-stage", + killFeedGlyphs: "kill-feed", } as const; /** Memoize an expensive template/atlas build for the lazy resource getters. */ @@ -225,5 +226,8 @@ export async function assembleScoreboardResources( get mapStartStageGlyphs() { return atlas.mapStartStageGlyphs(); }, + get killFeedGlyphs() { + return atlas.killFeedGlyphs(); + }, }; } diff --git a/app/features/scanner/core/scanner-match.ts b/app/features/scanner/core/scanner-match.ts index 664ae28b8..ee3c56413 100644 --- a/app/features/scanner/core/scanner-match.ts +++ b/app/features/scanner/core/scanner-match.ts @@ -79,6 +79,16 @@ export interface ScannerMatchPlayerStatus { samples: ScannerMatchPlayerStatusSample[]; } +/** One splat by the POV (or specced) player, off the kill feed. */ +export interface ScannerMatchKill { + /** whole seconds into the video/stream the feed row was first seen at */ + t: number; + /** seconds shown on the match timer — the same key the objective samples carry */ + time: number | null; + /** the splatted player's name as read; null when unreadable */ + name: string | null; +} + export interface ScannerMatch { /** whole seconds into the video/stream the match starts at */ startsAt: number | null; @@ -108,6 +118,12 @@ export interface ScannerMatch { * objective samples; null when the icon strip was never read */ playerStatus: ScannerMatchPlayerStatus | null; + /** + * the POV player's splats (a broadcast's: the specced player's, so they + * follow camera swaps), chronological, derived from the kill feed's stack + * reads; null when the feed was never read + */ + kills: ScannerMatchKill[] | null; /** on-screen order: scoreboard rows 0-3 are teams[0] (the winners), minimap own side is teams[0] */ teams: [ScannerMatchTeam, ScannerMatchTeam]; /** scoreboard-sourced matches know it (0); minimap-only matches don't */ diff --git a/app/features/scanner/core/text.ts b/app/features/scanner/core/text.ts index e92ae16d3..8b001a29c 100644 --- a/app/features/scanner/core/text.ts +++ b/app/features/scanner/core/text.ts @@ -1,6 +1,7 @@ /** Small text utilities: edit distance and closed-set snapping for OCR output. */ -function editDistance(a: string, b: string): number { +/** Levenshtein distance between two strings. */ +export function editDistance(a: string, b: string): number { const dp = Array.from({ length: a.length + 1 }, (_, i) => { const row = new Array(b.length + 1).fill(0); row[0] = i; diff --git a/app/features/scanner/core/timeline/index.ts b/app/features/scanner/core/timeline/index.ts index 2109a6750..36e731303 100644 --- a/app/features/scanner/core/timeline/index.ts +++ b/app/features/scanner/core/timeline/index.ts @@ -3,6 +3,7 @@ * (highest confidence kept); events below a confidence floor are dropped. */ +import { KILL_EVENT_TYPE, sameKillData } from "../detectors/kill/index"; import { MINIMAP_EVENT_TYPE, sameMinimapStatusData, @@ -48,13 +49,17 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = { // own event via the content guard). Objective: reads repeat every second; the // content guard keeps every change while static stretches collapse. // PlayerStatus: a state can recur no sooner than a respawn (~9s), so the - // window stays under that. StripWeapons: sampled every ~5s, each distinct evidence + // window stays under that. StripWeapons: sampled every ~5s, each distinct evidence. + // Kill: the same stack re-read while it shows merges; a splatted player + // can't re-enter the feed before respawning (~8.5s), so the window stays + // under that and the content guard splits a growing stack. mergeWindowByType: { Death: 8, [MINIMAP_EVENT_TYPE]: 5, [OBJECTIVE_EVENT_TYPE]: 10, [PLAYER_STATUS_EVENT_TYPE]: 5, [STRIP_WEAPONS_EVENT_TYPE]: 2, + [KILL_EVENT_TYPE]: 8, }, sameEventDataByType: { [SCOREBOARD_EVENT_TYPE]: sameScoreboardMatch, @@ -63,6 +68,7 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = { [MINIMAP_EVENT_TYPE]: sameMinimapStatusData, [OBJECTIVE_EVENT_TYPE]: sameObjectiveData, [PLAYER_STATUS_EVENT_TYPE]: samePlayerStatusData, + [KILL_EVENT_TYPE]: sameKillData, }, minConfidence: 0.6, minConfidenceByType: { diff --git a/app/features/scanner/node/fixtures.ts b/app/features/scanner/node/fixtures.ts index 15bd20e97..46b9a33b3 100644 --- a/app/features/scanner/node/fixtures.ts +++ b/app/features/scanner/node/fixtures.ts @@ -72,6 +72,7 @@ interface ExpectedScoreboard { | "Objective" | "PlayerStatus" | "StripWeapons" + | "Kill" | "none"; data?: { lobby?: ScannerLobby; @@ -99,8 +100,10 @@ interface ExpectedScoreboard { abilities?: AbilityWithUnknown[][]; /** Death only: killer's splash-tag name */ name?: string; - /** Objective only: match-timer seconds ("3:35" = 215); null = unreadable */ + /** Objective + Kill: match-timer seconds ("3:35" = 215); null = unreadable */ time?: number | null; + /** Kill only: feed rows bottom (newest) first; null = row shown but name unreadable */ + names?: (string | null)[]; /** Objective only: displayed counter per team, [alpha, bravo] */ score?: [number | null, number | null]; /** Objective only: penalty pill value per team; null = no pill */ diff --git a/app/features/scanner/scanner-schemas.ts b/app/features/scanner/scanner-schemas.ts index 5a950ccb8..978a52935 100644 --- a/app/features/scanner/scanner-schemas.ts +++ b/app/features/scanner/scanner-schemas.ts @@ -13,6 +13,7 @@ import type { Ability } from "~/modules/in-game-lists/types"; import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; import type { ScannerMatch, + ScannerMatchKill, ScannerMatchObjective, ScannerMatchPlayer, ScannerMatchPlayerStatus, @@ -96,6 +97,15 @@ const scannerMatchPlayerStatusSchema = v.object({ ), }); +/** a splat every few seconds over a match runs to dozens, not hundreds */ +const MAX_KILLS = 200; + +const scannerMatchKillSchema = v.object({ + t: v.pipe(v.number(), v.integer(), v.minValue(0)), + time: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))), + name: v.nullable(detectionText), +}); + export const scannerMatchSchema = v.object({ startsAt: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))), endsAt: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))), @@ -111,6 +121,9 @@ export const scannerMatchSchema = v.object({ cast: v.boolean(), objective: v.nullable(scannerMatchObjectiveSchema), playerStatus: v.nullable(scannerMatchPlayerStatusSchema), + kills: v.nullable( + v.pipe(v.array(scannerMatchKillSchema), v.maxLength(MAX_KILLS)), + ), teams: v.tuple([scannerMatchTeamSchema, scannerMatchTeamSchema]), winner: v.nullable(teamIndexSchema), pov: v.nullable( @@ -146,6 +159,10 @@ true satisfies MutuallyAssignable< v.InferOutput, ScannerMatchPlayerStatus >; +true satisfies MutuallyAssignable< + v.InferOutput, + ScannerMatchKill +>; true satisfies MutuallyAssignable< v.InferOutput, ScannerMatch diff --git a/app/features/scanner/tests/fixtures/kill/cast-sws26-bracket-name/expected.json b/app/features/scanner/tests/fixtures/kill/cast-sws26-bracket-name/expected.json new file mode 100644 index 000000000..3f0fea371 --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/cast-sws26-bracket-name/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Kill", + "data": { + "time": 173, + "names": ["[K]yo!"] + }, + "options": { + "notes": "Frame shared with objective/splat-zones-cast-penalty-pill-under-nameplate-badge (SWS26 broadcast). The name ends in its own '!', so the row shows two; only the message's is stripped. The opening bracket ranks the fullwidth 【 a hair over '[' (0.915 vs 0.900) — the plain-tie rule prefers the ASCII bracket." + } +} diff --git a/app/features/scanner/tests/fixtures/kill/cast-sws26-bracket-name/frame.png b/app/features/scanner/tests/fixtures/kill/cast-sws26-bracket-name/frame.png new file mode 120000 index 000000000..9d6fca1ef --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/cast-sws26-bracket-name/frame.png @@ -0,0 +1 @@ +../../objective/splat-zones-cast-penalty-pill-under-nameplate-badge/frame.png \ No newline at end of file diff --git a/app/features/scanner/tests/fixtures/kill/cast-sws26-burstie-leafi/expected.json b/app/features/scanner/tests/fixtures/kill/cast-sws26-burstie-leafi/expected.json new file mode 100644 index 000000000..ee61bb763 --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/cast-sws26-burstie-leafi/expected.json @@ -0,0 +1,11 @@ +{ + "event": "Kill", + "data": { + "time": 262, + "names": ["Burstie", "leafi !!"] + }, + "options": { + "skipFields": ["names.1"], + "notes": "Frame shared with objective/splat-zones-cast-lime-control-blur-left (SWS26 broadcast): the spectator HUD draws the specced player's kill feed. Bottom row Burstie (the dot of its i reads as a grave accent, re-decided by the plain-tie rule), above it 'leafi !!' — the row shows three marks, the last being the message's own. Stream blur merges 'fi' into one segment (reads ↑), hence the skip." + } +} diff --git a/app/features/scanner/tests/fixtures/kill/cast-sws26-burstie-leafi/frame.png b/app/features/scanner/tests/fixtures/kill/cast-sws26-burstie-leafi/frame.png new file mode 120000 index 000000000..2bbe08d65 --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/cast-sws26-burstie-leafi/frame.png @@ -0,0 +1 @@ +../../objective/splat-zones-cast-lime-control-blur-left/frame.png \ No newline at end of file diff --git a/app/features/scanner/tests/fixtures/kill/cast-sws26-datkid-yoshell/expected.json b/app/features/scanner/tests/fixtures/kill/cast-sws26-datkid-yoshell/expected.json new file mode 100644 index 000000000..0d845e3f1 --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/cast-sws26-datkid-yoshell/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Kill", + "data": { + "time": 214, + "names": ["datkid", "y0shell"] + }, + "options": { + "notes": "Frame shared with player-status/cast-sws26-three-dead-one-special (SWS26 broadcast): a double. The second character of y0shell is the O/0 box BlitzMain draws for both; labeled as the digit the after-lowercase rule reads — UNCONFIRMED against the player's real name." + } +} diff --git a/app/features/scanner/tests/fixtures/kill/cast-sws26-datkid-yoshell/frame.png b/app/features/scanner/tests/fixtures/kill/cast-sws26-datkid-yoshell/frame.png new file mode 120000 index 000000000..1fec8b195 --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/cast-sws26-datkid-yoshell/frame.png @@ -0,0 +1 @@ +../../player-status/cast-sws26-three-dead-one-special/frame.png \ No newline at end of file diff --git a/app/features/scanner/tests/fixtures/kill/cast-sws26-leafi/expected.json b/app/features/scanner/tests/fixtures/kill/cast-sws26-leafi/expected.json new file mode 100644 index 000000000..6e2d92ec4 --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/cast-sws26-leafi/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Kill", + "data": { + "time": 249, + "names": ["leafi !!"] + }, + "options": { + "notes": "Frame shared with objective/splat-zones-cast-lime-control-blur-right (SWS26 broadcast), seconds after the two-row frame: only the leafi row is left. Its leading 'l' ranks an accented í first, with the bar glyphs next; the plain-tie rule hands it to the bar rule, which reads 'l' off the lowercase neighbor." + } +} diff --git a/app/features/scanner/tests/fixtures/kill/cast-sws26-leafi/frame.png b/app/features/scanner/tests/fixtures/kill/cast-sws26-leafi/frame.png new file mode 120000 index 000000000..ce1bbc9da --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/cast-sws26-leafi/frame.png @@ -0,0 +1 @@ +../../objective/splat-zones-cast-lime-control-blur-right/frame.png \ No newline at end of file diff --git a/app/features/scanner/tests/fixtures/kill/double-24k-datkid/expected.json b/app/features/scanner/tests/fixtures/kill/double-24k-datkid/expected.json new file mode 100644 index 000000000..fbd01c201 --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/double-24k-datkid/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Kill", + "data": { + "time": 186, + "names": ["datkid", "24K"] + }, + "options": { + "notes": "Native 1080p POV capture, Splat Zones (timer 3:06). Two rows stacked: datkid on the bottom (the newest splat — a trade, datkid's splash tag is on the death overlay up at the same time), 24K above. The death overlay's WIPEOUT burst and gear panel sit clear of the feed; the death gate legitimately fires on this frame too." + } +} diff --git a/app/features/scanner/tests/fixtures/kill/double-24k-datkid/frame.png b/app/features/scanner/tests/fixtures/kill/double-24k-datkid/frame.png new file mode 100644 index 000000000..a8a3a0255 Binary files /dev/null and b/app/features/scanner/tests/fixtures/kill/double-24k-datkid/frame.png differ diff --git a/app/features/scanner/tests/fixtures/kill/pov-heavy-kez-death-overlay/expected.json b/app/features/scanner/tests/fixtures/kill/pov-heavy-kez-death-overlay/expected.json new file mode 100644 index 000000000..ae06d5ef0 --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/pov-heavy-kez-death-overlay/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Kill", + "data": { + "time": 155, + "names": ["heavy kez"] + }, + "options": { + "notes": "Frame shared with death/rapid-blaster-deco-heavy-kez (720p JPEG POV capture): a trade — the death overlay is up while the feed shows the splat. A space inside the name." + } +} diff --git a/app/features/scanner/tests/fixtures/kill/pov-heavy-kez-death-overlay/frame.jpg b/app/features/scanner/tests/fixtures/kill/pov-heavy-kez-death-overlay/frame.jpg new file mode 120000 index 000000000..e3fa7a48d --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/pov-heavy-kez-death-overlay/frame.jpg @@ -0,0 +1 @@ +../../death/rapid-blaster-deco-heavy-kez/frame.jpg \ No newline at end of file diff --git a/app/features/scanner/tests/fixtures/kill/pov-umami-julufm-k-dictator/expected.json b/app/features/scanner/tests/fixtures/kill/pov-umami-julufm-k-dictator/expected.json new file mode 100644 index 000000000..9c2af1d36 --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/pov-umami-julufm-k-dictator/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Kill", + "data": { + "time": 277, + "names": ["julufm", "K DICTATOR"] + }, + "options": { + "notes": "Frame shared with player-status/pov-umami-three-splats-cast-geometry (Sendou POV, Um'ami Ruins SZ 4:37): a double with a space inside a name." + } +} diff --git a/app/features/scanner/tests/fixtures/kill/pov-umami-julufm-k-dictator/frame.png b/app/features/scanner/tests/fixtures/kill/pov-umami-julufm-k-dictator/frame.png new file mode 120000 index 000000000..a349113e5 --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/pov-umami-julufm-k-dictator/frame.png @@ -0,0 +1 @@ +../../player-status/pov-umami-three-splats-cast-geometry/frame.png \ No newline at end of file diff --git a/app/features/scanner/tests/fixtures/kill/single-nwrm/expected.json b/app/features/scanner/tests/fixtures/kill/single-nwrm/expected.json new file mode 100644 index 000000000..a85387a97 --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/single-nwrm/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Kill", + "data": { + "time": 150, + "names": ["nwrm"] + }, + "options": { + "notes": "1280x720 POV capture, Splat Zones (counter 88-18, timer 2:30). One feed row with no other overlay in the way." + } +} diff --git a/app/features/scanner/tests/fixtures/kill/single-nwrm/frame.png b/app/features/scanner/tests/fixtures/kill/single-nwrm/frame.png new file mode 100644 index 000000000..6c7759e68 Binary files /dev/null and b/app/features/scanner/tests/fixtures/kill/single-nwrm/frame.png differ diff --git a/app/features/scanner/tests/kill.test.ts b/app/features/scanner/tests/kill.test.ts new file mode 100644 index 000000000..239dfc57d --- /dev/null +++ b/app/features/scanner/tests/kill.test.ts @@ -0,0 +1,188 @@ +/** + * Golden-file tests for the KillDetector over every fixture in kill/, plus + * cross-negative sweeps both ways: the kill gate must stay quiet on every + * other detector's positives and the shared negatives, and the gates of + * screens that replace live gameplay must stay quiet on kill positives + * (death and objective ride the same live HUD and legitimately fire). + */ + +import assert from "node:assert/strict"; +import { realpathSync } from "node:fs"; +import { loadOpenCV } from "../core/cv"; +import { + createKillDetector, + type KillData, +} from "../core/detectors/kill/index"; +import { createMapStartDetector } from "../core/detectors/map-start/index"; +import { createMinimapDetector } from "../core/detectors/minimap/index"; +import { createScoreboardDetector } from "../core/detectors/scoreboard/index"; +import { createScoreboardBattleLogDetector } from "../core/detectors/scoreboard-battle-log/index"; +import { createScoreboardBattleLogReplayDetector } from "../core/detectors/scoreboard-battle-log-replay/index"; +import { createScoreboardOwnDetector } from "../core/detectors/scoreboard-own/index"; +import type { Detector } from "../core/detectors/types"; +import { + type Fixture, + isFieldSkipped, + loadFixtures, + runDetectorOnFixture, +} from "../node/fixtures"; +import { loadScoreboardResources } from "../node/resources"; +import { test } from "./node-test-compat"; + +await loadOpenCV(); +const resources = await loadScoreboardResources(); +const detector = createKillDetector(resources); +const fixtures = loadFixtures("kill"); + +test("kill fixtures exist", () => { + assert.ok(fixtures.length > 0, "no fixtures found under kill/"); +}); + +for (const fixture of fixtures) { + test(`kill/${fixture.name}`, async (t) => { + const { gate, events } = await runDetectorOnFixture( + detector, + fixture, + ); + const expectPositive = fixture.expected.event === "Kill"; + + await t.test("gate", () => { + assert.equal( + gate.pass, + expectPositive, + `gate ${gate.pass ? "fired" : "did not fire"} (score=${gate.score.toFixed(3)}), expected ${expectPositive ? "fire" : "no fire"}`, + ); + }); + + if (!expectPositive) return; + const event = events[0]; + assert.ok( + event, + gate.pass + ? "gate passed but parse emitted no event (no row read as a feed message?)" + : "no event (gate did not fire)", + ); + const expected = fixture.expected.data ?? {}; + const debug = () => JSON.stringify(event.debug); + + await t.test( + "time", + { skip: expected.time === undefined || skip(fixture, "time") }, + () => { + assert.equal( + event.data.time, + expected.time, + `time mismatch (${debug()})`, + ); + }, + ); + + await t.test( + "rows", + { skip: expected.names === undefined || skip(fixture, "names") }, + () => { + assert.equal( + event.data.names.length, + expected.names!.length, + `row count mismatch (${debug()})`, + ); + }, + ); + + for (const [row, want] of (expected.names ?? []).entries()) { + await t.test( + `names[${row}]`, + { skip: skip(fixture, `names.${row}`) }, + () => { + assert.equal( + event.data.names[row], + want, + `name mismatch (${debug()})`, + ); + }, + ); + } + }); +} + +// The feed sits on live gameplay, which the other screens replace; the kill +// gate must stay quiet on every other detector's positives — except the +// frames a kill fixture shares (symlinked): those legitimately show a feed. +const killFrames = new Set(fixtures.map((f) => realpathSync(f.framePath))); +const otherPositives: readonly [string, string][] = [ + ["scoreboard", "Scoreboard"], + ["scoreboard-battle-log-replay", "ScoreboardBattleLogReplay"], + ["scoreboard-battle-log", "ScoreboardBattleLog"], + ["scoreboard-own", "ScoreboardOwn"], + ["death", "Death"], + ["map-start", "MapStart"], + ["minimap", "Minimap"], + ["objective", "Objective"], + ["player-status", "PlayerStatus"], + ["strip-weapons", "StripWeapons"], +]; +for (const [dir, eventType] of otherPositives) { + for (const fixture of loadFixtures(dir).filter( + (f) => + f.expected.event === eventType && + !killFrames.has(realpathSync(f.framePath)), + )) { + test(`kill gate stays quiet on ${dir}/${fixture.name}`, async () => { + const { gate } = await runDetectorOnFixture(detector, fixture); + assert.equal( + gate.pass, + false, + `kill gate fired (score=${gate.score.toFixed(3)})`, + ); + }); + } +} + +// ...and on the shared negatives (tests/fixtures/negative/). +for (const fixture of loadFixtures("negative")) { + test(`kill gate stays quiet on negative/${fixture.name}`, async () => { + const { gate } = await runDetectorOnFixture(detector, fixture); + assert.equal( + gate.pass, + false, + `kill gate fired (score=${gate.score.toFixed(3)})`, + ); + }); +} + +// Death and objective overlays share the live HUD with the feed (the +// double-row fixture shows a trade with the death cam up), so only the +// screen-replacing detectors are swept the other way. +const otherDetectors: readonly [string, Detector][] = [ + ["scoreboard", createScoreboardDetector(resources) as Detector], + [ + "scoreboard-battle-log-replay", + createScoreboardBattleLogReplayDetector(resources) as Detector, + ], + [ + "scoreboard-battle-log", + createScoreboardBattleLogDetector(resources) as Detector, + ], + [ + "scoreboard-own", + createScoreboardOwnDetector(resources) as Detector, + ], + ["map-start", createMapStartDetector(resources) as Detector], + ["minimap", createMinimapDetector(resources) as Detector], +]; +for (const fixture of fixtures.filter((f) => f.expected.event === "Kill")) { + test(`other gates stay quiet on kill/${fixture.name}`, async () => { + for (const [name, other] of otherDetectors) { + const { gate } = await runDetectorOnFixture(other, fixture); + assert.equal( + gate.pass, + false, + `${name} gate fired (score=${gate.score.toFixed(3)})`, + ); + } + }); +} + +function skip(fixture: Fixture, field: string): boolean | string { + return isFieldSkipped(fixture, field) ? "skipFields" : false; +} diff --git a/app/features/scanner/tests/logic/kill-message.test.ts b/app/features/scanner/tests/logic/kill-message.test.ts new file mode 100644 index 000000000..923e72a72 --- /dev/null +++ b/app/features/scanner/tests/logic/kill-message.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { matchKillMessage } from "../../core/detectors/kill/message"; +import { test } from "../node-test-compat"; + +const cases: { + why: string; + read: string; + lang: string; + name: string | null; + minScore: number; +}[] = [ + { + why: "clean English read", + read: "Splatted nwrm!", + lang: "USen", + name: "nwrm", + minScore: 1, + }, + { + why: "a dropped constant glyph still strips the whole word", + read: "Splatte datkid!", + lang: "USen", + name: "datkid", + minScore: 0.85, + }, + { + why: "a bar read for the exclamation mark counts as one", + read: "Splatted 24Kl", + lang: "USen", + name: "24K", + minScore: 1, + }, + { + why: "a name with spaces survives", + read: "Splatted Now or Never!", + lang: "USen", + name: "Now or Never", + minScore: 1, + }, + { + why: "a name-first language keys on the tail", + read: "nwrm erledigt!", + lang: "EUde", + name: "nwrm", + minScore: 1, + }, + { + why: "case and accents fold in the constant text", + read: "éclaboussé nwrm!", + lang: "USfr", + name: "nwrm", + minScore: 1, + }, + { + why: "a missing exclamation mark strips nothing off the name", + read: "Splatted nwrm", + lang: "USen", + name: "nwrm", + minScore: 0.85, + }, + { + why: "the constant text alone leaves no name", + read: "Splatted !", + lang: "USen", + name: null, + minScore: 1, + }, +]; + +for (const { why, read, lang, name, minScore } of cases) { + test(`matchKillMessage: ${why}`, () => { + const match = matchKillMessage(read); + assert.ok(match, "no template matched"); + assert.ok( + match.template.langs.includes(lang), + `picked ${match.template.langs.join("/")}`, + ); + assert.equal(match.name, name); + assert.ok( + match.score >= minScore, + `score ${match.score.toFixed(2)} under ${minScore}`, + ); + }); +} + +test("matchKillMessage: unrelated text scores low", () => { + const match = matchKillMessage("Respawn in 01"); + assert.ok(match); + assert.ok(match.score < 0.5, `score ${match.score.toFixed(2)}`); +}); diff --git a/app/features/scanner/tests/logic/match-builder.test.ts b/app/features/scanner/tests/logic/match-builder.test.ts index 47878f539..13e79b420 100644 --- a/app/features/scanner/tests/logic/match-builder.test.ts +++ b/app/features/scanner/tests/logic/match-builder.test.ts @@ -6,6 +6,7 @@ import type { StageId, } from "~/modules/in-game-lists/types"; import type { DeathData } from "../../core/detectors/death/index"; +import type { KillData } from "../../core/detectors/kill/index"; import type { MinimapData, MinimapEnemy, @@ -1298,3 +1299,127 @@ test("pov diamond cards map to scoreboard rows by name", () => { [false, false, false, false], ]); }); + +function kill( + t: number, + names: (string | null)[], + { time = (300 - Math.round(t)) as number | null } = {}, +): DetectedEvent { + const data: KillData = { time, names }; + return { type: "Kill", t, confidence: 0.9, data }; +} + +test("kill reads become one kill per row entering the stack", () => { + const built = buildScannerMatches([ + mapStart(0), + kill(60, ["24K"]), + kill(61, ["datkid", "24K"]), + kill(65, ["datkid"]), + scoreboard(300), + ]); + assert.deepEqual(built[0]!.match.kills, [ + { t: 60, time: 240, name: "24K" }, + { t: 61, time: 239, name: "datkid" }, + ]); +}); + +test("a repeated name past the row lifetime is a fresh kill", () => { + const built = buildScannerMatches([ + mapStart(0), + kill(60, ["24K"]), + kill(75, ["24K"]), + scoreboard(300), + ]); + assert.deepEqual( + built[0]!.match.kills!.map((k) => k.t), + [60, 75], + ); +}); + +test("a wobbling read of a persisting row is not a new kill", () => { + const built = buildScannerMatches([ + mapStart(0), + kill(60, ["datkid"]), + kill(63, ["datkíd"]), + scoreboard(300), + ]); + assert.equal(built[0]!.match.kills!.length, 1); +}); + +test("an unreadable row still counts as a kill", () => { + const built = buildScannerMatches([ + mapStart(0), + kill(60, [null]), + scoreboard(300), + ]); + assert.deepEqual(built[0]!.match.kills, [{ t: 60, time: 240, name: null }]); +}); + +test("kill reads off a replay wipe are dropped", () => { + const built = buildScannerMatches([ + mapStart(0), + objective(60), + objective(70), + objective(80), + kill(65, ["24K"]), + // a broadcast re-running the 3:30 moment at t=200 + kill(200, ["datkid"], { time: 210 }), + scoreboard(300), + ]); + assert.deepEqual( + built[0]!.match.kills!.map((k) => k.name), + ["24K"], + ); +}); + +test("kills survive on a known non-SZ match", () => { + const built = buildScannerMatches([ + mapStart(0, { mode: "TC" }), + kill(60, ["24K"]), + scoreboard(300, { mode: "TC" }), + ]); + assert.equal(built[0]!.match.kills!.length, 1); +}); + +test("a match with no kill reads has null kills", () => { + const built = buildScannerMatches([mapStart(0), scoreboard(300)]); + assert.equal(built[0]!.match.kills, null); +}); + +test("a read that misses an inner row does not recount the rows it drops", () => { + const built = buildScannerMatches([ + mapStart(0), + kill(50, ["Z"]), + // Z's pill blurred: the bottom-up scan stops after the new row + kill(50.5, ["X"]), + kill(51, ["X", "Z"]), + scoreboard(300), + ]); + assert.deepEqual( + built[0]!.match.kills!.map((k) => [k.t, k.name]), + [ + [50, "Z"], + [50, "X"], + ], + ); +}); + +test("the same name twice in one stack is two kills", () => { + const built = buildScannerMatches([ + mapStart(0), + kill(60, ["A", "A"]), + kill(61, ["A", "A"]), + scoreboard(300), + ]); + assert.equal(built[0]!.match.kills!.length, 2); +}); + +test("a same-name stack seen again inside the row lifetime is the same row", () => { + const built = buildScannerMatches([ + mapStart(0), + kill(60, ["A"]), + kill(64, ["A"]), + scoreboard(300), + ]); + assert.equal(built[0]!.match.kills!.length, 1); +}); diff --git a/app/features/scanner/tests/logic/match-sets.test.ts b/app/features/scanner/tests/logic/match-sets.test.ts index 2ea04bc7c..4012b6813 100644 --- a/app/features/scanner/tests/logic/match-sets.test.ts +++ b/app/features/scanner/tests/logic/match-sets.test.ts @@ -31,6 +31,7 @@ function match( cast: false, objective: null, playerStatus: null, + kills: null, teams: [{ players: alpha.map(player) }, { players: bravo.map(player) }], winner: null, pov: null, diff --git a/app/features/scanner/tests/timeline.test.ts b/app/features/scanner/tests/timeline.test.ts index 0d7460aa2..58bc98df3 100644 --- a/app/features/scanner/tests/timeline.test.ts +++ b/app/features/scanner/tests/timeline.test.ts @@ -175,3 +175,14 @@ test("per-type merge window: repeat death frames merge, consecutive deaths do no tl.push(event(200, 0.8, "Scoreboard")); assert.equal(tl.push(event(212, 0.7, "Scoreboard")).action, "merged"); }); + +test("kill stacks merge while unchanged and split when a row enters", () => { + const tl = new TimelineBuilder(); + const kill = (t: number, names: string[]) => + tl.push({ type: "Kill", t, confidence: 0.8, data: { time: null, names } }); + assert.equal(kill(100, ["24K"]).action, "added"); + assert.equal(kill(100.5, ["24K"]).action, "merged"); + assert.equal(kill(101, ["datkid", "24K"]).action, "added"); + assert.equal(kill(104, ["datkid", "24K"]).action, "merged"); + assert.equal(tl.events.length, 2); +}); diff --git a/scripts/scanner/build-glyph-atlas.ts b/scripts/scanner/build-glyph-atlas.ts index acfe0fda5..7f3b5cd07 100644 --- a/scripts/scanner/build-glyph-atlas.ts +++ b/scripts/scanner/build-glyph-atlas.ts @@ -22,6 +22,8 @@ import { LOCALIZED_WEAPON_NAMES, } from "../../app/features/scanner/core/detectors/death/localized-messages"; import { ALL_WEAPON_ENTRIES } from "../../app/features/scanner/core/detectors/death/weapon-names"; +import { KILL_MESSAGE_TEMPLATES } from "../../app/features/scanner/core/detectors/kill/localized-messages"; +import { KILL_TEXT_HEIGHT } from "../../app/features/scanner/core/detectors/kill/rois"; import type { AtlasMeta } from "../../app/features/scanner/core/glyphs"; import { ALL_LOBBY_ENTRIES, @@ -456,3 +458,18 @@ await build("death-tag-name", 42, [ { family: "BlitzBold", pxs: [53, 54], chars: nameCharset() }, { family: "Rowdy", pxs: [52, 54], chars: nameCharset() }, ]); +// kill feed: the "Splatted !" rows bottom-center, BlitzMain with ~24px +// caps (kill/rois.ts) — the scoreboard-names charset plus every language's row text +const killFeedTexts = KILL_MESSAGE_TEMPLATES.flatMap((t) => [t.pre, t.post]); +await build("kill-feed", KILL_TEXT_HEIGHT, [ + { + family: "BlitzMain", + pxs: [28, 29], + chars: [ + ...nameCharset(), + ...NAME_GREEK, + ...nameSymbols("BlitzMain"), + ...localizedChars(killFeedTexts, "BlitzMain"), + ], + }, +]); diff --git a/scripts/scanner/build-localized-entries.ts b/scripts/scanner/build-localized-entries.ts index d0f6ab5fb..ebd87f593 100644 --- a/scripts/scanner/build-localized-entries.ts +++ b/scripts/scanner/build-localized-entries.ts @@ -13,11 +13,14 @@ * LayoutMsg/Mng_Result_00 replay-browser VICTORY / DEFEAT tags * LayoutMsg/VS_Beaten_00 (999) death-burst message; the weapon placeholder sits on * line 1 or 2 by language, so it becomes a per-language template + * LayoutMsg/VS_BeatMessage_00 (000) kill-feed row ("Splatted !"); the name placeholder + * splits it into a per-language pre/post text pair * CommonMsg/Weapon/WeaponName_* weapon names, mapped to canonical entries via USen * * Usage: pnpm scanner:build-localized-entries [path-to-splat3] * Writes app/features/scanner/core/localized-entries.ts * and app/features/scanner/core/detectors/death/localized-messages.ts + * and app/features/scanner/core/detectors/kill/localized-messages.ts */ import { readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -38,6 +41,10 @@ const OUT_MESSAGES = new URL( "../../app/features/scanner/core/detectors/death/localized-messages.ts", import.meta.url, ).pathname; +const OUT_KILL_MESSAGES = new URL( + "../../app/features/scanner/core/detectors/kill/localized-messages.ts", + import.meta.url, +).pathname; const CANONICAL_LANG = "USen"; const misc = gameMisc as Record; @@ -270,6 +277,30 @@ for (const lang of languages) { }); } +interface KillTemplate { + langs: string[]; + pre: string; + post: string; +} + +// the name placeholder is the first group; KRko carries a second (a particle +// chosen by the name's final syllable) that clean() drops along with markup +const killTemplates: KillTemplate[] = []; +for (const lang of languages) { + const raw = dumps.get(lang)!["LayoutMsg/VS_BeatMessage_00"]!["000"]!; + const line = raw.replace(PLACEHOLDER, SENTINEL).replace(/\[[^\]]*\]/g, ""); + if (!line.includes(SENTINEL) || line.includes("\n")) + throw new Error( + `${lang}: kill message is not one line with a name: ${raw}`, + ); + const [pre, post] = line + .split(SENTINEL) + .map((s) => s.replace(/[ \t]+/g, " ")) as [string, string]; + const existing = killTemplates.find((t) => t.pre === pre && t.post === post); + if (existing) existing.langs.push(lang); + else killTemplates.push({ langs: [lang], pre, post }); +} + const WEAPON_MSGS = [ "CommonMsg/Weapon/WeaponName_Main", "CommonMsg/Weapon/WeaponName_Sub", @@ -410,11 +441,37 @@ export const LOCALIZED_WEAPON_NAMES: Readonly< `, ); +writeFileSync( + OUT_KILL_MESSAGES, + `${banner( + `Per-language kill-feed row templates: the "Splatted !" row + * wraps the splatted player's name in language-specific text on either side + * (spaces kept as rendered; either side may be empty).`, + )} + +export interface KillMessageTemplate { + /** languages sharing this exact template */ + langs: readonly string[]; + /** constant text before the name (may end with a space, or be empty) */ + pre: string; + /** constant text after the name (may start with a space, or be empty) */ + post: string; +} + +export const KILL_MESSAGE_TEMPLATES: readonly KillMessageTemplate[] = ${JSON.stringify( + killTemplates, + null, + 2, + )}; +`, +); + console.info( `localized-entries: ${languages.length} languages, ` + `${languageEntries.reduce((n, l) => n + l.stages.length, 0)} stage strings`, ); console.info( `localized-messages: ${templates.length} death templates, ` + - `${Object.values(localizedWeaponNames).reduce((n, e) => n + e.length, 0)} localized weapon names`, + `${Object.values(localizedWeaponNames).reduce((n, e) => n + e.length, 0)} localized weapon names, ` + + `${killTemplates.length} kill templates`, ); diff --git a/scripts/scanner/overlay-rois.ts b/scripts/scanner/overlay-rois.ts index c324f5b1c..7cc93e43d 100644 --- a/scripts/scanner/overlay-rois.ts +++ b/scripts/scanner/overlay-rois.ts @@ -5,8 +5,10 @@ */ import { loadOpenCV, type Mat } from "../../app/features/scanner/core/cv"; import * as death from "../../app/features/scanner/core/detectors/death/rois"; +import * as kill from "../../app/features/scanner/core/detectors/kill/rois"; import * as mapStart from "../../app/features/scanner/core/detectors/map-start/rois"; import * as minimap from "../../app/features/scanner/core/detectors/minimap/rois"; +import { TIMER_DIGIT_ROI } from "../../app/features/scanner/core/detectors/objective/rois"; import * as sb from "../../app/features/scanner/core/detectors/scoreboard/rois"; import * as bl from "../../app/features/scanner/core/detectors/scoreboard-battle-log/rois"; import * as replay from "../../app/features/scanner/core/detectors/scoreboard-battle-log-replay/rois"; @@ -22,7 +24,7 @@ const [imagePath, outPath = "roi-overlay.png", detector = "scoreboard"] = process.argv.slice(2); if (!imagePath) { console.error( - "usage: vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/overlay-rois.ts [out.png] [scoreboard|scoreboard-battle-log-replay|scoreboard-battle-log|death|map-start|minimap]", + "usage: vite-node -c scripts/scanner/vite-node.config.ts scripts/scanner/overlay-rois.ts [out.png] [scoreboard|scoreboard-battle-log-replay|scoreboard-battle-log|death|kill|map-start|minimap]", ); process.exit(1); } @@ -105,6 +107,13 @@ if (detector === "scoreboard") { for (const roi of [...death.GATE_BURST_PROBES, ...death.GATE_PANEL_PROBES]) { rect(frame, roi, [255, 255, 0]); } +} else if (detector === "kill") { + for (let row = 0; row < kill.MAX_ROWS; row++) { + rect(frame, kill.textRoi(row), [0, 255, 0]); + rect(frame, kill.skullRoi(row), [0, 128, 255]); + for (const roi of kill.darkProbes(row)) rect(frame, roi, [255, 255, 0]); + } + rect(frame, TIMER_DIGIT_ROI, [255, 0, 0]); } else if (detector === "map-start") { rect(frame, mapStart.MODE_LABEL_ROI, [0, 255, 0]); rect(frame, mapStart.MODE_BLOCK_ROI, [255, 0, 0]); diff --git a/scripts/scanner/report.ts b/scripts/scanner/report.ts index f77d55e90..5c75d5b07 100644 --- a/scripts/scanner/report.ts +++ b/scripts/scanner/report.ts @@ -9,6 +9,10 @@ import { createDeathDetector, type DeathData, } from "../../app/features/scanner/core/detectors/death/index"; +import { + createKillDetector, + type KillData, +} from "../../app/features/scanner/core/detectors/kill/index"; import { createMapStartDetector, type MapStartData, @@ -523,3 +527,70 @@ for (const config of configs) { for (const m of misses) console.info(` ${m}`); } } + +// Kill fixtures: the feed's stacked rows (names, newest first) and the timer. +{ + const detector = createKillDetector(resources); + const fixtures = loadFixtures("kill"); + const tally = { + gate: { ok: 0, total: 0 } as Tally, + time: { ok: 0, total: 0 } as Tally, + rows: { ok: 0, total: 0 } as Tally, + names: { ok: 0, total: 0 } as Tally, + }; + let charEdits = 0; + let charTotal = 0; + const misses: string[] = []; + + for (const fixture of fixtures) { + const { gate, events } = await runDetectorOnFixture( + detector, + fixture, + ); + const expectPositive = fixture.expected.event === "Kill"; + tally.gate.total++; + if (gate.pass === expectPositive) tally.gate.ok++; + if (!expectPositive || !events[0]) continue; + const event = events[0]; + const expected = fixture.expected.data ?? {}; + if (expected.time !== undefined) { + tally.time.total++; + if (event.data.time === expected.time) tally.time.ok++; + else + misses.push( + `${fixture.name}: time ${event.data.time} != ${expected.time}`, + ); + } + if (expected.names !== undefined) { + tally.rows.total++; + if (event.data.names.length === expected.names.length) tally.rows.ok++; + else + misses.push( + `${fixture.name}: ${event.data.names.length} rows != ${expected.names.length}`, + ); + for (const [row, want] of expected.names.entries()) { + if (want === null) continue; + tally.names.total++; + const got = event.data.names[row] ?? ""; + const dist = editDistance(got, want); + charEdits += dist; + charTotal += want.length; + if (dist === 0) tally.names.ok++; + else misses.push(`${fixture.name}: row ${row} "${got}" != "${want}"`); + } + } + } + + console.info(`\n=== kill (${fixtures.length} fixtures) ===`); + console.info(`gate ${pct(tally.gate)}`); + console.info(`time ${pct(tally.time)}`); + console.info(`rows ${pct(tally.rows)}`); + console.info(`names ${pct(tally.names)}`); + console.info( + `name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`, + ); + if (misses.length > 0) { + console.info("misses:"); + for (const m of misses) console.info(` ${m}`); + } +}