mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-12 22:26:12 -05:00
Scanner kill feed event
This commit is contained in:
@@ -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]),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -43,6 +43,7 @@ export function scannerMatch(
|
||||
cast: false,
|
||||
objective: null,
|
||||
playerStatus: null,
|
||||
kills: null,
|
||||
teams: [
|
||||
{
|
||||
players: NAMES.slice(0, 4).map((name, i) =>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 <name>!" 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/<detector>/<case-name>/` 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/<case>/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
|
||||
|
||||
@@ -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 ? (
|
||||
<DeathCard {...shared} data={data as DeathData} />
|
||||
) : type === KILL_EVENT_TYPE ? (
|
||||
<KillCard {...shared} data={data as KillData} />
|
||||
) : type === MAP_START_EVENT_TYPE ? (
|
||||
<MapStartCard {...shared} data={data as MapStartData} />
|
||||
) : type === SCOREBOARD_OWN_EVENT_TYPE ? (
|
||||
|
||||
@@ -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<string, LucideIcon> = {
|
||||
[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,
|
||||
|
||||
@@ -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<string, string> = {
|
||||
[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",
|
||||
|
||||
45
app/features/scanner/components/KillCard.tsx
Normal file
45
app/features/scanner/components/KillCard.tsx
Normal file
@@ -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<Blob | null | undefined>;
|
||||
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 (
|
||||
<EventCardShell>
|
||||
<EventCardMeta>
|
||||
<MetaPills
|
||||
t={t}
|
||||
confidence={confidence}
|
||||
type={KILL_EVENT_TYPE}
|
||||
label="kill"
|
||||
/>
|
||||
<span>
|
||||
{data.time !== null ? `${formatClock(data.time)} · ` : null}
|
||||
splatted <b>{names.join(", ")}</b>
|
||||
</span>
|
||||
{detectedAt ? <span>{formatDetectedAt(detectedAt)}</span> : null}
|
||||
<FrameThumb
|
||||
thumbnail={thumbnail}
|
||||
getFrame={getFrame}
|
||||
onInspect={onInspect}
|
||||
fixture={{ data, type: KILL_EVENT_TYPE }}
|
||||
/>
|
||||
</EventCardMeta>
|
||||
</EventCardShell>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Record<ModeShort, number>> = { 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 (
|
||||
<div className={styles.group}>
|
||||
{card}
|
||||
{expanded ? <div className={styles.events}>{children}</div> : null}
|
||||
{expanded ? (
|
||||
<div className={styles.events}>
|
||||
{match.kills ? (
|
||||
<MatchKills kills={match.kills} mode={match.mode} />
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className={styles.kills}>
|
||||
<span className={styles.killsLabel}>
|
||||
<Crosshair size={12} aria-hidden />
|
||||
kills · {kills.length}
|
||||
</span>
|
||||
{kills.map((kill, i) => (
|
||||
<span key={i} className={styles.kill}>
|
||||
<span className={styles.killClock}>
|
||||
{kill.time !== null
|
||||
? formatClock(Math.max(0, clockStart - kill.time))
|
||||
: "?:??"}
|
||||
</span>
|
||||
{kill.name ?? "?"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{frame && event && isKill ? (
|
||||
<div className={styles.detail}>
|
||||
{(() => {
|
||||
const data = event.data as unknown as KillData;
|
||||
const rows = (event.debug?.rows ?? []) as { raw?: string }[];
|
||||
return (
|
||||
<>
|
||||
<div className={styles.detailStats}>
|
||||
<Stat label="timer" raw={event.debug?.timerRaw}>
|
||||
{formatTimer(data.time)}
|
||||
</Stat>
|
||||
{data.names.map((name, row) => (
|
||||
<Stat key={row} label={`row ${row}`} raw={rows[row]?.raw}>
|
||||
{name ?? "?"}
|
||||
</Stat>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.detailCrops}>
|
||||
{data.names.map((_, row) => (
|
||||
<LabeledCrop
|
||||
key={row}
|
||||
label={`row ${row}`}
|
||||
frame={frame}
|
||||
roi={kill.textRoi(row)}
|
||||
/>
|
||||
))}
|
||||
<LabeledCrop
|
||||
label="timer"
|
||||
frame={frame}
|
||||
roi={objective.TIMER_DIGIT_ROI}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{frame &&
|
||||
event &&
|
||||
!isDeath &&
|
||||
!isMapStart &&
|
||||
!isOwn &&
|
||||
!isMinimap &&
|
||||
!isObjective ? (
|
||||
!isObjective &&
|
||||
!isKill ? (
|
||||
<table className={styles.inspector}>
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
193
app/features/scanner/core/detectors/kill/index.ts
Normal file
193
app/features/scanner/core/detectors/kill/index.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* KillDetector: parses the kill feed — the "Splatted <name>!" 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<KillData> {
|
||||
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<KillData>[] {
|
||||
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<string, unknown>[] = [];
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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 <name>!" 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: "!",
|
||||
},
|
||||
];
|
||||
132
app/features/scanner/core/detectors/kill/message.ts
Normal file
132
app/features/scanner/core/detectors/kill/message.ts
Normal file
@@ -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 };
|
||||
}
|
||||
74
app/features/scanner/core/detectors/kill/rois.ts
Normal file
74
app/features/scanner/core/detectors/kill/rois.ts
Normal file
@@ -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 <name>!" 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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
94
app/features/scanner/core/detectors/objective/timer.ts
Normal file
94
app/features/scanner/core/detectors/objective/timer.ts
Normal file
@@ -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 };
|
||||
}
|
||||
@@ -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<unknown>,
|
||||
createMinimapDetector(resources) as Detector<unknown>,
|
||||
createObjectiveDetector(resources) as Detector<unknown>,
|
||||
createKillDetector(resources) as Detector<unknown>,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string> = { "【": "[", "】": "]" };
|
||||
|
||||
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(
|
||||
|
||||
@@ -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<E extends DetectedEvent> {
|
||||
match: ScannerMatch;
|
||||
/** input events the match was built from, chronological — the send-status unit for callers */
|
||||
@@ -145,6 +159,7 @@ export function buildScannerMatches<E extends DetectedEvent>(
|
||||
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<E extends DetectedEvent>(
|
||||
orphanObjectives = [];
|
||||
orphanPlayerStatuses = [];
|
||||
orphanStripWeapons = [];
|
||||
orphanKills = [];
|
||||
} else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) {
|
||||
if (!open) {
|
||||
open = startMatch();
|
||||
@@ -179,6 +195,9 @@ export function buildScannerMatches<E extends DetectedEvent>(
|
||||
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<E extends DetectedEvent>(
|
||||
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<E extends DetectedEvent>(
|
||||
(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<E extends DetectedEvent> {
|
||||
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<E extends DetectedEvent>(): OpenMatch<E> {
|
||||
objectives: [],
|
||||
playerStatuses: [],
|
||||
stripWeapons: [],
|
||||
kills: [],
|
||||
scoreboard: null,
|
||||
stageVotes: new Map(),
|
||||
lastMinimapT: null,
|
||||
@@ -407,6 +432,7 @@ function toBuiltMatch<E extends DetectedEvent>(
|
||||
...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<E extends DetectedEvent>(
|
||||
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<E extends DetectedEvent>(
|
||||
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<number, number>();
|
||||
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) */
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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<number>(b.length + 1).fill(0);
|
||||
row[0] = i;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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<typeof scannerMatchPlayerStatusSchema>,
|
||||
ScannerMatchPlayerStatus
|
||||
>;
|
||||
true satisfies MutuallyAssignable<
|
||||
v.InferOutput<typeof scannerMatchKillSchema>,
|
||||
ScannerMatchKill
|
||||
>;
|
||||
true satisfies MutuallyAssignable<
|
||||
v.InferOutput<typeof scannerMatchSchema>,
|
||||
ScannerMatch
|
||||
|
||||
10
app/features/scanner/tests/fixtures/kill/cast-sws26-bracket-name/expected.json
vendored
Normal file
10
app/features/scanner/tests/fixtures/kill/cast-sws26-bracket-name/expected.json
vendored
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
1
app/features/scanner/tests/fixtures/kill/cast-sws26-bracket-name/frame.png
vendored
Symbolic link
1
app/features/scanner/tests/fixtures/kill/cast-sws26-bracket-name/frame.png
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../objective/splat-zones-cast-penalty-pill-under-nameplate-badge/frame.png
|
||||
11
app/features/scanner/tests/fixtures/kill/cast-sws26-burstie-leafi/expected.json
vendored
Normal file
11
app/features/scanner/tests/fixtures/kill/cast-sws26-burstie-leafi/expected.json
vendored
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
1
app/features/scanner/tests/fixtures/kill/cast-sws26-burstie-leafi/frame.png
vendored
Symbolic link
1
app/features/scanner/tests/fixtures/kill/cast-sws26-burstie-leafi/frame.png
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../objective/splat-zones-cast-lime-control-blur-left/frame.png
|
||||
10
app/features/scanner/tests/fixtures/kill/cast-sws26-datkid-yoshell/expected.json
vendored
Normal file
10
app/features/scanner/tests/fixtures/kill/cast-sws26-datkid-yoshell/expected.json
vendored
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
1
app/features/scanner/tests/fixtures/kill/cast-sws26-datkid-yoshell/frame.png
vendored
Symbolic link
1
app/features/scanner/tests/fixtures/kill/cast-sws26-datkid-yoshell/frame.png
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../player-status/cast-sws26-three-dead-one-special/frame.png
|
||||
10
app/features/scanner/tests/fixtures/kill/cast-sws26-leafi/expected.json
vendored
Normal file
10
app/features/scanner/tests/fixtures/kill/cast-sws26-leafi/expected.json
vendored
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
1
app/features/scanner/tests/fixtures/kill/cast-sws26-leafi/frame.png
vendored
Symbolic link
1
app/features/scanner/tests/fixtures/kill/cast-sws26-leafi/frame.png
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../objective/splat-zones-cast-lime-control-blur-right/frame.png
|
||||
10
app/features/scanner/tests/fixtures/kill/double-24k-datkid/expected.json
vendored
Normal file
10
app/features/scanner/tests/fixtures/kill/double-24k-datkid/expected.json
vendored
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/kill/double-24k-datkid/frame.png
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/kill/double-24k-datkid/frame.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
10
app/features/scanner/tests/fixtures/kill/pov-heavy-kez-death-overlay/expected.json
vendored
Normal file
10
app/features/scanner/tests/fixtures/kill/pov-heavy-kez-death-overlay/expected.json
vendored
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
1
app/features/scanner/tests/fixtures/kill/pov-heavy-kez-death-overlay/frame.jpg
vendored
Symbolic link
1
app/features/scanner/tests/fixtures/kill/pov-heavy-kez-death-overlay/frame.jpg
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../death/rapid-blaster-deco-heavy-kez/frame.jpg
|
||||
10
app/features/scanner/tests/fixtures/kill/pov-umami-julufm-k-dictator/expected.json
vendored
Normal file
10
app/features/scanner/tests/fixtures/kill/pov-umami-julufm-k-dictator/expected.json
vendored
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
1
app/features/scanner/tests/fixtures/kill/pov-umami-julufm-k-dictator/frame.png
vendored
Symbolic link
1
app/features/scanner/tests/fixtures/kill/pov-umami-julufm-k-dictator/frame.png
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../player-status/pov-umami-three-splats-cast-geometry/frame.png
|
||||
10
app/features/scanner/tests/fixtures/kill/single-nwrm/expected.json
vendored
Normal file
10
app/features/scanner/tests/fixtures/kill/single-nwrm/expected.json
vendored
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
BIN
app/features/scanner/tests/fixtures/kill/single-nwrm/frame.png
vendored
Normal file
BIN
app/features/scanner/tests/fixtures/kill/single-nwrm/frame.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
188
app/features/scanner/tests/kill.test.ts
Normal file
188
app/features/scanner/tests/kill.test.ts
Normal file
@@ -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<KillData>(
|
||||
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<unknown>][] = [
|
||||
["scoreboard", createScoreboardDetector(resources) as Detector<unknown>],
|
||||
[
|
||||
"scoreboard-battle-log-replay",
|
||||
createScoreboardBattleLogReplayDetector(resources) as Detector<unknown>,
|
||||
],
|
||||
[
|
||||
"scoreboard-battle-log",
|
||||
createScoreboardBattleLogDetector(resources) as Detector<unknown>,
|
||||
],
|
||||
[
|
||||
"scoreboard-own",
|
||||
createScoreboardOwnDetector(resources) as Detector<unknown>,
|
||||
],
|
||||
["map-start", createMapStartDetector(resources) as Detector<unknown>],
|
||||
["minimap", createMinimapDetector(resources) as Detector<unknown>],
|
||||
];
|
||||
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;
|
||||
}
|
||||
90
app/features/scanner/tests/logic/kill-message.test.ts
Normal file
90
app/features/scanner/tests/logic/kill-message.test.ts
Normal file
@@ -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)}`);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 <name>!" 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"),
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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 <name>!"); 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<string, string>;
|
||||
@@ -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 <name>!" 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`,
|
||||
);
|
||||
|
||||
@@ -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 <image> [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 <image> [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]);
|
||||
|
||||
@@ -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<KillData>(
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user