From a8f8dd9a43d58b0d2693706fba4eceaa37998d1a Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:22:06 +0300 Subject: [PATCH] CV housekeeping: biome, knip, docs - biome sweep over app/features/cv + scripts/cv (tabs, a11y button types, CLI scripts get the repo's noConsole ignore convention); fixture corpus excluded from biome - knip: vendored scripts/dicts ignored (knip's vite plugin started analyzing it once scripts/cv/vite-node.config.ts appeared), dead exports pruned - app/features/cv/README.md carries the detector/atlas/fixture/gotcha documentation; AGENTS.md points to it and pins the fixture ground-truth and id-types rules - assets/fonts/ gitignored (proprietary atlas-builder fonts) --- AGENTS.md | 8 + app/features/cv/README.md | 150 + app/features/cv/capture/sampler.ts | 101 +- app/features/cv/capture/vod-frames.ts | 149 +- app/features/cv/components/AbilityGrid.tsx | 93 +- app/features/cv/components/App.tsx | 121 +- app/features/cv/components/DeathCard.tsx | 104 +- app/features/cv/components/EventCard.tsx | 183 +- app/features/cv/components/LivePage.tsx | 535 +- app/features/cv/components/MapStartCard.tsx | 76 +- app/features/cv/components/MinimapCard.tsx | 196 +- app/features/cv/components/ScoreboardCard.tsx | 220 +- .../cv/components/ScoreboardOwnCard.tsx | 116 +- app/features/cv/components/ScreenshotPage.tsx | 995 +- app/features/cv/components/VodPage.tsx | 940 +- app/features/cv/components/events-csv.ts | 338 +- app/features/cv/components/fixture-export.ts | 350 +- app/features/cv/components/format.ts | 8 +- app/features/cv/components/labels.ts | 12 +- .../cv/components/screenshot-handoff.ts | 8 +- app/features/cv/components/sendou-ingest.ts | 216 +- app/features/cv/components/sendou-upload.ts | 51 +- app/features/cv/components/styles.css | 779 +- app/features/cv/components/thumbnail.ts | 14 +- app/features/cv/core/ability-harvest.ts | 88 +- app/features/cv/core/batches.ts | 120 +- app/features/cv/core/canonical.ts | 8 +- app/features/cv/core/cv.ts | 100 +- .../cv/core/detectors/death/abilities.ts | 88 +- app/features/cv/core/detectors/death/index.ts | 1276 +- .../detectors/death/localized-messages.ts | 19811 ++++++++-------- app/features/cv/core/detectors/death/rois.ts | 27 +- .../cv/core/detectors/death/weapon-names.ts | 433 +- .../cv/core/detectors/map-start/index.ts | 556 +- .../cv/core/detectors/map-start/rois.ts | 8 +- .../cv/core/detectors/minimap/abilities.ts | 20 +- .../cv/core/detectors/minimap/index.ts | 1021 +- .../cv/core/detectors/minimap/rois.ts | 202 +- .../cv/core/detectors/minimap/stage.ts | 277 +- app/features/cv/core/detectors/registry.ts | 34 +- .../detectors/scoreboard-own/abilities.ts | 38 +- .../cv/core/detectors/scoreboard-own/index.ts | 380 +- .../cv/core/detectors/scoreboard-own/rois.ts | 26 +- .../core/detectors/scoreboard-replay/code.ts | 144 +- .../detectors/scoreboard-replay/header.ts | 167 +- .../core/detectors/scoreboard-replay/index.ts | 630 +- .../core/detectors/scoreboard-replay/rois.ts | 30 +- .../cv/core/detectors/scoreboard/digits.ts | 73 +- .../cv/core/detectors/scoreboard/header.ts | 181 +- .../cv/core/detectors/scoreboard/index.ts | 472 +- .../cv/core/detectors/scoreboard/kits.ts | 24 +- .../cv/core/detectors/scoreboard/names.ts | 279 +- .../cv/core/detectors/scoreboard/pov.ts | 68 +- .../cv/core/detectors/scoreboard/rois.ts | 28 +- .../cv/core/detectors/scoreboard/row.ts | 213 +- .../cv/core/detectors/scoreboard/specials.ts | 310 +- .../cv/core/detectors/scoreboard/weapons.ts | 451 +- app/features/cv/core/detectors/suppressor.ts | 103 +- app/features/cv/core/detectors/types.ts | 40 +- app/features/cv/core/glyphs.ts | 919 +- app/features/cv/core/image.ts | 182 +- app/features/cv/core/localized-entries.ts | 4294 ++-- app/features/cv/core/localized.ts | 105 +- app/features/cv/core/replay-time.ts | 156 +- app/features/cv/core/resources.ts | 318 +- app/features/cv/core/text.ts | 170 +- app/features/cv/core/timeline/index.ts | 129 +- .../cv/core/timeline/same-scoreboard.ts | 84 +- app/features/cv/core/vod-matches.ts | 286 +- app/features/cv/cv-schemas.ts | 16 +- app/features/cv/cv-types.ts | 25 +- app/features/cv/node/assets-dir.ts | 4 +- app/features/cv/node/fixtures.ts | 233 +- app/features/cv/node/image-io.ts | 24 +- app/features/cv/node/resources.ts | 57 +- app/features/cv/routes/cv.tsx | 4 +- app/features/cv/store/db.ts | 134 +- app/features/cv/store/events.ts | 224 +- app/features/cv/store/vods.ts | 183 +- app/features/cv/tests/ability-harvest.test.ts | 166 +- app/features/cv/tests/batches.test.ts | 328 +- app/features/cv/tests/map-start.test.ts | 167 +- app/features/cv/tests/minimap.test.ts | 295 +- app/features/cv/tests/node-test-compat.ts | 15 +- app/features/cv/tests/opencv-smoke.test.ts | 40 +- app/features/cv/tests/replay-time.test.ts | 172 +- app/features/cv/tests/scoreboard-own.test.ts | 181 +- app/features/cv/tests/scoreboard.test.ts | 252 +- app/features/cv/tests/suites/death.ts | 242 +- .../cv/tests/suites/scoreboard-replay.ts | 394 +- app/features/cv/tests/suppressor.test.ts | 70 +- app/features/cv/tests/timeline.test.ts | 227 +- app/features/cv/tests/vod-matches.test.ts | 290 +- app/features/cv/worker/analyzer.worker.ts | 118 +- app/features/cv/worker/client.ts | 156 +- app/features/cv/worker/pool.ts | 110 +- app/features/cv/worker/protocol.ts | 64 +- app/features/cv/worker/resources.ts | 108 +- biome.json | 1 + db-test.sqlite3 | Bin 1474560 -> 1482752 bytes knip.ts | 2 + scripts/cv/bootstrap-atlas-from-fixture.ts | 1123 +- scripts/cv/build-glyph-atlas.ts | 495 +- scripts/cv/build-localized-entries.ts | 383 +- scripts/cv/build-planner-signatures.ts | 107 +- scripts/cv/dump-crops.ts | 117 +- scripts/cv/otf-cmap.ts | 88 +- scripts/cv/overlay-rois.ts | 168 +- scripts/cv/report.ts | 816 +- scripts/cv/run-fixtures.ts | 54 +- 110 files changed, 24817 insertions(+), 22968 deletions(-) create mode 100644 app/features/cv/README.md diff --git a/AGENTS.md b/AGENTS.md index 98a1fd5a2..8f5d010e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,3 +90,11 @@ - use the template `/github/pull_request_template.md` - do not mention claude or claude code in the description + +## CV feature (app/features/cv) + +- computer-vision match-event detection; full docs in `app/features/cv/README.md` — read it before touching detector/recognition code +- OpenCV ROI-view gotcha: `.data`/`.clone()` are broken on ROI views — always `view.copyTo(freshMat)` before pixel access +- fixture workflow: every live misread becomes a fixture under `app/features/cv/tests/fixtures/`; ground-truth labels are hand-corrected by the maintainer and definitive over any matcher output +- test with `pnpm test:cv`; accuracy report with `pnpm cv:report`; atlas regen commands and the assets-repo/CDN flow are in the README +- events, snap tables, and fixtures speak sendou ids (`ModeShort`/`StageId`/weapon ids/`Ability`) — never reintroduce English game-name literals outside the generated localized snap tables diff --git a/app/features/cv/README.md b/app/features/cv/README.md new file mode 100644 index 000000000..8249ceed5 --- /dev/null +++ b/app/features/cv/README.md @@ -0,0 +1,150 @@ +# CV — Splatoon match-event detection + +Browser app (route `/cv`, dev-only until promoted) that watches OBS Virtual +Camera footage, VoD files, or screenshots, detects Splatoon 3 UI screens with +OpenCV.js in a Web Worker, parses them into events speaking sendou.ink ids +(`ModeShort`/`StageId`/weapon ids/`Ability`), records them to IndexedDB, and +feeds them to `/ingest` and the `/vods/new` prefill. Imported wholesale from +the emberz repo (kept read-only for archaeology); see `MIGRATION.md` there. + +## Commands + +```sh +pnpm test:cv # golden-file suite over tests/fixtures/ (Vitest, Node) +pnpm cv:report # accuracy table + name character error rate across fixtures +pnpm cv:fixtures [name-substring] # run detectors over matching fixtures, verbose +pnpm cv:bootstrap-atlas # harvest labeled fixture crops into the glyph atlases +pnpm cv:build-glyph-atlas # add the font-rendered charset (fonts required, see below) +pnpm cv:build-localized-entries # regen localized closed sets from ../splat3 +pnpm cv:build-planner-signatures # regen the minimap stage-ID atlas from the assets repo +``` + +The cv scripts run through `vite-node -c scripts/cv/vite-node.config.ts` — the +root vite config pre-bundles `@techstark/opencv-js` for the browser worker, and +vite-node must not consume that prebundle (it crashes on `__dirname` in Node). +The package itself is pnpm-patched (`patches/`): its CJS export is the +emscripten ready-promise, and a thenable `module.exports` breaks vite-node's +CJS interop; the patch wraps it as `{ cvReadyPromise }`, unwrapped in +`core/cv.ts`. + +## Architecture + +``` +MediaStream → capture/sampler (rVFC @2fps, ImageBitmap out) [Live tab] +video file → capture/vod-frames (WebCodecs decode, seek fallback) [VoD tab] + → worker/analyzer.worker (OpenCV.js WASM lives here) + core/detectors/* gate(mat) → parse(mat) → events + → core/timeline (dedupe within 30s window, keep highest confidence) + → store/events (IndexedDB) + components/ live feed +``` + +- `core/` is pure (mats in, events out) and must stay runnable in three + contexts: the worker, the `/cv` Screenshot tab, and Node tests. Keep + DOM/browser APIs out of it; Node-only helpers (image IO, fixture loading) + live in `node/`. Importing pure data/type modules from `~/modules` and + `~/features/build-analyzer/data` is fine — zod and the app config graph are + not (schemas live in `cv-schemas.ts`, consumed by `features/ingest`; + detectors only `import type` the shapes). +- The route (`routes/cv.tsx`) is SSR-guarded: everything below it assumes a + browser (worker, IndexedDB, WebCodecs, getUserMedia), so the client tree + loads via `React.lazy` after `useHydrated`. Nothing from + `core/worker/capture/store` may be imported at route-module top level. +- Six detectors: `scoreboard` (results screen), `scoreboard-replay` + (replay-browser detail screen), `scoreboard-own` (personal results screen), + `death` (respawn overlay), `map-start` (match-intro splash), `minimap` + (in-match map overlay, plus the casted 8-player spectator map screen as a + gated variant). Detector-specific parsing details are documented in each + detector's module header; accuracy-critical matching internals (background + masking, ink-coverage penalty, wide-segment splitting) in the module headers + of `core/glyphs.ts` and `core/detectors/scoreboard/weapons.ts` — read those + before touching recognition code. +- Ingestion is language-agnostic: OCR output snaps against every game language + at once (`core/localized-entries.ts`, generated) and events always carry the + sendou id. English display names for the UI come from `components/labels.ts`. +- ROI coordinates are in each detector's `rois.ts`, in canonical 1920×1080 + space; every input frame is normalized to that size first. +- New event types implement `Detector` (`core/detectors/types.ts`): a cheap + `gate(mat)` at sample rate plus `parse(mat, t)` when the gate fires. + Register in `core/detectors/registry.ts`. Event data shapes are pinned to + `cv-schemas.ts` by compile-time asserts — extend both together. + +## Assets (CDN) and fonts + +Glyph atlases, weapon/ability/special/sub template sets, and the planner +signature atlas live in the **sendou-ink/assets repo** under `assets/cv/v1/**` +(local checkout expected at `../assets`, override with `CV_ASSETS_DIR`; +version segment bumps on breaking atlas-format changes so old deploys keep +reading old files): + +- Browser/worker: fetched from `${Config.staticAssetsUrl}/cv/v1` (the base URL + rides the worker init message). For local dev against fresh regens, serve + the checkout with CORS — `npx serve /Users/kalle/Developer/assets/assets -l 9100 --cors` + — and set `VITE_STATIC_ASSETS_URL=http://localhost:9100` in `.env`. + The DO Space needs CORS (GET, sendou.ink + localhost origins) because the + worker `fetch()`es JSON/PNG cross-origin — plain `` consumers don't. +- Node (tests/scripts): read from the checkout directly, never the CDN. +- Shipping a regen = commit + push the assets repo (CDN mirrors on push to + main); plain regens overwrite in place, breaking format changes bump `v1`. + +Fonts are proprietary and gitignored: `BlitzMain.otf`, `BlitzBold.otf`, +`FOT-RowdyStd-EB.otf`, `FOT-KurokaneStd-EB.otf` in `assets/fonts/` (repo +root; from the splatoon3-fonts repo). Atlas builders fail loudly without +them. Names and row digits use BlitzMain; team totals use BlitzBold; the +replay code line and VICTORY/DEFEAT tags use FOT-RowdyStd-EB; the JP death +message mixes condensed Kurokane and Rowdy (`death-weapon-ja`). Regeneration +order: `cv:bootstrap-atlas` (fixture crops win via tie-break) → +`cv:build-glyph-atlas`; localized sets via `cv:build-localized-entries` +(expects a splat3 checkout at `../splat3`) then the atlas rebuild; planner +atlas via `cv:build-planner-signatures` (reads the assets repo's +`assets/planner-maps/`, MINI variant). + +## Fixtures are the workflow + +A test case is a directory `tests/fixtures///` with +`frame.png|jpg` (raw capture, never re-encoded) and `expected.json` (partial +expectations, sendou ids; informational `stageLabel`/`weaponLabel` fields help +the human corrector — tests compare only ids). 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 +plus a prefilled `expected.json`. **Fixture ground-truth labels are +hand-corrected by the user (the Splatoon domain authority) — treat them as +definitive over any matcher output.** Fixtures are committed as plain blobs +(deliberately no LFS for now) — keep additions deliberate; the retreat plan +is LFS for future fixtures or an external corpus (fixture IO is isolated in +`node/fixtures.ts`). + +## Gotchas + +- @techstark/opencv-js 5.0.0-release.1: `.data` and `.clone()` are broken on + ROI views — always `view.copyTo(freshMat)` before pixel access. Views are + fine as inputs to cv calls. +- `matchTemplate` silently skips templates larger than the ROI — a weapon ROI + only competes against icon templates that fit its height. The minimap's + template sets are built with `cropToArt` (alpha-bbox-trimmed) or dark-art + weapons would be unmatchable there. +- BlitzMain renders `I`/`l`/`|`/`1` as identical bars; `parseName` resolves + every bar by context, not pixels. Same for `ー`/`-`. +- Scoped vs unscoped charger icons are pixel-indistinguishable; near ties + resolve to unscoped (`SCOPED_TWINS`) and are flagged `twinAmbiguous`. + Near-tied weapons whose kits differ resolve via the row's special icon + (`specials.ts`) or the minimap sub tile (`subResolved`); kits derive + directly from `~/features/build-analyzer/data/weapon-params.ts`. +- Header parsing OCRs the whole lobby/mode/stage line and snaps it to the + localized combos from `core/localized.ts` — new stages get a `StageId` in + `~/modules/in-game-lists` and the localized sets + atlases regenerate; + nothing is added to the OCR itself. +- Death events merge within an 8s timeline window, minimap 5s (see + `mergeWindowByType`); Scoreboard/ScoreboardReplay events carry a content + guard (`core/timeline/same-scoreboard.ts`). +- The minimap stage ID (`core/detectors/minimap/stage.ts`) matches an + ink-invariant structural signature against the planner atlas; **stage** + separates cleanly, **mode** does not (the atlas keeps five renders per + stage only to match whichever mode is on screen). The casted spectator map + screen is a gated minimap *variant* with its own card grid. The minimap + cannot read the mode: VoD matches without a MapStart/Scoreboard default to + SZ, flagged `modeAssumed`. +- A static screen would re-run expensive parses every sampled frame: + `ParseSuppressor` skips `parse()` once a gate keeps passing without + confidence improving. The Screenshot tab inits its worker with + `suppressSteadyFrames: false` — one-shot re-analyses must always parse. diff --git a/app/features/cv/capture/sampler.ts b/app/features/cv/capture/sampler.ts index 8875f4cde..ade8a0dff 100644 --- a/app/features/cv/capture/sampler.ts +++ b/app/features/cv/capture/sampler.ts @@ -4,20 +4,22 @@ * (bitmap, t) — a WHIP/MediaMTX transport can replace this file later. */ -export async function openVirtualCamera(deviceId?: string): Promise { - return navigator.mediaDevices.getUserMedia({ - video: { - deviceId: deviceId ? { exact: deviceId } : undefined, - width: { ideal: 1920 }, - height: { ideal: 1080 }, - }, - audio: false, - }); +export async function openVirtualCamera( + deviceId?: string, +): Promise { + return navigator.mediaDevices.getUserMedia({ + video: { + deviceId: deviceId ? { exact: deviceId } : undefined, + width: { ideal: 1920 }, + height: { ideal: 1080 }, + }, + audio: false, + }); } export async function listVideoInputs(): Promise { - const devices = await navigator.mediaDevices.enumerateDevices(); - return devices.filter((d) => d.kind === "videoinput"); + const devices = await navigator.mediaDevices.enumerateDevices(); + return devices.filter((d) => d.kind === "videoinput"); } export type FrameHandler = (bitmap: ImageBitmap, t: number) => void; @@ -27,45 +29,46 @@ export type FrameHandler = (bitmap: ImageBitmap, t: number) => void; * requestVideoFrameCallback. Returns a stop function. */ export function startSampler( - video: HTMLVideoElement, - fps: number, - onFrame: FrameHandler, + video: HTMLVideoElement, + fps: number, + onFrame: FrameHandler, ): () => void { - const intervalMs = 1000 / fps; - let lastSample = -Infinity; - let lastMediaTime = -Infinity; - let stopped = false; - let handle = 0; + const intervalMs = 1000 / fps; + let lastSample = Number.NEGATIVE_INFINITY; + let lastMediaTime = Number.NEGATIVE_INFINITY; + let stopped = false; + let handle = 0; - const tick = async (now: number, metadata: VideoFrameCallbackMetadata) => { - if (stopped) return; - // Throttle on the callback clock, not metadata.mediaTime: Firefox never - // advances mediaTime for MediaStream-backed videos, which would freeze - // sampling after the first frame. - if (now - lastSample >= intervalMs) { - lastSample = now; - try { - const bitmap = await createImageBitmap(video); - if (stopped) { - bitmap.close(); - return; - } - // Same Firefox quirk for the frame timestamp: fall back to the clock - // when mediaTime isn't advancing so timestamps stay monotonic (the - // timeline's merge windows compare them). - const t = metadata.mediaTime > lastMediaTime ? metadata.mediaTime : now / 1000; - lastMediaTime = Math.max(lastMediaTime, metadata.mediaTime); - onFrame(bitmap, t); - } catch { - // video not ready / tab hidden — skip this frame - } - } - if (!stopped) handle = video.requestVideoFrameCallback(tick); - }; - handle = video.requestVideoFrameCallback(tick); + const tick = async (now: number, metadata: VideoFrameCallbackMetadata) => { + if (stopped) return; + // Throttle on the callback clock, not metadata.mediaTime: Firefox never + // advances mediaTime for MediaStream-backed videos, which would freeze + // sampling after the first frame. + if (now - lastSample >= intervalMs) { + lastSample = now; + try { + const bitmap = await createImageBitmap(video); + if (stopped) { + bitmap.close(); + return; + } + // Same Firefox quirk for the frame timestamp: fall back to the clock + // when mediaTime isn't advancing so timestamps stay monotonic (the + // timeline's merge windows compare them). + const t = + metadata.mediaTime > lastMediaTime ? metadata.mediaTime : now / 1000; + lastMediaTime = Math.max(lastMediaTime, metadata.mediaTime); + onFrame(bitmap, t); + } catch { + // video not ready / tab hidden — skip this frame + } + } + if (!stopped) handle = video.requestVideoFrameCallback(tick); + }; + handle = video.requestVideoFrameCallback(tick); - return () => { - stopped = true; - video.cancelVideoFrameCallback(handle); - }; + return () => { + stopped = true; + video.cancelVideoFrameCallback(handle); + }; } diff --git a/app/features/cv/capture/vod-frames.ts b/app/features/cv/capture/vod-frames.ts index cbe6e13d9..c8e7c5051 100644 --- a/app/features/cv/capture/vod-frames.ts +++ b/app/features/cv/capture/vod-frames.ts @@ -17,17 +17,17 @@ import { ALL_FORMATS, BlobSource, Input, VideoSampleSink } from "mediabunny"; const SEEK_STEP_SECONDS = 0.25; interface VodFrame { - /** the consumer owns the frame and must close() it */ - frame: ImageBitmap | VideoFrame; - /** seconds into the video */ - t: number; + /** the consumer owns the frame and must close() it */ + frame: ImageBitmap | VideoFrame; + /** seconds into the video */ + t: number; } export interface VodScan { - method: "webcodecs" | "seek"; - duration: number; - frames: AsyncGenerator; - dispose(): void; + method: "webcodecs" | "seek"; + duration: number; + frames: AsyncGenerator; + dispose(): void; } /** @@ -36,75 +36,90 @@ export interface VodScan { * the file loaded (metadata not required yet); it is only driven by the * seek fallback. */ -export async function openVodScan(file: File, video: HTMLVideoElement): Promise { - const input = new Input({ formats: ALL_FORMATS, source: new BlobSource(file) }); - try { - const track = await input.getPrimaryVideoTrack(); - if (track && (await track.canDecode())) { - const duration = await input.computeDuration([track]); - return { - method: "webcodecs", - duration, - frames: webCodecsFrames(input, new VideoSampleSink(track)), - dispose: () => input.dispose(), - }; - } - input.dispose(); - } catch { - input.dispose(); - } +export async function openVodScan( + file: File, + video: HTMLVideoElement, +): Promise { + const input = new Input({ + formats: ALL_FORMATS, + source: new BlobSource(file), + }); + try { + const track = await input.getPrimaryVideoTrack(); + if (track && (await track.canDecode())) { + const duration = await input.computeDuration([track]); + return { + method: "webcodecs", + duration, + frames: webCodecsFrames(input, new VideoSampleSink(track)), + dispose: () => input.dispose(), + }; + } + input.dispose(); + } catch { + input.dispose(); + } - await loadMetadata(video); - if (!Number.isFinite(video.duration)) { - throw new Error("video has no known duration — cannot scan by seeking"); - } - return { - method: "seek", - duration: video.duration, - frames: seekFrames(video), - dispose: () => {}, - }; + await loadMetadata(video); + if (!Number.isFinite(video.duration)) { + throw new Error("video has no known duration — cannot scan by seeking"); + } + return { + method: "seek", + duration: video.duration, + frames: seekFrames(video), + dispose: () => {}, + }; } -async function* webCodecsFrames(input: Input, sink: VideoSampleSink): AsyncGenerator { - try { - for await (const sample of sink.samples()) { - if (!sample) continue; - const t = sample.timestamp; - const frame = sample.toVideoFrame(); - sample.close(); - yield { frame, t }; - } - } finally { - input.dispose(); - } +async function* webCodecsFrames( + input: Input, + sink: VideoSampleSink, +): AsyncGenerator { + try { + for await (const sample of sink.samples()) { + if (!sample) continue; + const t = sample.timestamp; + const frame = sample.toVideoFrame(); + sample.close(); + yield { frame, t }; + } + } finally { + input.dispose(); + } } async function* seekFrames(video: HTMLVideoElement): AsyncGenerator { - for (let t = 0; t < video.duration; t += SEEK_STEP_SECONDS) { - await seekTo(video, t); - yield { frame: await createImageBitmap(video), t }; - } + for (let t = 0; t < video.duration; t += SEEK_STEP_SECONDS) { + await seekTo(video, t); + yield { frame: await createImageBitmap(video), t }; + } } function loadMetadata(video: HTMLVideoElement): Promise { - return new Promise((resolve, reject) => { - if (video.readyState >= HTMLMediaElement.HAVE_METADATA) return resolve(); - video.addEventListener("loadedmetadata", () => resolve(), { once: true }); - video.addEventListener( - "error", - () => reject(new Error(video.error?.message || "cannot decode this file as video")), - { once: true }, - ); - }); + return new Promise((resolve, reject) => { + if (video.readyState >= HTMLMediaElement.HAVE_METADATA) return resolve(); + video.addEventListener("loadedmetadata", () => resolve(), { once: true }); + video.addEventListener( + "error", + () => + reject( + new Error(video.error?.message || "cannot decode this file as video"), + ), + { once: true }, + ); + }); } function seekTo(video: HTMLVideoElement, t: number): Promise { - return new Promise((resolve) => { - if (video.currentTime === t && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) { - return resolve(); - } - video.addEventListener("seeked", () => resolve(), { once: true }); - video.currentTime = t; - }); + return new Promise((resolve) => { + if ( + video.currentTime === t && + video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA + ) { + return resolve(); + } + video.addEventListener("seeked", () => resolve(), { once: true }); + video.currentTime = t; + }); } diff --git a/app/features/cv/components/AbilityGrid.tsx b/app/features/cv/components/AbilityGrid.tsx index 0a99d25b0..3f18047e1 100644 --- a/app/features/cv/components/AbilityGrid.tsx +++ b/app/features/cv/components/AbilityGrid.tsx @@ -2,34 +2,38 @@ * Gear-ability grid (3 rows: head/clothes/shoes, each [main, sub, sub, sub]) * shared by the death card and the scoreboard player popover. */ -import { CV_ASSETS_URL } from "~/utils/urls"; + import { useState } from "react"; +import { CV_ASSETS_URL } from "~/utils/urls"; const ROW_LABELS = ["head", "clothes", "shoes"] as const; export function AbilityGrid({ abilities }: { abilities: string[][] }) { - return ( - - - {abilities.map((row, i) => ( - - - {row.map((id, j) => ( - - ))} - - ))} - -
{ROW_LABELS[i]} - {id} -
- ); + return ( + + + {abilities.map((row, i) => ( + + + {row.map((id, j) => ( + + ))} + + ))} + +
{ROW_LABELS[i]} + {id} +
+ ); } /** @@ -37,23 +41,28 @@ export function AbilityGrid({ abilities }: { abilities: string[][] }) { * the head-main ability icon. Closes when the pointer leaves it. */ export function AbilityPopover({ abilities }: { abilities: string[][] }) { - const [open, setOpen] = useState(false); - const trigger = abilities[0]?.[0]; - if (!trigger) return null; - return ( - setOpen(false)}> - - {open && ( -
- -
- )} -
- ); + const [open, setOpen] = useState(false); + const trigger = abilities[0]?.[0]; + if (!trigger) return null; + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: hover-dismiss only; the button inside is the interactive element + setOpen(false)}> + + {open && ( +
+ +
+ )} +
+ ); } diff --git a/app/features/cv/components/App.tsx b/app/features/cv/components/App.tsx index 0fc8cf8cb..943fe27d6 100644 --- a/app/features/cv/components/App.tsx +++ b/app/features/cv/components/App.tsx @@ -7,71 +7,74 @@ import { VodPage } from "./VodPage"; import "./styles.css"; function useHashRoute(): string { - const [hash, setHash] = useState(() => window.location.hash); - useEffect(() => { - const onChange = () => setHash(window.location.hash); - window.addEventListener("hashchange", onChange); - return () => window.removeEventListener("hashchange", onChange); - }, []); - return hash; + const [hash, setHash] = useState(() => window.location.hash); + useEffect(() => { + const onChange = () => setHash(window.location.hash); + window.addEventListener("hashchange", onChange); + return () => window.removeEventListener("hashchange", onChange); + }, []); + return hash; } /** The sendou.ink login, from the root loader's user context. */ function SendouLogin({ user }: { user: SendouUser | null }) { - return ( -
- {user ? ( - - {user.username} - - ) : ( - Log in - )} -
- ); + return ( +
+ {user ? ( + + {user.username} + + ) : ( + Log in + )} +
+ ); } export function App() { - const route = useHashRoute(); - const rootUser = useUser(); - const sendouUser: SendouUser | null = rootUser - ? { id: rootUser.id, username: rootUser.username } - : null; + const route = useHashRoute(); + const rootUser = useUser(); + const sendouUser: SendouUser | null = rootUser + ? { id: rootUser.id, username: rootUser.username } + : null; - const page = route.startsWith("#/screenshot") ? ( - - ) : route.startsWith("#/vod") ? ( - - ) : ( - - ); - const active = route.startsWith("#/screenshot") - ? "screenshot" - : route.startsWith("#/vod") - ? "vod" - : "live"; - return ( -
-
-
-
-

CV

-
- - -
- {page} -
-
- ); + const page = route.startsWith("#/screenshot") ? ( + + ) : route.startsWith("#/vod") ? ( + + ) : ( + + ); + const active = route.startsWith("#/screenshot") + ? "screenshot" + : route.startsWith("#/vod") + ? "vod" + : "live"; + return ( +
+
+
+
+

CV

+
+ + +
+ {page} +
+
+ ); } diff --git a/app/features/cv/components/DeathCard.tsx b/app/features/cv/components/DeathCard.tsx index 27b6ca3eb..66fa6fb3f 100644 --- a/app/features/cv/components/DeathCard.tsx +++ b/app/features/cv/components/DeathCard.tsx @@ -6,51 +6,63 @@ import { formatTime } from "./format"; import { weaponLabel } from "./labels"; export function DeathCard(props: { - t: number; - confidence: number; - data: DeathData; - thumbnail?: string; - detectedAt?: number; - /** lazy loader for the exact analyzed frame — enables fixture export */ - getFrame?: () => Promise; - onInspect?: () => void; + t: number; + confidence: number; + data: DeathData; + thumbnail?: string; + detectedAt?: number; + /** lazy loader for the exact analyzed frame — enables fixture export */ + getFrame?: () => Promise; + onInspect?: () => void; }) { - const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } = props; - const weaponName = weaponLabel(data.weaponType, data.weaponId); - return ( -
-
- t={formatTime(t)} - death - - splatted by {weaponName ?? "?"} - {data.name && <> ({data.name})} - - confidence {(confidence * 100).toFixed(0)}% - {detectedAt && {new Date(detectedAt).toLocaleTimeString()}} - {onInspect && } - {getFrame && ( - - )} - {thumbnail && analyzed frame} -
-
-
- {data.weaponId !== null && data.weaponType === "MAIN" && ( - {weaponName - )} - -
-
-
- ); + const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } = + props; + const weaponName = weaponLabel(data.weaponType, data.weaponId); + return ( +
+
+ t={formatTime(t)} + death + + splatted by {weaponName ?? "?"} + {data.name && <> ({data.name})} + + confidence {(confidence * 100).toFixed(0)}% + {detectedAt && {new Date(detectedAt).toLocaleTimeString()}} + {onInspect && ( + + )} + {getFrame && ( + + )} + {thumbnail && ( + analyzed frame + )} +
+
+
+ {data.weaponId !== null && data.weaponType === "MAIN" && ( + {weaponName + )} + +
+
+
+ ); } diff --git a/app/features/cv/components/EventCard.tsx b/app/features/cv/components/EventCard.tsx index 68efd2786..799baa661 100644 --- a/app/features/cv/components/EventCard.tsx +++ b/app/features/cv/components/EventCard.tsx @@ -7,13 +7,22 @@ */ import type { PlayerAbilityMap } from "../core/ability-harvest"; -import { DEATH_EVENT_TYPE, type DeathData } from "../core/detectors/death/index"; -import { MAP_START_EVENT_TYPE, type MapStartData } from "../core/detectors/map-start/index"; -import { MINIMAP_EVENT_TYPE, type MinimapData } from "../core/detectors/minimap/index"; +import { + DEATH_EVENT_TYPE, + type DeathData, +} from "../core/detectors/death/index"; +import { + MAP_START_EVENT_TYPE, + type MapStartData, +} from "../core/detectors/map-start/index"; +import { + MINIMAP_EVENT_TYPE, + type MinimapData, +} from "../core/detectors/minimap/index"; import type { ScoreboardData } from "../core/detectors/scoreboard/index"; import { - SCOREBOARD_OWN_EVENT_TYPE, - type ScoreboardOwnData, + SCOREBOARD_OWN_EVENT_TYPE, + type ScoreboardOwnData, } from "../core/detectors/scoreboard-own/index"; import type { SendStatus } from "../store/events"; import { DeathCard } from "./DeathCard"; @@ -27,92 +36,102 @@ import { setScreenshotFrame } from "./screenshot-handoff"; export type GetFrame = () => Promise; export function EventCard(props: { - type: string; - t: number; - confidence: number; - data: FixtureData; - thumbnail?: string; - detectedAt?: number; - /** lazy loader for the exact analyzed frame; enables Inspect + fixture export */ - getFrame?: GetFrame; - /** Scoreboard only: abilities harvested from the match's death events */ - abilities?: PlayerAbilityMap; - /** sendou.ink /ingest status of this event; absent = never attempted */ - send?: SendStatus; - /** when set, shows a Send/Retry button that sends this event's match batch */ - onSend?: () => void; + type: string; + t: number; + confidence: number; + data: FixtureData; + thumbnail?: string; + detectedAt?: number; + /** lazy loader for the exact analyzed frame; enables Inspect + fixture export */ + getFrame?: GetFrame; + /** Scoreboard only: abilities harvested from the match's death events */ + abilities?: PlayerAbilityMap; + /** sendou.ink /ingest status of this event; absent = never attempted */ + send?: SendStatus; + /** when set, shows a Send/Retry button that sends this event's match batch */ + onSend?: () => void; }) { - const { type, t, confidence, data, thumbnail, detectedAt, getFrame } = props; - const onInspect = getFrame - ? () => - void getFrame().then((frame) => { - if (!frame) return; - setScreenshotFrame(frame); - window.location.hash = "#/screenshot"; - }) - : undefined; - const shared = { t, confidence, thumbnail, detectedAt, getFrame, onInspect }; + const { type, t, confidence, data, thumbnail, detectedAt, getFrame } = props; + const onInspect = getFrame + ? () => + void getFrame().then((frame) => { + if (!frame) return; + setScreenshotFrame(frame); + window.location.hash = "#/screenshot"; + }) + : undefined; + const shared = { t, confidence, thumbnail, detectedAt, getFrame, onInspect }; - const card = renderCard(type, data, shared, props.abilities); - if (!props.send && !props.onSend) return card; - return ( -
- {card} - -
- ); + const card = renderCard(type, data, shared, props.abilities); + if (!props.send && !props.onSend) return card; + return ( +
+ {card} + +
+ ); } const SEND_LABELS: Record = { - queued: "queued", - sending: "sending…", - sent: "sent", - failed: "failed", + queued: "queued", + sending: "sending…", + sent: "sent", + failed: "failed", }; -function SendStrip({ send, onSend }: { send?: SendStatus; onSend?: () => void }) { - const state = send?.state; - return ( -
- - sendou.ink: {state ? SEND_LABELS[state] : "not sent"} - {state === "sent" && send && ` ${new Date(send.at).toLocaleTimeString()}`} - - {send?.error && {send.error}} - {onSend && state !== "sent" && state !== "sending" && ( - - )} -
- ); +function SendStrip({ + send, + onSend, +}: { + send?: SendStatus; + onSend?: () => void; +}) { + const state = send?.state; + return ( +
+ + sendou.ink: {state ? SEND_LABELS[state] : "not sent"} + {state === "sent" && + send && + ` ${new Date(send.at).toLocaleTimeString()}`} + + {send?.error && {send.error}} + {onSend && state !== "sent" && state !== "sending" && ( + + )} +
+ ); } function renderCard( - type: string, - data: FixtureData, - shared: { - t: number; - confidence: number; - thumbnail?: string; - detectedAt?: number; - getFrame?: GetFrame; - onInspect?: () => void; - }, - abilities?: PlayerAbilityMap, + type: string, + data: FixtureData, + shared: { + t: number; + confidence: number; + thumbnail?: string; + detectedAt?: number; + getFrame?: GetFrame; + onInspect?: () => void; + }, + abilities?: PlayerAbilityMap, ) { - return type === DEATH_EVENT_TYPE ? ( - - ) : type === MAP_START_EVENT_TYPE ? ( - - ) : type === SCOREBOARD_OWN_EVENT_TYPE ? ( - - ) : type === MINIMAP_EVENT_TYPE ? ( - - ) : ( - - ); + return type === DEATH_EVENT_TYPE ? ( + + ) : type === MAP_START_EVENT_TYPE ? ( + + ) : type === SCOREBOARD_OWN_EVENT_TYPE ? ( + + ) : type === MINIMAP_EVENT_TYPE ? ( + + ) : ( + + ); } diff --git a/app/features/cv/components/LivePage.tsx b/app/features/cv/components/LivePage.tsx index b866b6fdb..824b58c30 100644 --- a/app/features/cv/components/LivePage.tsx +++ b/app/features/cv/components/LivePage.tsx @@ -1,274 +1,329 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { listVideoInputs, openVirtualCamera, startSampler } from "../capture/sampler"; +import { + listVideoInputs, + openVirtualCamera, + startSampler, +} from "../capture/sampler"; import { DEATH_EVENT_TYPE } from "../core/detectors/death/index"; import { MAP_START_EVENT_TYPE } from "../core/detectors/map-start/index"; import { SCOREBOARD_EVENT_TYPES } from "../core/detectors/registry"; import type { DetectedEvent, GateResult } from "../core/detectors/types"; import { TimelineBuilder } from "../core/timeline/index"; import { - clearEvents, - deleteEvent, - listEvents, - loadEventFrame, - type StoredEvent, - saveEvent, - updateEventsSend, + clearEvents, + deleteEvent, + listEvents, + loadEventFrame, + type StoredEvent, + saveEvent, + updateEventsSend, } from "../store/events"; import { AnalyzerClient } from "../worker/client"; import { EventCard } from "./EventCard"; import { downloadEventsCsv } from "./events-csv"; import { type FixtureData, saveFixture } from "./fixture-export"; import { SENDOU_UPLOAD_ENABLED } from "./flags"; -import { batchContaining, type SendouUser, sendBatches, unsentBatches } from "./sendou-ingest"; +import { + batchContaining, + type SendouUser, + sendBatches, + unsentBatches, +} from "./sendou-ingest"; import { thumbnailFromBlob } from "./thumbnail"; const SAMPLE_FPS = 2; /** Event types the /ingest batches carry — the only ones with a send status. */ -const INGESTABLE_TYPES = [MAP_START_EVENT_TYPE, DEATH_EVENT_TYPE, ...SCOREBOARD_EVENT_TYPES]; +const INGESTABLE_TYPES = [ + MAP_START_EVENT_TYPE, + DEATH_EVENT_TYPE, + ...SCOREBOARD_EVENT_TYPES, +]; type Status = "idle" | "loading" | "watching" | "detected" | "error"; export function LivePage({ - sendouUser, + sendouUser, }: { - /** sendou.ink login shown in the header; undefined = probing, null = not logged in */ - sendouUser: SendouUser | null | undefined; + /** sendou.ink login shown in the header; undefined = probing, null = not logged in */ + sendouUser: SendouUser | null | undefined; }) { - const videoRef = useRef(null); - const clientRef = useRef(null); - const timelineRef = useRef(new TimelineBuilder()); - const storedIdsRef = useRef(new WeakMap()); - const latestParseRef = useRef<{ type: string; data: FixtureData } | null>(null); - const gatesRef = useRef(new Map()); - const stopRef = useRef<(() => void) | null>(null); + const videoRef = useRef(null); + const clientRef = useRef(null); + const timelineRef = useRef(new TimelineBuilder()); + const storedIdsRef = useRef(new WeakMap()); + const latestParseRef = useRef<{ type: string; data: FixtureData } | null>( + null, + ); + const gatesRef = useRef(new Map()); + const stopRef = useRef<(() => void) | null>(null); - const [devices, setDevices] = useState([]); - const [deviceId, setDeviceId] = useState(""); - const [status, setStatus] = useState("idle"); - const [gateScore, setGateScore] = useState(null); - const [error, setError] = useState(null); - const [feed, setFeed] = useState([]); - const [running, setRunning] = useState(false); - const [sendouError, setSendouError] = useState(null); - const [liveSend, setLiveSend] = useState(false); - const liveSendRef = useRef(false); - const sendingRef = useRef(false); + const [devices, setDevices] = useState([]); + const [deviceId, setDeviceId] = useState(""); + const [status, setStatus] = useState("idle"); + const [gateScore, setGateScore] = useState(null); + const [error, setError] = useState(null); + const [feed, setFeed] = useState([]); + const [running, setRunning] = useState(false); + const [sendouError, setSendouError] = useState(null); + const [liveSend, setLiveSend] = useState(false); + const liveSendRef = useRef(false); + const sendingRef = useRef(false); - const refreshFeed = useCallback(() => { - void listEvents().then((events) => - setFeed(events.sort((a, b) => b.detectedAt - a.detectedAt || (b.id ?? 0) - (a.id ?? 0))), - ); - }, []); + const refreshFeed = useCallback(() => { + void listEvents().then((events) => + setFeed( + events.sort( + (a, b) => b.detectedAt - a.detectedAt || (b.id ?? 0) - (a.id ?? 0), + ), + ), + ); + }, []); - useEffect(() => { - refreshFeed(); - return () => { - stopRef.current?.(); - clientRef.current?.dispose(); - }; - }, [refreshFeed]); + useEffect(() => { + refreshFeed(); + return () => { + stopRef.current?.(); + clientRef.current?.dispose(); + }; + }, [refreshFeed]); - /** Sends the batches `include` selects; serialized so sends never overlap. */ - const send = useCallback( - async (include: (batch: StoredEvent[]) => boolean, { manual = false } = {}) => { - if (sendingRef.current) return; - sendingRef.current = true; - if (manual) setSendouError(null); - try { - const events = await listEvents(); - const { sentBatches, failedBatches } = await sendBatches({ - events, - include, - onStatus: refreshFeed, - }); - if (manual && sentBatches + failedBatches === 0) { - setSendouError("nothing to send — no complete match (ending in a scoreboard) selected"); - } - } finally { - sendingRef.current = false; - refreshFeed(); - } - }, - [refreshFeed], - ); + /** Sends the batches `include` selects; serialized so sends never overlap. */ + const send = useCallback( + async ( + include: (batch: StoredEvent[]) => boolean, + { manual = false } = {}, + ) => { + if (sendingRef.current) return; + sendingRef.current = true; + if (manual) setSendouError(null); + try { + const events = await listEvents(); + const { sentBatches, failedBatches } = await sendBatches({ + events, + include, + onStatus: refreshFeed, + }); + if (manual && sentBatches + failedBatches === 0) { + setSendouError( + "nothing to send — no complete match (ending in a scoreboard) selected", + ); + } + } finally { + sendingRef.current = false; + refreshFeed(); + } + }, + [refreshFeed], + ); - const start = useCallback(async () => { - setError(null); - setStatus("loading"); - try { - const video = videoRef.current!; - const stream = await openVirtualCamera(deviceId || undefined); - video.srcObject = stream; - await video.play(); - setDevices(await listVideoInputs()); + const start = useCallback(async () => { + setError(null); + setStatus("loading"); + try { + const video = videoRef.current!; + const stream = await openVirtualCamera(deviceId || undefined); + video.srcObject = stream; + await video.play(); + setDevices(await listVideoInputs()); - clientRef.current ??= new AnalyzerClient( - (result) => { - // one result arrives per detector per frame; status reflects - // whether any of them fired - gatesRef.current.set(result.detector, result.gate); - const gates = [...gatesRef.current.values()]; - setGateScore(Math.max(...gates.map((g) => g.score))); - if (!result.gate.pass) { - if (!gates.some((g) => g.pass)) setStatus("watching"); - return; - } - setStatus("detected"); - for (const event of result.events as DetectedEvent[]) { - latestParseRef.current = { type: event.type, data: event.data }; - const action = timelineRef.current.push(event); - if (action.action === "added" || action.action === "replaced") { - const stale = - action.action === "replaced" - ? storedIdsRef.current.get(action.replaced) - : undefined; - void (async () => { - if (stale !== undefined) await deleteEvent(stale); - const thumbnail = result.frame ? await thumbnailFromBlob(result.frame) : undefined; - const id = await saveEvent(event, thumbnail, result.frame); - storedIdsRef.current.set(event, id); - if (liveSendRef.current && INGESTABLE_TYPES.includes(event.type)) { - if (SCOREBOARD_EVENT_TYPES.includes(event.type)) { - // a scoreboard closes its match batch — send it - refreshFeed(); - await send((batch) => batchContaining(id)(batch) && unsentBatches(batch)); - } else { - await updateEventsSend([id], { state: "queued", at: Date.now() }); - } - } - refreshFeed(); - })(); - } - } - }, - (message) => { - setError(message); - setStatus("error"); - }, - ); - await clientRef.current.whenReady(); + clientRef.current ??= new AnalyzerClient( + (result) => { + // one result arrives per detector per frame; status reflects + // whether any of them fired + gatesRef.current.set(result.detector, result.gate); + const gates = [...gatesRef.current.values()]; + setGateScore(Math.max(...gates.map((g) => g.score))); + if (!result.gate.pass) { + if (!gates.some((g) => g.pass)) setStatus("watching"); + return; + } + setStatus("detected"); + for (const event of result.events as DetectedEvent[]) { + latestParseRef.current = { type: event.type, data: event.data }; + const action = timelineRef.current.push(event); + if (action.action === "added" || action.action === "replaced") { + const stale = + action.action === "replaced" + ? storedIdsRef.current.get(action.replaced) + : undefined; + void (async () => { + if (stale !== undefined) await deleteEvent(stale); + const thumbnail = result.frame + ? await thumbnailFromBlob(result.frame) + : undefined; + const id = await saveEvent(event, thumbnail, result.frame); + storedIdsRef.current.set(event, id); + if ( + liveSendRef.current && + INGESTABLE_TYPES.includes(event.type) + ) { + if (SCOREBOARD_EVENT_TYPES.includes(event.type)) { + // a scoreboard closes its match batch — send it + refreshFeed(); + await send( + (batch) => + batchContaining(id)(batch) && unsentBatches(batch), + ); + } else { + await updateEventsSend([id], { + state: "queued", + at: Date.now(), + }); + } + } + refreshFeed(); + })(); + } + } + }, + (message) => { + setError(message); + setStatus("error"); + }, + ); + await clientRef.current.whenReady(); - stopRef.current = startSampler(video, SAMPLE_FPS, (bitmap, t) => { - clientRef.current?.analyze(bitmap, t); - }); - setStatus("watching"); - setRunning(true); - } catch (e) { - setError(String(e)); - setStatus("error"); - } - }, [deviceId, refreshFeed, send]); + stopRef.current = startSampler(video, SAMPLE_FPS, (bitmap, t) => { + clientRef.current?.analyze(bitmap, t); + }); + setStatus("watching"); + setRunning(true); + } catch (e) { + setError(String(e)); + setStatus("error"); + } + }, [deviceId, refreshFeed, send]); - const stop = useCallback(() => { - stopRef.current?.(); - stopRef.current = null; - const video = videoRef.current; - if (video?.srcObject instanceof MediaStream) { - for (const track of video.srcObject.getTracks()) track.stop(); - video.srcObject = null; - } - setRunning(false); - setStatus("idle"); - }, []); + const stop = useCallback(() => { + stopRef.current?.(); + stopRef.current = null; + const video = videoRef.current; + if (video?.srcObject instanceof MediaStream) { + for (const track of video.srcObject.getTracks()) track.stop(); + video.srcObject = null; + } + setRunning(false); + setStatus("idle"); + }, []); - return ( -
-
- {!running ? ( - - ) : ( - - )} - - - {status} - {gateScore !== null && ` · gate ${gateScore.toFixed(2)}`} - - - - -
- {SENDOU_UPLOAD_ENABLED && ( -
- - - {liveSend && sending matches live} -
- )} - {error &&

{error}

} - {sendouError &&

{sendouError}

} -
-
-
- ); + return ( +
+
+ {!running ? ( + + ) : ( + + )} + + + {status} + {gateScore !== null && ` · gate ${gateScore.toFixed(2)}`} + + + + +
+ {SENDOU_UPLOAD_ENABLED && ( +
+ + + {liveSend && ( + sending matches live + )} +
+ )} + {error &&

{error}

} + {sendouError &&

{sendouError}

} +
+
+
+ ); } diff --git a/app/features/cv/components/MapStartCard.tsx b/app/features/cv/components/MapStartCard.tsx index ff2b3c915..227d7fe50 100644 --- a/app/features/cv/components/MapStartCard.tsx +++ b/app/features/cv/components/MapStartCard.tsx @@ -4,38 +4,48 @@ import { formatTime } from "./format"; import { modeLabel, stageLabel } from "./labels"; export function MapStartCard(props: { - t: number; - confidence: number; - data: MapStartData; - thumbnail?: string; - detectedAt?: number; - /** lazy loader for the exact analyzed frame — enables fixture export */ - getFrame?: () => Promise; - onInspect?: () => void; + t: number; + confidence: number; + data: MapStartData; + thumbnail?: string; + detectedAt?: number; + /** lazy loader for the exact analyzed frame — enables fixture export */ + getFrame?: () => Promise; + onInspect?: () => void; }) { - const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } = props; - return ( -
-
- t={formatTime(t)} - map start - - {modeLabel(data.mode) ?? "?"} · {stageLabel(data.stage) ?? "?"} - - confidence {(confidence * 100).toFixed(0)}% - {detectedAt && {new Date(detectedAt).toLocaleTimeString()}} - {onInspect && } - {getFrame && ( - - )} - {thumbnail && analyzed frame} -
-
- ); + const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } = + props; + return ( +
+
+ t={formatTime(t)} + map start + + {modeLabel(data.mode) ?? "?"} · {stageLabel(data.stage) ?? "?"} + + confidence {(confidence * 100).toFixed(0)}% + {detectedAt && {new Date(detectedAt).toLocaleTimeString()}} + {onInspect && ( + + )} + {getFrame && ( + + )} + {thumbnail && ( + analyzed frame + )} +
+
+ ); } diff --git a/app/features/cv/components/MinimapCard.tsx b/app/features/cv/components/MinimapCard.tsx index ebd0a0698..0382c7c22 100644 --- a/app/features/cv/components/MinimapCard.tsx +++ b/app/features/cv/components/MinimapCard.tsx @@ -1,105 +1,121 @@ import { CV_ASSETS_URL } from "~/utils/urls"; import type { - MinimapData, - MinimapEnemy, - MinimapTeammate, + MinimapData, + MinimapEnemy, + MinimapTeammate, } from "../core/detectors/minimap/index"; import { saveFixtureFromEvent } from "./fixture-export"; import { formatTime } from "./format"; import { mainWeaponLabel, stageLabel } from "./labels"; function AbilityRow({ abilities }: { abilities: (string | null)[] }) { - return ( - <> - {abilities.map((id, i) => - id ? ( - {id} - ) : ( - - ? - - ), - )} - - ); + return ( + <> + {abilities.map((id, i) => + id ? ( + {id} + ) : ( + + ? + + ), + )} + + ); } -function PlayerRow({ label, player }: { label: string; player: MinimapTeammate | MinimapEnemy }) { - return ( - - {label} - {player.name ?? ""} - - {player.weaponId !== null ? ( - {mainWeaponLabel(player.weaponId) - ) : ( - "?" - )} - - - - - - ); +function PlayerRow({ + label, + player, +}: { + label: string; + player: MinimapTeammate | MinimapEnemy; +}) { + return ( + + {label} + {player.name ?? ""} + + {player.weaponId !== null ? ( + {mainWeaponLabel(player.weaponId) + ) : ( + "?" + )} + + + + + + ); } export function MinimapCard(props: { - t: number; - confidence: number; - data: MinimapData; - thumbnail?: string; - detectedAt?: number; - /** lazy loader for the exact analyzed frame — enables fixture export */ - getFrame?: () => Promise; - onInspect?: () => void; + t: number; + confidence: number; + data: MinimapData; + thumbnail?: string; + detectedAt?: number; + /** lazy loader for the exact analyzed frame — enables fixture export */ + getFrame?: () => Promise; + onInspect?: () => void; }) { - const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } = props; - return ( -
-
- t={formatTime(t)} - minimap - {data.stage !== null && {stageLabel(data.stage)}} - confidence {(confidence * 100).toFixed(0)}% - {detectedAt && {new Date(detectedAt).toLocaleTimeString()}} - {onInspect && } - {getFrame && ( - - )} - {thumbnail && analyzed frame} -
-
-
- - - {data.teammates.map((p) => ( - - ))} - {data.enemies.map((p, i) => ( - - ))} - -
-
-
-
- ); + const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } = + props; + return ( +
+
+ t={formatTime(t)} + minimap + {data.stage !== null && {stageLabel(data.stage)}} + confidence {(confidence * 100).toFixed(0)}% + {detectedAt && {new Date(detectedAt).toLocaleTimeString()}} + {onInspect && ( + + )} + {getFrame && ( + + )} + {thumbnail && ( + analyzed frame + )} +
+
+
+ + + {data.teammates.map((p) => ( + + ))} + {data.enemies.map((p, i) => ( + + ))} + +
+
+
+
+ ); } diff --git a/app/features/cv/components/ScoreboardCard.tsx b/app/features/cv/components/ScoreboardCard.tsx index 65fd101a1..fe54712e3 100644 --- a/app/features/cv/components/ScoreboardCard.tsx +++ b/app/features/cv/components/ScoreboardCard.tsx @@ -1,6 +1,9 @@ import { CV_ASSETS_URL } from "~/utils/urls"; import type { PlayerAbilityMap } from "../core/ability-harvest"; -import type { ScoreboardData, ScoreboardPlayer } from "../core/detectors/scoreboard/index"; +import type { + ScoreboardData, + ScoreboardPlayer, +} from "../core/detectors/scoreboard/index"; import { SCOREBOARD_REPLAY_EVENT_TYPE } from "../core/detectors/scoreboard-replay/index"; import { AbilityPopover } from "./AbilityGrid"; import { type CardData, saveFixtureFromEvent } from "./fixture-export"; @@ -8,110 +11,131 @@ import { formatTime } from "./format"; import { lobbyLabel, modeLabel, stageLabel } from "./labels"; function PlayerRows({ - players, - offset, - abilities, + players, + offset, + abilities, }: { - players: ScoreboardPlayer[]; - /** index of the first row within the full 8-player list */ - offset: number; - abilities?: PlayerAbilityMap; + players: ScoreboardPlayer[]; + /** index of the first row within the full 8-player list */ + offset: number; + abilities?: PlayerAbilityMap; }) { - return ( - - - {players.map((p, i) => ( - - - - - - - ))} - -
- - {p.weaponId !== null && ( - {String(p.weaponId)} - )} - {abilities?.has(offset + i) && ( - - )} - - {p.name || "?"}{p.paint ?? "?"}p - {p.ka ?? "?"}/{p.d ?? "?"}/{p.s ?? "?"} -
- ); + return ( + + + {players.map((p, i) => ( + + + + + + + ))} + +
+ + {p.weaponId !== null && ( + {String(p.weaponId)} + )} + {abilities?.has(offset + i) && ( + + )} + + {p.name || "?"}{p.paint ?? "?"}p + {p.ka ?? "?"}/{p.d ?? "?"}/{p.s ?? "?"} +
+ ); } function teamHeading(label: string, data: CardData, side: 0 | 1): string { - const score = `${label} — ${data.scores[side] ?? "?"}p`; - const match = data.matchScores?.[side]; - return match != null ? `${score} · score ${match}` : score; + const score = `${label} — ${data.scores[side] ?? "?"}p`; + const match = data.matchScores?.[side]; + return match != null ? `${score} · score ${match}` : score; } export function ScoreboardCard(props: { - t: number; - confidence: number; - data: ScoreboardData; - /** DetectedEvent type; replay cards add timestamp/code details */ - eventType?: string; - thumbnail?: string; - detectedAt?: number; - /** lazy loader for the exact analyzed frame — enables fixture export */ - getFrame?: () => Promise; - /** when set, shows an Inspect button (open this match in the screenshot page) */ - onInspect?: () => void; - /** abilities harvested from this match's death events, by player index */ - abilities?: PlayerAbilityMap; + t: number; + confidence: number; + data: ScoreboardData; + /** DetectedEvent type; replay cards add timestamp/code details */ + eventType?: string; + thumbnail?: string; + detectedAt?: number; + /** lazy loader for the exact analyzed frame — enables fixture export */ + getFrame?: () => Promise; + /** when set, shows an Inspect button (open this match in the screenshot page) */ + onInspect?: () => void; + /** abilities harvested from this match's death events, by player index */ + abilities?: PlayerAbilityMap; }) { - const { t, confidence, thumbnail, detectedAt, getFrame, onInspect } = props; - const eventType = props.eventType ?? "Scoreboard"; - const data = props.data as CardData; - const isReplay = eventType === SCOREBOARD_REPLAY_EVENT_TYPE; - return ( -
-
- t={formatTime(t)} - {isReplay && replay} - {(data.mode !== null || data.stage !== null) && ( - - {[lobbyLabel(data.lobby), modeLabel(data.mode), stageLabel(data.stage)] - .filter(Boolean) - .join(" · ")} - - )} - {data.timestamp && {data.timestamp}} - {data.replayCode && {data.replayCode}} - confidence {(confidence * 100).toFixed(0)}% - {detectedAt && {new Date(detectedAt).toLocaleTimeString()}} - {onInspect && } - {getFrame && ( - - )} - {thumbnail && analyzed frame} -
-
-
-

{teamHeading("Victory", data, 0)}

- -
-
-

{teamHeading("Defeat", data, 1)}

- -
-
-
- ); + const { t, confidence, thumbnail, detectedAt, getFrame, onInspect } = props; + const eventType = props.eventType ?? "Scoreboard"; + const data = props.data as CardData; + const isReplay = eventType === SCOREBOARD_REPLAY_EVENT_TYPE; + return ( +
+
+ t={formatTime(t)} + {isReplay && replay} + {(data.mode !== null || data.stage !== null) && ( + + {[ + lobbyLabel(data.lobby), + modeLabel(data.mode), + stageLabel(data.stage), + ] + .filter(Boolean) + .join(" · ")} + + )} + {data.timestamp && {data.timestamp}} + {data.replayCode && {data.replayCode}} + confidence {(confidence * 100).toFixed(0)}% + {detectedAt && {new Date(detectedAt).toLocaleTimeString()}} + {onInspect && ( + + )} + {getFrame && ( + + )} + {thumbnail && ( + analyzed frame + )} +
+
+
+

{teamHeading("Victory", data, 0)}

+ +
+
+

{teamHeading("Defeat", data, 1)}

+ +
+
+
+ ); } diff --git a/app/features/cv/components/ScoreboardOwnCard.tsx b/app/features/cv/components/ScoreboardOwnCard.tsx index 96b85efbb..2eb875422 100644 --- a/app/features/cv/components/ScoreboardOwnCard.tsx +++ b/app/features/cv/components/ScoreboardOwnCard.tsx @@ -6,56 +6,70 @@ import { formatTime } from "./format"; import { lobbyLabel, mainWeaponLabel, modeLabel, stageLabel } from "./labels"; export function ScoreboardOwnCard(props: { - t: number; - confidence: number; - data: ScoreboardOwnData; - thumbnail?: string; - detectedAt?: number; - /** lazy loader for the exact analyzed frame — enables fixture export */ - getFrame?: () => Promise; - onInspect?: () => void; + t: number; + confidence: number; + data: ScoreboardOwnData; + thumbnail?: string; + detectedAt?: number; + /** lazy loader for the exact analyzed frame — enables fixture export */ + getFrame?: () => Promise; + onInspect?: () => void; }) { - const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } = props; - return ( -
-
- t={formatTime(t)} - own results - - {[lobbyLabel(data.lobby), modeLabel(data.mode), stageLabel(data.stage)] - .map((v) => v ?? "?") - .join(" · ")} - - - weapon {mainWeaponLabel(data.weaponId) ?? "?"} - - confidence {(confidence * 100).toFixed(0)}% - {detectedAt && {new Date(detectedAt).toLocaleTimeString()}} - {onInspect && } - {getFrame && ( - - )} - {thumbnail && analyzed frame} -
-
-
- {data.weaponId !== null && ( - {mainWeaponLabel(data.weaponId) - )} - -
-
-
- ); + const { t, confidence, data, thumbnail, detectedAt, getFrame, onInspect } = + props; + return ( +
+
+ t={formatTime(t)} + own results + + {[ + lobbyLabel(data.lobby), + modeLabel(data.mode), + stageLabel(data.stage), + ] + .map((v) => v ?? "?") + .join(" · ")} + + + weapon {mainWeaponLabel(data.weaponId) ?? "?"} + + confidence {(confidence * 100).toFixed(0)}% + {detectedAt && {new Date(detectedAt).toLocaleTimeString()}} + {onInspect && ( + + )} + {getFrame && ( + + )} + {thumbnail && ( + analyzed frame + )} +
+
+
+ {data.weaponId !== null && ( + {mainWeaponLabel(data.weaponId) + )} + +
+
+
+ ); } diff --git a/app/features/cv/components/ScreenshotPage.tsx b/app/features/cv/components/ScreenshotPage.tsx index f736223ad..9c2da8b09 100644 --- a/app/features/cv/components/ScreenshotPage.tsx +++ b/app/features/cv/components/ScreenshotPage.tsx @@ -1,5 +1,5 @@ -import { CV_ASSETS_URL } from "~/utils/urls"; import { useCallback, useEffect, useRef, useState } from "react"; +import { CV_ASSETS_URL } 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"; @@ -16,503 +16,580 @@ import type { DetectedEvent } from "../core/detectors/types"; import { AnalyzerClient } from "../worker/client"; import type { WorkerResponse } from "../worker/protocol"; import { downloadEventsCsv } from "./events-csv"; -import { lobbyLabel, mainWeaponLabel, modeLabel, stageLabel, weaponLabel } from "./labels"; import { type CardData, downloadExpectedJson } from "./fixture-export"; +import { + lobbyLabel, + mainWeaponLabel, + modeLabel, + stageLabel, + weaponLabel, +} from "./labels"; import { takeScreenshotFrame } from "./screenshot-handoff"; type Result = Extract; /** Draw a ROI crop from the normalized frame, scaled up. */ -function RoiCrop(props: { frame: HTMLCanvasElement; roi: Roi; scale?: number }) { - const { frame, roi, scale = 1.5 } = props; - const ref = useRef(null); - useEffect(() => { - const canvas = ref.current!; - canvas.width = Math.round(roi.w * scale); - canvas.height = Math.round(roi.h * scale); - const ctx = canvas.getContext("2d")!; - ctx.imageSmoothingEnabled = false; - ctx.drawImage(frame, roi.x, roi.y, roi.w, roi.h, 0, 0, canvas.width, canvas.height); - }, [frame, roi.x, roi.y, roi.w, roi.h, scale]); - return ; +function RoiCrop(props: { + frame: HTMLCanvasElement; + roi: Roi; + scale?: number; +}) { + const { frame, roi, scale = 1.5 } = props; + const ref = useRef(null); + useEffect(() => { + const canvas = ref.current!; + canvas.width = Math.round(roi.w * scale); + canvas.height = Math.round(roi.h * scale); + const ctx = canvas.getContext("2d")!; + ctx.imageSmoothingEnabled = false; + ctx.drawImage( + frame, + roi.x, + roi.y, + roi.w, + roi.h, + 0, + 0, + canvas.width, + canvas.height, + ); + }, [frame, roi.x, roi.y, roi.w, roi.h, scale]); + return ; } /** Per-row parse ROIs, in the same order as the event's players array. */ interface RowRois { - weapon: Roi; - name: Roi; - paint: Roi; - stats: [Roi, Roi, Roi]; + weapon: Roi; + name: Roi; + paint: Roi; + stats: [Roi, Roi, Roi]; } function scoreboardRows(): RowRois[] { - return sb.ROW_CENTERS.map((cy) => ({ - weapon: sb.weaponRoi(cy), - name: sb.nameRoi(cy), - paint: sb.paintRoi(cy), - stats: [sb.statRoi(cy, 0), sb.statRoi(cy, 1), sb.statRoi(cy, 2)], - })); + return sb.ROW_CENTERS.map((cy) => ({ + weapon: sb.weaponRoi(cy), + name: sb.nameRoi(cy), + paint: sb.paintRoi(cy), + stats: [sb.statRoi(cy, 0), sb.statRoi(cy, 1), sb.statRoi(cy, 2)], + })); } /** winnerSide comes from the event debug: players are ordered winners-first. */ function replayRows(winnerSide: string): RowRois[] { - const panels = winnerSide === "right" ? [replay.PANEL_DX, 0] : [0, replay.PANEL_DX]; - return panels.flatMap((dx) => - replay.ROW_CENTERS.map((cy) => ({ - weapon: replay.weaponRoi(cy, dx), - name: replay.nameRoi(cy, dx), - paint: replay.paintRoi(cy, dx), - stats: [replay.statRoi(cy, dx, 0), replay.statRoi(cy, dx, 1), replay.statRoi(cy, dx, 2)] as [ - Roi, - Roi, - Roi, - ], - })), - ); + const panels = + winnerSide === "right" ? [replay.PANEL_DX, 0] : [0, replay.PANEL_DX]; + return panels.flatMap((dx) => + replay.ROW_CENTERS.map((cy) => ({ + weapon: replay.weaponRoi(cy, dx), + name: replay.nameRoi(cy, dx), + paint: replay.paintRoi(cy, dx), + stats: [ + replay.statRoi(cy, dx, 0), + replay.statRoi(cy, dx, 1), + replay.statRoi(cy, dx, 2), + ] as [Roi, Roi, Roi], + })), + ); } function drawOverlay(ctx: CanvasRenderingContext2D, detector: string) { - const rect = (roi: Roi, color: string) => { - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.strokeRect(roi.x, roi.y, roi.w, roi.h); - }; - if (detector === "death") { - rect(death.SPLAT_LINE1_ROI, "#34d399"); - rect(death.WEAPON_LINE_ROI, "#f87171"); - for (let row = 0; row < death.ABILITY_ROWS; row++) { - rect(death.abilityMainRoi(row), "#60a5fa"); - for (const slot of [0, 1, 2]) rect(death.abilitySubRoi(row, slot), "#e879f9"); - } - rect(death.TAG_NAME_OUTER, "#4ade80"); - for (const roi of [...death.GATE_BURST_PROBES, ...death.GATE_PANEL_PROBES]) { - rect(roi, "#facc15"); - } - return; - } - if (detector === "map-start") { - rect(mapStart.MODE_LABEL_ROI, "#34d399"); - rect(mapStart.MODE_BLOCK_ROI, "#f87171"); - rect(mapStart.STAGE_ROI, "#60a5fa"); - rect(mapStart.GATE_INK_BAND, "#e879f9"); - for (const roi of mapStart.GATE_DARK_PROBES) rect(roi, "#facc15"); - return; - } - if (detector === "scoreboard-own") { - rect(own.WEAPON_TITLE_BAND, "#f87171"); - for (let row = 0; row < own.GEAR_ROWS; row++) { - rect(own.gearMainRoi(row), "#60a5fa"); - for (const slot of [0, 1, 2]) rect(own.gearSubRoi(row, slot), "#e879f9"); - rect(own.gateStripProbe(row), "#facc15"); - } - for (const roi of [...own.GATE_PANEL_PROBES, ...own.GATE_TITLE_TEXT_PROBES]) { - rect(roi, "#facc15"); - } - rect(sb.HEADER_LOBBY_BAND, "#34d399"); - rect(sb.HEADER_LINE_BAND, "#34d399"); - return; - } - if (detector === "minimap") { - for (const card of minimap.CARD_LAYOUTS) { - rect(card.name, "#4ade80"); - rect(card.weapon, "#f87171"); - rect(card.subTile, "#22d3ee"); - for (const [cx, cy] of card.badges) rect(minimap.badgeRoi(cx, cy), "#60a5fa"); - rect(card.cross, "#e879f9"); - } - for (const cy of minimap.ENEMY_ROW_CYS) { - rect(minimap.enemyWeaponRoi(cy), "#f87171"); - rect(minimap.enemySubTileRoi(cy), "#22d3ee"); - for (const cx of minimap.ENEMY_BADGE_XS) rect(minimap.badgeRoi(cx, cy), "#60a5fa"); - rect(minimap.enemyCrossRoi(cy), "#e879f9"); - } - for (const roi of [ - minimap.GATE_CLOSE_BRIGHT, - minimap.GATE_SPAWN_BRIGHT, - ...minimap.GATE_CLOSE_DARK_PROBES, - ...minimap.GATE_SPAWN_DARK_PROBES, - ]) { - rect(roi, "#facc15"); - } - return; - } - if (detector === "scoreboard-replay") { - for (const dx of replay.PANEL_XS) { - for (const cy of replay.ROW_CENTERS) { - rect(replay.weaponRoi(cy, dx), "#f87171"); - rect(replay.nameRoi(cy, dx), "#4ade80"); - rect(replay.paintRoi(cy, dx), "#60a5fa"); - for (const i of [0, 1, 2] as const) rect(replay.statRoi(cy, dx, i), "#e879f9"); - rect(replay.gateFlatProbe(cy, dx), "#facc15"); - } - rect(replay.teamScoreRoi(dx), "#60a5fa"); - rect(replay.resultTagRoi(dx), "#fb923c"); - } - for (const roi of replay.MATCH_SCORE_ROIS) rect(roi, "#60a5fa"); - for (const roi of replay.GATE_GAP_PROBES) rect(roi, "#facc15"); - rect(replay.HEADER_TOP_BAND, "#34d399"); - rect(replay.HEADER_BOTTOM_BAND, "#34d399"); - rect(replay.REPLAY_CODE_ROI, "#34d399"); - return; - } - for (const cy of sb.ROW_CENTERS) { - rect(sb.weaponRoi(cy), "#f87171"); - rect(sb.nameRoi(cy), "#4ade80"); - rect(sb.paintRoi(cy), "#60a5fa"); - for (const i of [0, 1, 2] as const) rect(sb.statRoi(cy, i), "#e879f9"); - rect(sb.gateDarkProbe(cy), "#facc15"); - } - for (const roi of sb.TEAM_SCORE_ROIS) rect(roi, "#60a5fa"); - for (const roi of sb.GATE_PANEL_PROBES) rect(roi, "#facc15"); - rect(sb.HEADER_LOBBY_BAND, "#34d399"); - rect(sb.HEADER_LINE_BAND, "#34d399"); + const rect = (roi: Roi, color: string) => { + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.strokeRect(roi.x, roi.y, roi.w, roi.h); + }; + if (detector === "death") { + rect(death.SPLAT_LINE1_ROI, "#34d399"); + rect(death.WEAPON_LINE_ROI, "#f87171"); + for (let row = 0; row < death.ABILITY_ROWS; row++) { + rect(death.abilityMainRoi(row), "#60a5fa"); + for (const slot of [0, 1, 2]) + rect(death.abilitySubRoi(row, slot), "#e879f9"); + } + rect(death.TAG_NAME_OUTER, "#4ade80"); + for (const roi of [ + ...death.GATE_BURST_PROBES, + ...death.GATE_PANEL_PROBES, + ]) { + rect(roi, "#facc15"); + } + return; + } + if (detector === "map-start") { + rect(mapStart.MODE_LABEL_ROI, "#34d399"); + rect(mapStart.MODE_BLOCK_ROI, "#f87171"); + rect(mapStart.STAGE_ROI, "#60a5fa"); + rect(mapStart.GATE_INK_BAND, "#e879f9"); + for (const roi of mapStart.GATE_DARK_PROBES) rect(roi, "#facc15"); + return; + } + if (detector === "scoreboard-own") { + rect(own.WEAPON_TITLE_BAND, "#f87171"); + for (let row = 0; row < own.GEAR_ROWS; row++) { + rect(own.gearMainRoi(row), "#60a5fa"); + for (const slot of [0, 1, 2]) rect(own.gearSubRoi(row, slot), "#e879f9"); + rect(own.gateStripProbe(row), "#facc15"); + } + for (const roi of [ + ...own.GATE_PANEL_PROBES, + ...own.GATE_TITLE_TEXT_PROBES, + ]) { + rect(roi, "#facc15"); + } + rect(sb.HEADER_LOBBY_BAND, "#34d399"); + rect(sb.HEADER_LINE_BAND, "#34d399"); + return; + } + if (detector === "minimap") { + for (const card of minimap.CARD_LAYOUTS) { + rect(card.name, "#4ade80"); + rect(card.weapon, "#f87171"); + rect(card.subTile, "#22d3ee"); + for (const [cx, cy] of card.badges) + rect(minimap.badgeRoi(cx, cy), "#60a5fa"); + rect(card.cross, "#e879f9"); + } + for (const cy of minimap.ENEMY_ROW_CYS) { + rect(minimap.enemyWeaponRoi(cy), "#f87171"); + rect(minimap.enemySubTileRoi(cy), "#22d3ee"); + for (const cx of minimap.ENEMY_BADGE_XS) + rect(minimap.badgeRoi(cx, cy), "#60a5fa"); + rect(minimap.enemyCrossRoi(cy), "#e879f9"); + } + for (const roi of [ + minimap.GATE_CLOSE_BRIGHT, + minimap.GATE_SPAWN_BRIGHT, + ...minimap.GATE_CLOSE_DARK_PROBES, + ...minimap.GATE_SPAWN_DARK_PROBES, + ]) { + rect(roi, "#facc15"); + } + return; + } + if (detector === "scoreboard-replay") { + for (const dx of replay.PANEL_XS) { + for (const cy of replay.ROW_CENTERS) { + rect(replay.weaponRoi(cy, dx), "#f87171"); + rect(replay.nameRoi(cy, dx), "#4ade80"); + rect(replay.paintRoi(cy, dx), "#60a5fa"); + for (const i of [0, 1, 2] as const) + rect(replay.statRoi(cy, dx, i), "#e879f9"); + rect(replay.gateFlatProbe(cy, dx), "#facc15"); + } + rect(replay.teamScoreRoi(dx), "#60a5fa"); + rect(replay.resultTagRoi(dx), "#fb923c"); + } + for (const roi of replay.MATCH_SCORE_ROIS) rect(roi, "#60a5fa"); + for (const roi of replay.GATE_GAP_PROBES) rect(roi, "#facc15"); + rect(replay.HEADER_TOP_BAND, "#34d399"); + rect(replay.HEADER_BOTTOM_BAND, "#34d399"); + rect(replay.REPLAY_CODE_ROI, "#34d399"); + return; + } + for (const cy of sb.ROW_CENTERS) { + rect(sb.weaponRoi(cy), "#f87171"); + rect(sb.nameRoi(cy), "#4ade80"); + rect(sb.paintRoi(cy), "#60a5fa"); + for (const i of [0, 1, 2] as const) rect(sb.statRoi(cy, i), "#e879f9"); + rect(sb.gateDarkProbe(cy), "#facc15"); + } + for (const roi of sb.TEAM_SCORE_ROIS) rect(roi, "#60a5fa"); + for (const roi of sb.GATE_PANEL_PROBES) rect(roi, "#facc15"); + rect(sb.HEADER_LOBBY_BAND, "#34d399"); + rect(sb.HEADER_LINE_BAND, "#34d399"); } export function ScreenshotPage() { - const displayRef = useRef(null); - const clientRef = useRef(null); - const resultRef = useRef<(r: Result) => void>(() => {}); - const doneRef = useRef<() => void>(() => {}); + const displayRef = useRef(null); + const clientRef = useRef(null); + const resultRef = useRef<(r: Result) => void>(() => {}); + const doneRef = useRef<() => void>(() => {}); - const [frame, setFrame] = useState(null); - const [results, setResults] = useState>({}); - const [busy, setBusy] = useState(false); - const [over, setOver] = useState(false); - const [error, setError] = useState(null); + const [frame, setFrame] = useState(null); + const [results, setResults] = useState>({}); + const [busy, setBusy] = useState(false); + const [over, setOver] = useState(false); + const [error, setError] = useState(null); - useEffect(() => { - clientRef.current = new AnalyzerClient( - (r) => resultRef.current(r), - (message) => { - setError(message); - setBusy(false); - }, - () => doneRef.current(), - // one-shot analyses: re-running the same screenshot must always parse - { suppressSteadyFrames: false }, - ); - return () => clientRef.current?.dispose(); - }, []); + useEffect(() => { + clientRef.current = new AnalyzerClient( + (r) => resultRef.current(r), + (message) => { + setError(message); + setBusy(false); + }, + () => doneRef.current(), + // one-shot analyses: re-running the same screenshot must always parse + { suppressSteadyFrames: false }, + ); + return () => clientRef.current?.dispose(); + }, []); - // the detector whose inspector/overlay is shown: the one that fired - const active = Object.values(results).find((r) => r.events.length > 0); - const activeDetector = active?.detector ?? "scoreboard"; + // the detector whose inspector/overlay is shown: the one that fired + const active = Object.values(results).find((r) => r.events.length > 0); + const activeDetector = active?.detector ?? "scoreboard"; - useEffect(() => { - if (!frame) return; - const display = displayRef.current!; - display.width = CANONICAL_WIDTH; - display.height = CANONICAL_HEIGHT; - const ctx = display.getContext("2d")!; - ctx.drawImage(frame, 0, 0); - drawOverlay(ctx, activeDetector); - }, [frame, activeDetector]); + useEffect(() => { + if (!frame) return; + const display = displayRef.current!; + display.width = CANONICAL_WIDTH; + display.height = CANONICAL_HEIGHT; + const ctx = display.getContext("2d")!; + ctx.drawImage(frame, 0, 0); + drawOverlay(ctx, activeDetector); + }, [frame, activeDetector]); - const analyze = useCallback(async (file: File | Blob) => { - setError(null); - setBusy(true); - setResults({}); - try { - const bitmap = await createImageBitmap(file); + const analyze = useCallback(async (file: File | Blob) => { + setError(null); + setBusy(true); + setResults({}); + try { + const bitmap = await createImageBitmap(file); - // normalized frame for local crop display, same as the pipeline does - const norm = document.createElement("canvas"); - norm.width = CANONICAL_WIDTH; - norm.height = CANONICAL_HEIGHT; - norm.getContext("2d")!.drawImage(bitmap, 0, 0, CANONICAL_WIDTH, CANONICAL_HEIGHT); - setFrame(norm); + // normalized frame for local crop display, same as the pipeline does + const norm = document.createElement("canvas"); + norm.width = CANONICAL_WIDTH; + norm.height = CANONICAL_HEIGHT; + norm + .getContext("2d")! + .drawImage(bitmap, 0, 0, CANONICAL_WIDTH, CANONICAL_HEIGHT); + setFrame(norm); - resultRef.current = (r) => { - setResults((prev) => ({ ...prev, [r.detector]: r })); - }; - doneRef.current = () => setBusy(false); - const client = clientRef.current!; - await client.whenReady(); - client.analyze(bitmap, 0); - } catch (e) { - setError(String(e)); - setBusy(false); - } - }, []); + resultRef.current = (r) => { + setResults((prev) => ({ ...prev, [r.detector]: r })); + }; + doneRef.current = () => setBusy(false); + const client = clientRef.current!; + await client.whenReady(); + client.analyze(bitmap, 0); + } catch (e) { + setError(String(e)); + setBusy(false); + } + }, []); - // frame handed off from another page (e.g. a VoD match's Inspect button) - useEffect(() => { - const frame = takeScreenshotFrame(); - if (frame) void analyze(frame); - }, [analyze]); + // frame handed off from another page (e.g. a VoD match's Inspect button) + useEffect(() => { + const frame = takeScreenshotFrame(); + if (frame) void analyze(frame); + }, [analyze]); - const event = active?.events[0] as DetectedEvent | undefined; - const rows = (event?.debug?.rows ?? []) as ScoreboardRowDebug[]; - const isReplay = activeDetector === "scoreboard-replay"; - const isDeath = activeDetector === "death"; - const isMapStart = activeDetector === "map-start"; - const isOwn = activeDetector === "scoreboard-own"; - const winnerSide = String(event?.debug?.winnerSide ?? "left"); - const rowRois = isReplay ? replayRows(winnerSide) : scoreboardRows(); + const event = active?.events[0] as DetectedEvent | undefined; + const rows = (event?.debug?.rows ?? []) as ScoreboardRowDebug[]; + const isReplay = activeDetector === "scoreboard-replay"; + const isDeath = activeDetector === "death"; + const isMapStart = activeDetector === "map-start"; + const isOwn = activeDetector === "scoreboard-own"; + const winnerSide = String(event?.debug?.winnerSide ?? "left"); + const rowRois = isReplay ? replayRows(winnerSide) : scoreboardRows(); - return ( -
-
{ - e.preventDefault(); - setOver(true); - }} - onDragLeave={() => setOver(false)} - onDrop={(e) => { - e.preventDefault(); - setOver(false); - const file = e.dataTransfer.files[0]; - if (file) void analyze(file); - }} - > - Drop a frame (PNG/JPEG) here, or{" "} - - {busy && " — analyzing…"} -
- {error &&

{error}

} + return ( +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */} +
{ + e.preventDefault(); + setOver(true); + }} + onDragLeave={() => setOver(false)} + onDrop={(e) => { + e.preventDefault(); + setOver(false); + const file = e.dataTransfer.files[0]; + if (file) void analyze(file); + }} + > + Drop a frame (PNG/JPEG) here, or{" "} + + {busy && " — analyzing…"} +
+ {error &&

{error}

} -
- -
+
+ +
- {frame && !busy && ( -

- {" "} - -

- )} + {frame && !busy && ( +

+ {" "} + +

+ )} - {Object.values(results).map((result) => ( -

- {result.detector} gate: {result.gate.pass ? "fired" : "no fire"} (score{" "} - {result.gate.score.toFixed(3)}) - {result.events[0] && result.detector === "death" && ( - <> - {" · "}confidence {((result.events[0].confidence ?? 0) * 100).toFixed(1)}%{" · "} - {(() => { - const data = result.events[0].data as DeathData; - return `splatted by ${weaponLabel(data.weaponType, data.weaponId) ?? "?"} (${data.name ?? "?"})`; - })()} - - )} - {result.events[0] && result.detector === "map-start" && ( - <> - {" · "}confidence {((result.events[0].confidence ?? 0) * 100).toFixed(1)}%{" · "} - {(() => { - const data = result.events[0].data as MapStartData; - return `${modeLabel(data.mode) ?? "?"} · ${stageLabel(data.stage) ?? "?"}`; - })()} - - )} - {result.events[0] && result.detector === "scoreboard-own" && ( - <> - {" · "}confidence {((result.events[0].confidence ?? 0) * 100).toFixed(1)}%{" · "} - {(() => { - const data = result.events[0].data as ScoreboardOwnData; - return `${[lobbyLabel(data.lobby), modeLabel(data.mode), stageLabel(data.stage)].map((v) => v ?? "?").join(" · ")} · ${mainWeaponLabel(data.weaponId) ?? "?"}`; - })()} - - )} - {result.events[0] && result.detector === "minimap" && ( - <> - {" · "}confidence {((result.events[0].confidence ?? 0) * 100).toFixed(1)}%{" · "} - {(() => { - const data = result.events[0].data as MinimapData; - const players = data.teammates.map((p) => p.name ?? "?").join(", "); - return `${data.stage ?? "?"} · ${players}`; - })()} - - )} - {result.events[0] && - result.detector !== "death" && - result.detector !== "map-start" && - result.detector !== "scoreboard-own" && - result.detector !== "minimap" && ( - <> - {" · "}confidence {((result.events[0].confidence ?? 0) * 100).toFixed(1)}% · scores{" "} - {JSON.stringify((result.events[0].data as CardData).scores)} - {" · "} - {(() => { - const data = result.events[0].data as CardData; - return [lobbyLabel(data.lobby), modeLabel(data.mode), stageLabel(data.stage)] - .map((v) => v ?? "?") - .join(" · "); - })()} - - )} -

- ))} + {Object.values(results).map((result) => ( +

+ {result.detector} gate:{" "} + {result.gate.pass ? "fired" : "no fire"} (score{" "} + {result.gate.score.toFixed(3)}) + {result.events[0] && result.detector === "death" && ( + <> + {" · "}confidence{" "} + {((result.events[0].confidence ?? 0) * 100).toFixed(1)}%{" · "} + {(() => { + const data = result.events[0].data as DeathData; + return `splatted by ${weaponLabel(data.weaponType, data.weaponId) ?? "?"} (${data.name ?? "?"})`; + })()} + + )} + {result.events[0] && result.detector === "map-start" && ( + <> + {" · "}confidence{" "} + {((result.events[0].confidence ?? 0) * 100).toFixed(1)}%{" · "} + {(() => { + const data = result.events[0].data as MapStartData; + return `${modeLabel(data.mode) ?? "?"} · ${stageLabel(data.stage) ?? "?"}`; + })()} + + )} + {result.events[0] && result.detector === "scoreboard-own" && ( + <> + {" · "}confidence{" "} + {((result.events[0].confidence ?? 0) * 100).toFixed(1)}%{" · "} + {(() => { + const data = result.events[0].data as ScoreboardOwnData; + return `${[lobbyLabel(data.lobby), modeLabel(data.mode), stageLabel(data.stage)].map((v) => v ?? "?").join(" · ")} · ${mainWeaponLabel(data.weaponId) ?? "?"}`; + })()} + + )} + {result.events[0] && result.detector === "minimap" && ( + <> + {" · "}confidence{" "} + {((result.events[0].confidence ?? 0) * 100).toFixed(1)}%{" · "} + {(() => { + const data = result.events[0].data as MinimapData; + const players = data.teammates + .map((p) => p.name ?? "?") + .join(", "); + return `${data.stage ?? "?"} · ${players}`; + })()} + + )} + {result.events[0] && + result.detector !== "death" && + result.detector !== "map-start" && + result.detector !== "scoreboard-own" && + result.detector !== "minimap" && ( + <> + {" · "}confidence{" "} + {((result.events[0].confidence ?? 0) * 100).toFixed(1)}% · + scores{" "} + {JSON.stringify((result.events[0].data as CardData).scores)} + {" · "} + {(() => { + const data = result.events[0].data as CardData; + return [ + lobbyLabel(data.lobby), + modeLabel(data.mode), + stageLabel(data.stage), + ] + .map((v) => v ?? "?") + .join(" · "); + })()} + + )} +

+ ))} - {frame && event && isReplay && ( -

- timestamp {event.data.timestamp ?? "?"} - {" · "}code {event.data.replayCode ?? "?"}{" "} - (raw: {String(event.debug?.codeRaw ?? "")}) - {" · "}match scores {JSON.stringify(event.data.matchScores)} - {" · "}winner panel {winnerSide} -
- {" "} - -

- )} + {frame && event && isReplay && ( +

+ timestamp {event.data.timestamp ?? "?"} + {" · "}code {event.data.replayCode ?? "?"}{" "} + + (raw: {String(event.debug?.codeRaw ?? "")}) + + {" · "}match scores {JSON.stringify(event.data.matchScores)} + {" · "}winner panel {winnerSide} +
+ {" "} + +

+ )} - {frame && event && isDeath && ( -

- {(() => { - const data = event.data as unknown as DeathData; - return ( - <> - weapon {weaponLabel(data.weaponType, data.weaponId) ?? "?"}{" "} - (raw: {String(event.debug?.weaponRaw ?? "")}) - {" · "}name {data.name ?? "?"}{" "} - (raw: {String(event.debug?.nameRaw ?? "")}) - {" · "}abilities {data.abilities.map((row) => row.join(" ")).join(" | ")} -
- {" "} - - - ); - })()} -

- )} + {frame && event && isDeath && ( +

+ {(() => { + const data = event.data as unknown as DeathData; + return ( + <> + weapon{" "} + {weaponLabel(data.weaponType, data.weaponId) ?? "?"}{" "} + + (raw: {String(event.debug?.weaponRaw ?? "")}) + + {" · "}name {data.name ?? "?"}{" "} + + (raw: {String(event.debug?.nameRaw ?? "")}) + + {" · "}abilities{" "} + {data.abilities.map((row) => row.join(" ")).join(" | ")} +
+ {" "} + + + ); + })()} +

+ )} - {frame && event && isMapStart && ( -

- {(() => { - const data = event.data as unknown as MapStartData; - return ( - <> - mode {modeLabel(data.mode) ?? "?"}{" "} - (raw: {String(event.debug?.modeReading ?? "")}) - {" · "}stage {stageLabel(data.stage) ?? "?"}{" "} - (raw: {String(event.debug?.stageReading ?? "")}) -
- {" "} - - - ); - })()} -

- )} + {frame && event && isMapStart && ( +

+ {(() => { + const data = event.data as unknown as MapStartData; + return ( + <> + mode {modeLabel(data.mode) ?? "?"}{" "} + + (raw: {String(event.debug?.modeReading ?? "")}) + + {" · "}stage {stageLabel(data.stage) ?? "?"}{" "} + + (raw: {String(event.debug?.stageReading ?? "")}) + +
+ {" "} + + + ); + })()} +

+ )} - {frame && event && isOwn && ( -

- {(() => { - const data = event.data as unknown as ScoreboardOwnData; - return ( - <> - weapon {mainWeaponLabel(data.weaponId) ?? "?"}{" "} - (raw: {String(event.debug?.weaponReading ?? "")}) - {" · "}abilities {data.abilities.map((row) => row.join(" ")).join(" | ")} -
- {" "} - {[0, 1, 2].map((row) => ( - - ))} - - ); - })()} -

- )} + {frame && event && isOwn && ( +

+ {(() => { + const data = event.data as unknown as ScoreboardOwnData; + return ( + <> + weapon {mainWeaponLabel(data.weaponId) ?? "?"}{" "} + + (raw: {String(event.debug?.weaponReading ?? "")}) + + {" · "}abilities{" "} + {data.abilities.map((row) => row.join(" ")).join(" | ")} +
+ {" "} + {[0, 1, 2].map((row) => ( + + ))} + + ); + })()} +

+ )} - {frame && event && !isDeath && !isMapStart && !isOwn && ( - - - - - - - - - - - - - {rowRois.map((roi, i) => { - const player = event.data.players[i]; - const dbg = rows[i]; - return ( - - - - - - - - - ); - })} - -
rowweapon croptop candidatesnamepaintstats (splats / deaths / specials)
{i} - - -
- {dbg?.weapon?.top.map((c) => ( - - {c.id} - {c.id} - {c.score.toFixed(3)} - - ))} -
-
- - {player?.name || "—"}{" "} - {dbg?.nameScore.toFixed(3)} - - - {player?.paint ?? "—"}{" "} - {dbg?.paintScore.toFixed(3)} - -
- {([0, 1, 2] as const).map((s) => ( - - - {[player?.ka, player?.d, player?.s][s] ?? "—"} - {dbg?.statScores[s].toFixed(2)} - - ))} -
-
- )} -
- ); + {frame && event && !isDeath && !isMapStart && !isOwn && ( + + + + + + + + + + + + + {rowRois.map((roi, i) => { + const player = event.data.players[i]; + const dbg = rows[i]; + return ( + + + + + + + + + ); + })} + +
rowweapon croptop candidatesnamepaintstats (splats / deaths / specials)
{i} + + +
+ {dbg?.weapon?.top.map((c) => ( + + {c.id} + {c.id} + {c.score.toFixed(3)} + + ))} +
+
+ + {player?.name || "—"}{" "} + {dbg?.nameScore.toFixed(3)} + + + {player?.paint ?? "—"}{" "} + {dbg?.paintScore.toFixed(3)} + +
+ {([0, 1, 2] as const).map((s) => ( + + + {[player?.ka, player?.d, player?.s][s] ?? "—"} + + {dbg?.statScores[s].toFixed(2)} + + + ))} +
+
+ )} +
+ ); } diff --git a/app/features/cv/components/VodPage.tsx b/app/features/cv/components/VodPage.tsx index b90bca280..84bcf03ad 100644 --- a/app/features/cv/components/VodPage.tsx +++ b/app/features/cv/components/VodPage.tsx @@ -13,17 +13,18 @@ * (src/store/vods.ts); the default view lists them for reinspection. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Link } from "react-router"; import { openVodScan } from "../capture/vod-frames"; import { connectAbilities } from "../core/ability-harvest"; import type { DetectedEvent } from "../core/detectors/types"; import { TimelineBuilder } from "../core/timeline/index"; import { - deleteVod, - listVods, - loadVodEventFrame, - loadVodEvents, - saveVod, - type VodSummary, + deleteVod, + listVods, + loadVodEventFrame, + loadVodEvents, + saveVod, + type VodSummary, } from "../store/vods"; import { AnalyzerPool, defaultPoolSize } from "../worker/pool"; import { EventCard, type GetFrame } from "./EventCard"; @@ -31,8 +32,11 @@ import { downloadEventsCsv } from "./events-csv"; import type { FixtureData } from "./fixture-export"; import { SENDOU_UPLOAD_ENABLED } from "./flags"; import { formatTime } from "./format"; -import { countIngestBatches, type SendouUser, sendVodResults } from "./sendou-ingest"; -import { Link } from "react-router"; +import { + countIngestBatches, + type SendouUser, + sendVodResults, +} from "./sendou-ingest"; import { sendouUpload } from "./sendou-upload"; import { thumbnailFromBlob } from "./thumbnail"; @@ -50,475 +54,515 @@ const MAX_ANALYSIS_GAP_SECONDS = 0.25; type Status = "idle" | "scanning" | "done" | "error"; interface VodMatch { - event: DetectedEvent; - thumbnail?: string; - /** lossless PNG of the exact frame the detector analyzed (live scan) */ - frame?: Blob; - /** stored VoD: id to load the frame from the vod-frames store */ - frameId?: number; + event: DetectedEvent; + thumbnail?: string; + /** lossless PNG of the exact frame the detector analyzed (live scan) */ + frame?: Blob; + /** stored VoD: id to load the frame from the vod-frames store */ + frameId?: number; } interface Progress { - t: number; - duration: number; - /** scan speed as a multiple of realtime */ - rate: number; + t: number; + duration: number; + /** scan speed as a multiple of realtime */ + rate: number; } /** "Upload as results" progress/outcome shown next to the button. */ type ResultsSend = - | { state: "sending"; sent: number; total: number } - | { state: "done"; sent: number; total: number; error: string | null }; + | { state: "sending"; sent: number; total: number } + | { state: "done"; sent: number; total: number; error: string | null }; export function VodPage({ - sendouUser, + sendouUser, }: { - /** sendou.ink login shown in the header; undefined = probing, null = not logged in */ - sendouUser: SendouUser | null | undefined; + /** sendou.ink login shown in the header; undefined = probing, null = not logged in */ + sendouUser: SendouUser | null | undefined; }) { - const videoRef = useRef(null); - const previewRef = useRef(null); - const poolRef = useRef(null); - const timelineRef = useRef(new TimelineBuilder()); - // latest gate score from any worker; flushed to state on the UI throttle - const gateScoreRef = useRef(null); - const abortRef = useRef({ aborted: false }); - const urlRef = useRef(null); - // source of truth for matches (state mirrors it) so the scan loop can - // persist the final list without waiting on React - const matchesRef = useRef([]); - // in-flight thumbnail work; awaited before persisting a finished scan - const sideWorkRef = useRef[]>([]); + const videoRef = useRef(null); + const previewRef = useRef(null); + const poolRef = useRef(null); + const timelineRef = useRef(new TimelineBuilder()); + // latest gate score from any worker; flushed to state on the UI throttle + const gateScoreRef = useRef(null); + const abortRef = useRef({ aborted: false }); + const urlRef = useRef(null); + // source of truth for matches (state mirrors it) so the scan loop can + // persist the final list without waiting on React + const matchesRef = useRef([]); + // in-flight thumbnail work; awaited before persisting a finished scan + const sideWorkRef = useRef[]>([]); - const [fileName, setFileName] = useState(null); - /** live scan vs. reopened saved VoD (no video element for the latter) */ - const [source, setSource] = useState<"scan" | "stored">("scan"); - const [status, setStatus] = useState("idle"); - const [method, setMethod] = useState(null); - const [gateScore, setGateScore] = useState(null); - const [progress, setProgress] = useState(null); - const [matches, setMatches] = useState([]); - const [vods, setVods] = useState([]); - const [error, setError] = useState(null); - const [over, setOver] = useState(false); - const [resultsSend, setResultsSend] = useState(null); + const [fileName, setFileName] = useState(null); + /** live scan vs. reopened saved VoD (no video element for the latter) */ + const [source, setSource] = useState<"scan" | "stored">("scan"); + const [status, setStatus] = useState("idle"); + const [method, setMethod] = useState(null); + const [gateScore, setGateScore] = useState(null); + const [progress, setProgress] = useState(null); + const [matches, setMatches] = useState([]); + const [vods, setVods] = useState([]); + const [error, setError] = useState(null); + const [over, setOver] = useState(false); + const [resultsSend, setResultsSend] = useState(null); - const abilityMap = useMemo(() => connectAbilities(matches.map((m) => m.event)), [matches]); + const abilityMap = useMemo( + () => connectAbilities(matches.map((m) => m.event)), + [matches], + ); - // only offered once the whole VoD has been processed (a stored VoD is a - // completed scan by construction) - const upload = useMemo( - () => (status === "done" ? sendouUpload(matches.map((m) => m.event)) : null), - [status, matches], - ); + // only offered once the whole VoD has been processed (a stored VoD is a + // completed scan by construction) + const upload = useMemo( + () => + status === "done" ? sendouUpload(matches.map((m) => m.event)) : null, + [status, matches], + ); - // "Upload as results" — the /ingest counterpart of live sending: private - // match batches (MapStart → deaths → scoreboard) POSTed in one go - const resultsBatchCount = useMemo( - () => (status === "done" ? countIngestBatches(matches.map((m) => m.event)) : 0), - [status, matches], - ); + // "Upload as results" — the /ingest counterpart of live sending: private + // match batches (MapStart → deaths → scoreboard) POSTed in one go + const resultsBatchCount = useMemo( + () => + status === "done" ? countIngestBatches(matches.map((m) => m.event)) : 0, + [status, matches], + ); - const uploadResults = useCallback(async () => { - const events = matchesRef.current.map((m) => m.event); - setResultsSend({ state: "sending", sent: 0, total: countIngestBatches(events) }); - const report = await sendVodResults(events, (sent, total) => - setResultsSend({ state: "sending", sent, total }), - ); - setResultsSend({ - state: "done", - sent: report.sentBatches, - total: report.totalBatches, - error: report.error, - }); - }, []); + const uploadResults = useCallback(async () => { + const events = matchesRef.current.map((m) => m.event); + setResultsSend({ + state: "sending", + sent: 0, + total: countIngestBatches(events), + }); + const report = await sendVodResults(events, (sent, total) => + setResultsSend({ state: "sending", sent, total }), + ); + setResultsSend({ + state: "done", + sent: report.sentBatches, + total: report.totalBatches, + error: report.error, + }); + }, []); - const refreshVods = useCallback(async () => { - try { - setVods(await listVods()); - } catch { - // listing failures are non-fatal; the scan UI still works - } - }, []); + const refreshVods = useCallback(async () => { + try { + setVods(await listVods()); + } catch { + // listing failures are non-fatal; the scan UI still works + } + }, []); - useEffect(() => { - void refreshVods(); - return () => { - abortRef.current.aborted = true; - poolRef.current?.dispose(); - if (urlRef.current) URL.revokeObjectURL(urlRef.current); - }; - }, [refreshVods]); + useEffect(() => { + void refreshVods(); + return () => { + abortRef.current.aborted = true; + poolRef.current?.dispose(); + if (urlRef.current) URL.revokeObjectURL(urlRef.current); + }; + }, [refreshVods]); - const scan = useCallback( - async (file: File) => { - abortRef.current.aborted = true; - const abort = { aborted: false }; - abortRef.current = abort; + const scan = useCallback( + async (file: File) => { + abortRef.current.aborted = true; + const abort = { aborted: false }; + abortRef.current = abort; - setError(null); - setMatches([]); - setResultsSend(null); - setProgress(null); - setGateScore(null); - setMethod(null); - setFileName(file.name); - setSource("scan"); - setStatus("scanning"); + setError(null); + setMatches([]); + setResultsSend(null); + setProgress(null); + setGateScore(null); + setMethod(null); + setFileName(file.name); + setSource("scan"); + setStatus("scanning"); - let dispose = () => {}; - try { - // the element is used by the seek fallback and for post-scan review - const video = videoRef.current!; - if (urlRef.current) URL.revokeObjectURL(urlRef.current); - urlRef.current = URL.createObjectURL(file); - video.src = urlRef.current; + let dispose = () => {}; + try { + // the element is used by the seek fallback and for post-scan review + const video = videoRef.current!; + if (urlRef.current) URL.revokeObjectURL(urlRef.current); + urlRef.current = URL.createObjectURL(file); + video.src = urlRef.current; - poolRef.current ??= new AnalyzerPool( - defaultPoolSize(), - (result) => { - gateScoreRef.current = result.gate.score; - if (!result.gate.pass) return; - for (const event of result.events as DetectedEvent[]) { - const action = timelineRef.current.push(event); - if (action.action !== "added" && action.action !== "replaced") continue; - const frame = result.frame; - sideWorkRef.current.push( - (async () => { - const thumbnail = frame ? await thumbnailFromBlob(frame) : undefined; - const next = - action.action === "replaced" - ? matchesRef.current.filter((m) => m.event !== action.replaced) - : matchesRef.current.slice(); - next.push({ event, thumbnail, frame }); - next.sort((a, b) => a.event.t - b.event.t); - matchesRef.current = next; - setMatches(next); - })().catch(() => {}), - ); - } - }, - (message) => { - setError(message); - setStatus("error"); - }, - ); - const pool = poolRef.current; - await pool.whenReady(); - // let frames still in flight from an aborted scan finish before the - // timeline resets, so their results can't bleed into this scan - await pool.whenIdle(); - if (abort.aborted) return; - matchesRef.current = []; - sideWorkRef.current = []; - timelineRef.current = new TimelineBuilder(); + poolRef.current ??= new AnalyzerPool( + defaultPoolSize(), + (result) => { + gateScoreRef.current = result.gate.score; + if (!result.gate.pass) return; + for (const event of result.events as DetectedEvent[]) { + const action = timelineRef.current.push(event); + if (action.action !== "added" && action.action !== "replaced") + continue; + const frame = result.frame; + sideWorkRef.current.push( + (async () => { + const thumbnail = frame + ? await thumbnailFromBlob(frame) + : undefined; + const next = + action.action === "replaced" + ? matchesRef.current.filter( + (m) => m.event !== action.replaced, + ) + : matchesRef.current.slice(); + next.push({ event, thumbnail, frame }); + next.sort((a, b) => a.event.t - b.event.t); + matchesRef.current = next; + setMatches(next); + })().catch(() => {}), + ); + } + }, + (message) => { + setError(message); + setStatus("error"); + }, + ); + const pool = poolRef.current; + await pool.whenReady(); + // let frames still in flight from an aborted scan finish before the + // timeline resets, so their results can't bleed into this scan + await pool.whenIdle(); + if (abort.aborted) return; + matchesRef.current = []; + sideWorkRef.current = []; + timelineRef.current = new TimelineBuilder(); - const vod = await openVodScan(file, video); - dispose = () => vod.dispose(); - if (abort.aborted) return; - setMethod(vod.method); + const vod = await openVodScan(file, video); + dispose = () => vod.dispose(); + if (abort.aborted) return; + setMethod(vod.method); - const started = performance.now(); - // each frame goes to an idle worker; with none free it is dropped - // unless MAX_ANALYSIS_GAP_SECONDS of video has passed unanalyzed, - // in which case decode waits for a worker. Preview/progress - // re-renders are throttled off the hot loop (the preview draw must - // precede tryAnalyze — transferring the frame to a worker detaches it). - let lastUiUpdate = -Infinity; - let lastAnalyzedT = -Infinity; - for await (const { frame, t } of vod.frames) { - if (abort.aborted) { - frame.close(); - break; - } - const now = performance.now(); - if (now - lastUiUpdate >= 250) { - lastUiUpdate = now; - drawPreview(previewRef.current, frame); - setGateScore(gateScoreRef.current); - const elapsed = (now - started) / 1000; - setProgress({ t, duration: vod.duration, rate: elapsed > 0 ? t / elapsed : 0 }); - } - if (!pool.hasIdle()) { - if (t - lastAnalyzedT < MAX_ANALYSIS_GAP_SECONDS) { - frame.close(); - continue; - } - await pool.whenAnyIdle(); - if (abort.aborted) { - frame.close(); - break; - } - } - pool.tryAnalyze(frame, t); - lastAnalyzedT = t; - } - await pool.whenIdle(); - await Promise.all(sideWorkRef.current); - if (!abort.aborted) { - setProgress((p) => (p ? { ...p, t: vod.duration } : p)); - setStatus("done"); - await saveVod( - { name: file.name, savedAt: Date.now(), duration: vod.duration }, - matchesRef.current.map((m) => ({ - type: m.event.type, - t: m.event.t, - confidence: m.event.confidence, - data: m.event.data, - thumbnail: m.thumbnail, - frame: m.frame, - })), - ); - await refreshVods(); - } - } catch (e) { - if (!abort.aborted) { - setError(String(e)); - setStatus("error"); - } - } finally { - dispose(); - } - }, - [refreshVods], - ); + const started = performance.now(); + // each frame goes to an idle worker; with none free it is dropped + // unless MAX_ANALYSIS_GAP_SECONDS of video has passed unanalyzed, + // in which case decode waits for a worker. Preview/progress + // re-renders are throttled off the hot loop (the preview draw must + // precede tryAnalyze — transferring the frame to a worker detaches it). + let lastUiUpdate = Number.NEGATIVE_INFINITY; + let lastAnalyzedT = Number.NEGATIVE_INFINITY; + for await (const { frame, t } of vod.frames) { + if (abort.aborted) { + frame.close(); + break; + } + const now = performance.now(); + if (now - lastUiUpdate >= 250) { + lastUiUpdate = now; + drawPreview(previewRef.current, frame); + setGateScore(gateScoreRef.current); + const elapsed = (now - started) / 1000; + setProgress({ + t, + duration: vod.duration, + rate: elapsed > 0 ? t / elapsed : 0, + }); + } + if (!pool.hasIdle()) { + if (t - lastAnalyzedT < MAX_ANALYSIS_GAP_SECONDS) { + frame.close(); + continue; + } + await pool.whenAnyIdle(); + if (abort.aborted) { + frame.close(); + break; + } + } + pool.tryAnalyze(frame, t); + lastAnalyzedT = t; + } + await pool.whenIdle(); + await Promise.all(sideWorkRef.current); + if (!abort.aborted) { + setProgress((p) => (p ? { ...p, t: vod.duration } : p)); + setStatus("done"); + await saveVod( + { name: file.name, savedAt: Date.now(), duration: vod.duration }, + matchesRef.current.map((m) => ({ + type: m.event.type, + t: m.event.t, + confidence: m.event.confidence, + data: m.event.data, + thumbnail: m.thumbnail, + frame: m.frame, + })), + ); + await refreshVods(); + } + } catch (e) { + if (!abort.aborted) { + setError(String(e)); + setStatus("error"); + } + } finally { + dispose(); + } + }, + [refreshVods], + ); - const openStored = useCallback(async (name: string) => { - abortRef.current.aborted = true; - try { - const events = await loadVodEvents(name); - const loaded: VodMatch[] = events.map((e) => ({ - event: { - type: e.type, - t: e.t, - confidence: e.confidence, - data: e.data as FixtureData, - }, - thumbnail: e.thumbnail, - frameId: e.hasFrame ? e.id : undefined, - })); - matchesRef.current = loaded; - setMatches(loaded); - setResultsSend(null); - setFileName(name); - setSource("stored"); - setStatus("done"); - setError(null); - setProgress(null); - setGateScore(null); - setMethod(null); - } catch (e) { - setError(String(e)); - } - }, []); + const openStored = useCallback(async (name: string) => { + abortRef.current.aborted = true; + try { + const events = await loadVodEvents(name); + const loaded: VodMatch[] = events.map((e) => ({ + event: { + type: e.type, + t: e.t, + confidence: e.confidence, + data: e.data as FixtureData, + }, + thumbnail: e.thumbnail, + frameId: e.hasFrame ? e.id : undefined, + })); + matchesRef.current = loaded; + setMatches(loaded); + setResultsSend(null); + setFileName(name); + setSource("stored"); + setStatus("done"); + setError(null); + setProgress(null); + setGateScore(null); + setMethod(null); + } catch (e) { + setError(String(e)); + } + }, []); - const removeVod = useCallback( - async (name: string) => { - if (!window.confirm(`Delete saved analysis of "${name}"?`)) return; - await deleteVod(name); - await refreshVods(); - }, - [refreshVods], - ); + const removeVod = useCallback( + async (name: string) => { + if (!window.confirm(`Delete saved analysis of "${name}"?`)) return; + await deleteVod(name); + await refreshVods(); + }, + [refreshVods], + ); - const backToList = useCallback(() => { - abortRef.current.aborted = true; - matchesRef.current = []; - setMatches([]); - setResultsSend(null); - setFileName(null); - setStatus("idle"); - setError(null); - setProgress(null); - setGateScore(null); - setMethod(null); - void refreshVods(); - }, [refreshVods]); + const backToList = useCallback(() => { + abortRef.current.aborted = true; + matchesRef.current = []; + setMatches([]); + setResultsSend(null); + setFileName(null); + setStatus("idle"); + setError(null); + setProgress(null); + setGateScore(null); + setMethod(null); + void refreshVods(); + }, [refreshVods]); - const showVodView = fileName !== null; + const showVodView = fileName !== null; - return ( -
-
{ - e.preventDefault(); - setOver(true); - }} - onDragLeave={() => setOver(false)} - onDrop={(e) => { - e.preventDefault(); - setOver(false); - const file = e.dataTransfer.files[0]; - if (file) void scan(file); - }} - > - Drop a VoD (video file) here, or{" "} - -
-
- {showVodView && ( - <> - - - {source === "stored" ? "saved" : status} - {fileName && ` · ${fileName}`} - {method && ` · ${method}`} - {gateScore !== null && status === "scanning" && ` · gate ${gateScore.toFixed(2)}`} - - {progress && ( - - {formatTime(progress.t)} / {formatTime(progress.duration)} - {progress.duration > 0 && - ` (${Math.round((progress.t / progress.duration) * 100)}%)`} - {progress.rate > 0 && ` · ${progress.rate.toFixed(0)}× realtime`} - - )} - {matches.length > 0 && ( - - {matches.length} match{matches.length === 1 ? "" : "es"} - - )} - {matches.length > 0 && ( - - )} - {SENDOU_UPLOAD_ENABLED && upload?.url && ( - - Upload as VoD - - )} - {SENDOU_UPLOAD_ENABLED && upload?.problem && ( - upload unavailable: {upload.problem} - )} - {SENDOU_UPLOAD_ENABLED && resultsBatchCount > 0 && ( - - )} - {resultsSend && ( - - {resultsSend.state === "sending" - ? `sending match ${Math.min(resultsSend.sent + 1, resultsSend.total)}/${resultsSend.total}…` - : resultsSend.error - ? `sent ${resultsSend.sent}/${resultsSend.total} matches — ${resultsSend.error}` - : `sent ${resultsSend.sent}/${resultsSend.total} matches to sendou.ink`} - - )} - - )} -
- {error &&

{error}

} - {!showVodView && ( -
- {vods.length === 0 && ( -

No saved VoDs yet — scan one and it will show up here.

- )} - {vods.map((vod) => ( -
- {vod.name} - - {vod.eventCount} event{vod.eventCount === 1 ? "" : "s"} · {formatTime(vod.duration)}{" "} - · {new Date(vod.savedAt).toLocaleString()} - - - -
- ))} -
- )} -
-
- -
-
- {matches.length === 0 && ( -

- {status === "scanning" - ? "Scanning — matches appear here as scoreboards are detected." - : "No matches found in this VoD."} -

- )} - {/* newest detection on top; storage keeps ascending video-time order */} - {[...matches].reverse().map((m, i) => { - const getFrame: GetFrame | undefined = m.frame - ? () => Promise.resolve(m.frame) - : m.frameId !== undefined - ? () => loadVodEventFrame(m.frameId!) - : undefined; - return ( - - ); - })} -
-
-
- ); + return ( +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target; the file input inside is the accessible path */} +
{ + e.preventDefault(); + setOver(true); + }} + onDragLeave={() => setOver(false)} + onDrop={(e) => { + e.preventDefault(); + setOver(false); + const file = e.dataTransfer.files[0]; + if (file) void scan(file); + }} + > + Drop a VoD (video file) here, or{" "} + +
+
+ {showVodView && ( + <> + + + {source === "stored" ? "saved" : status} + {fileName && ` · ${fileName}`} + {method && ` · ${method}`} + {gateScore !== null && + status === "scanning" && + ` · gate ${gateScore.toFixed(2)}`} + + {progress && ( + + {formatTime(progress.t)} / {formatTime(progress.duration)} + {progress.duration > 0 && + ` (${Math.round((progress.t / progress.duration) * 100)}%)`} + {progress.rate > 0 && + ` · ${progress.rate.toFixed(0)}× realtime`} + + )} + {matches.length > 0 && ( + + {matches.length} match{matches.length === 1 ? "" : "es"} + + )} + {matches.length > 0 && ( + + )} + {SENDOU_UPLOAD_ENABLED && upload?.url && ( + + Upload as VoD + + )} + {SENDOU_UPLOAD_ENABLED && upload?.problem && ( + + upload unavailable: {upload.problem} + + )} + {SENDOU_UPLOAD_ENABLED && resultsBatchCount > 0 && ( + + )} + {resultsSend && ( + + {resultsSend.state === "sending" + ? `sending match ${Math.min(resultsSend.sent + 1, resultsSend.total)}/${resultsSend.total}…` + : resultsSend.error + ? `sent ${resultsSend.sent}/${resultsSend.total} matches — ${resultsSend.error}` + : `sent ${resultsSend.sent}/${resultsSend.total} matches to sendou.ink`} + + )} + + )} +
+ {error &&

{error}

} + {!showVodView && ( +
+ {vods.length === 0 && ( +

+ No saved VoDs yet — scan one and it will show up here. +

+ )} + {vods.map((vod) => ( +
+ {vod.name} + + {vod.eventCount} event{vod.eventCount === 1 ? "" : "s"} ·{" "} + {formatTime(vod.duration)} ·{" "} + {new Date(vod.savedAt).toLocaleString()} + + + +
+ ))} +
+ )} +
+
+ +
+
+ {matches.length === 0 && ( +

+ {status === "scanning" + ? "Scanning — matches appear here as scoreboards are detected." + : "No matches found in this VoD."} +

+ )} + {/* newest detection on top; storage keeps ascending video-time order */} + {[...matches].reverse().map((m, i) => { + const getFrame: GetFrame | undefined = m.frame + ? () => Promise.resolve(m.frame) + : m.frameId !== undefined + ? () => loadVodEventFrame(m.frameId!) + : undefined; + return ( + + ); + })} +
+
+
+ ); } -function drawPreview(canvas: HTMLCanvasElement | null, frame: ImageBitmap | VideoFrame): void { - if (!canvas) return; - const width = "displayWidth" in frame ? frame.displayWidth : frame.width; - const height = "displayHeight" in frame ? frame.displayHeight : frame.height; - if (canvas.width !== width || canvas.height !== height) { - canvas.width = width; - canvas.height = height; - } - canvas.getContext("2d")!.drawImage(frame, 0, 0); +function drawPreview( + canvas: HTMLCanvasElement | null, + frame: ImageBitmap | VideoFrame, +): void { + if (!canvas) return; + const width = "displayWidth" in frame ? frame.displayWidth : frame.width; + const height = "displayHeight" in frame ? frame.displayHeight : frame.height; + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + canvas.getContext("2d")!.drawImage(frame, 0, 0); } diff --git a/app/features/cv/components/events-csv.ts b/app/features/cv/components/events-csv.ts index f168ed0df..136dedb41 100644 --- a/app/features/cv/components/events-csv.ts +++ b/app/features/cv/components/events-csv.ts @@ -7,194 +7,230 @@ */ import type { MainWeaponId } from "~/modules/in-game-lists/types"; -import { DEATH_EVENT_TYPE, type DeathData } from "../core/detectors/death/index"; -import { MAP_START_EVENT_TYPE, type MapStartData } from "../core/detectors/map-start/index"; -import { MINIMAP_EVENT_TYPE, type MinimapData } from "../core/detectors/minimap/index"; +import { + DEATH_EVENT_TYPE, + type DeathData, +} from "../core/detectors/death/index"; +import { + MAP_START_EVENT_TYPE, + type MapStartData, +} from "../core/detectors/map-start/index"; +import { + MINIMAP_EVENT_TYPE, + type MinimapData, +} from "../core/detectors/minimap/index"; import type { ScoreboardData } from "../core/detectors/scoreboard/index"; import { - SCOREBOARD_OWN_EVENT_TYPE, - type ScoreboardOwnData, + SCOREBOARD_OWN_EVENT_TYPE, + type ScoreboardOwnData, } from "../core/detectors/scoreboard-own/index"; import { - SCOREBOARD_REPLAY_EVENT_TYPE, - type ScoreboardReplayData, + SCOREBOARD_REPLAY_EVENT_TYPE, + type ScoreboardReplayData, } from "../core/detectors/scoreboard-replay/index"; import { formatTime } from "./format"; -import { lobbyLabel, mainWeaponLabel, modeLabel, stageLabel, weaponLabel } from "./labels"; +import { + lobbyLabel, + mainWeaponLabel, + modeLabel, + stageLabel, + weaponLabel, +} from "./labels"; export interface CsvEvent { - type: string; - /** video/stream time in seconds */ - t: number; - /** wall-clock time of detection (live capture only) */ - detectedAt?: number; - confidence: number; - data: unknown; + type: string; + /** video/stream time in seconds */ + t: number; + /** wall-clock time of detection (live capture only) */ + detectedAt?: number; + confidence: number; + data: unknown; } const HEADER = [ - "type", - "time", - "t_seconds", - "detected_at", - "confidence", - "lobby", - "mode", - "stage", - "winner_score", - "loser_score", - "pov", - "weapon", - "name", - "abilities", - "players", - "replay_code", - "replay_timestamp", + "type", + "time", + "t_seconds", + "detected_at", + "confidence", + "lobby", + "mode", + "stage", + "winner_score", + "loser_score", + "pov", + "weapon", + "name", + "abilities", + "players", + "replay_code", + "replay_timestamp", ]; type Cell = string | number | null | undefined; function csvCell(value: Cell): string { - if (value === null || value === undefined) return ""; - const s = String(value); - return /[",\n\r]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s; + if (value === null || value === undefined) return ""; + const s = String(value); + return /[",\n\r]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s; } /** [head, clothes, shoes] rows of [main, sub, sub, sub] ability ids */ function formatAbilities(rows: string[][]): string { - return rows.map((row) => row.join("+")).join(" | "); + return rows.map((row) => row.join("+")).join(" | "); } /** the minimap's flat [head, clothes, shoes] main-ability row */ function formatMinimapAbilities(abilities: (string | null)[]): string { - return abilities.map((a) => a ?? "?").join("+"); + return abilities.map((a) => a ?? "?").join("+"); } function formatMinimapPlayers(data: MinimapData): string { - const fmt = ( - label: string, - p: { name: string | null; weaponId: number | null; abilities: (string | null)[] }, - ) => - `${label} ${p.name ?? "?"} · ${mainWeaponLabel(p.weaponId as MainWeaponId | null) ?? "?"} · ${formatMinimapAbilities(p.abilities)}`; - return [ - ...data.teammates.map((p) => fmt(p.slot, p)), - ...data.enemies.map((p, i) => fmt(`enemy${i + 1}`, p)), - ].join("; "); + const fmt = ( + label: string, + p: { + name: string | null; + weaponId: number | null; + abilities: (string | null)[]; + }, + ) => + `${label} ${p.name ?? "?"} · ${mainWeaponLabel(p.weaponId as MainWeaponId | null) ?? "?"} · ${formatMinimapAbilities(p.abilities)}`; + return [ + ...data.teammates.map((p) => fmt(p.slot, p)), + ...data.enemies.map((p, i) => fmt(`enemy${i + 1}`, p)), + ].join("; "); } function formatPlayers(data: ScoreboardData): string { - return data.players - .map( - (p, i) => - `${i < 4 ? "W" : "L"} ${p.name} · ${p.weaponId ?? "?"} · ${p.paint ?? "?"}p ` + - `${p.ka ?? "?"}/${p.d ?? "?"}/${p.s ?? "?"}`, - ) - .join("; "); + return data.players + .map( + (p, i) => + `${i < 4 ? "W" : "L"} ${p.name} · ${p.weaponId ?? "?"} · ${p.paint ?? "?"}p ` + + `${p.ka ?? "?"}/${p.d ?? "?"}/${p.s ?? "?"}`, + ) + .join("; "); } function eventCells(event: CsvEvent): Cell[] { - const base: Cell[] = [ - event.type, - formatTime(event.t), - Math.round(event.t * 1000) / 1000, - event.detectedAt === undefined ? "" : new Date(event.detectedAt).toISOString(), - Math.round(event.confidence * 1000) / 1000, - ]; - switch (event.type) { - case DEATH_EVENT_TYPE: { - const d = event.data as DeathData; - return [ - ...base, - "", - "", - "", - "", - "", - "", - weaponLabel(d.weaponType, d.weaponId), - d.name, - formatAbilities(d.abilities), - "", - "", - "", - ]; - } - case MAP_START_EVENT_TYPE: { - const d = event.data as MapStartData; - return [...base, "", modeLabel(d.mode), stageLabel(d.stage), "", "", "", "", "", "", "", "", ""]; - } - case SCOREBOARD_OWN_EVENT_TYPE: { - const d = event.data as ScoreboardOwnData; - return [ - ...base, - lobbyLabel(d.lobby), - modeLabel(d.mode), - stageLabel(d.stage), - "", - "", - "", - mainWeaponLabel(d.weaponId), - "", - formatAbilities(d.abilities), - "", - "", - "", - ]; - } - case MINIMAP_EVENT_TYPE: { - const d = event.data as MinimapData; - const self = d.teammates.find((p) => p.slot === "self"); - return [ - ...base, - "", // lobby - "", // mode (not readable from the minimap) - stageLabel(d.stage), // stage (planner-signature match) - "", // winner_score - "", // loser_score - self?.name, - self ? mainWeaponLabel(self.weaponId) : "", - "", - self ? formatMinimapAbilities(self.abilities) : "", - formatMinimapPlayers(d), - "", - "", - ]; - } - default: { - // Scoreboard and ScoreboardReplay share the base shape - const d = event.data as ScoreboardData & Partial; - const isReplay = event.type === SCOREBOARD_REPLAY_EVENT_TYPE; - return [ - ...base, - lobbyLabel(d.lobby), - modeLabel(d.mode), - stageLabel(d.stage), - d.scores[0], - d.scores[1], - d.povIndex === null ? "" : d.players[d.povIndex]?.name, - "", - "", - "", - formatPlayers(d), - isReplay ? d.replayCode : "", - isReplay ? d.timestamp : "", - ]; - } - } + const base: Cell[] = [ + event.type, + formatTime(event.t), + Math.round(event.t * 1000) / 1000, + event.detectedAt === undefined + ? "" + : new Date(event.detectedAt).toISOString(), + Math.round(event.confidence * 1000) / 1000, + ]; + switch (event.type) { + case DEATH_EVENT_TYPE: { + const d = event.data as DeathData; + return [ + ...base, + "", + "", + "", + "", + "", + "", + weaponLabel(d.weaponType, d.weaponId), + d.name, + formatAbilities(d.abilities), + "", + "", + "", + ]; + } + case MAP_START_EVENT_TYPE: { + const d = event.data as MapStartData; + return [ + ...base, + "", + modeLabel(d.mode), + stageLabel(d.stage), + "", + "", + "", + "", + "", + "", + "", + "", + "", + ]; + } + case SCOREBOARD_OWN_EVENT_TYPE: { + const d = event.data as ScoreboardOwnData; + return [ + ...base, + lobbyLabel(d.lobby), + modeLabel(d.mode), + stageLabel(d.stage), + "", + "", + "", + mainWeaponLabel(d.weaponId), + "", + formatAbilities(d.abilities), + "", + "", + "", + ]; + } + case MINIMAP_EVENT_TYPE: { + const d = event.data as MinimapData; + const self = d.teammates.find((p) => p.slot === "self"); + return [ + ...base, + "", // lobby + "", // mode (not readable from the minimap) + stageLabel(d.stage), // stage (planner-signature match) + "", // winner_score + "", // loser_score + self?.name, + self ? mainWeaponLabel(self.weaponId) : "", + "", + self ? formatMinimapAbilities(self.abilities) : "", + formatMinimapPlayers(d), + "", + "", + ]; + } + default: { + // Scoreboard and ScoreboardReplay share the base shape + const d = event.data as ScoreboardData & Partial; + const isReplay = event.type === SCOREBOARD_REPLAY_EVENT_TYPE; + return [ + ...base, + lobbyLabel(d.lobby), + modeLabel(d.mode), + stageLabel(d.stage), + d.scores[0], + d.scores[1], + d.povIndex === null ? "" : d.players[d.povIndex]?.name, + "", + "", + "", + formatPlayers(d), + isReplay ? d.replayCode : "", + isReplay ? d.timestamp : "", + ]; + } + } } function eventsToCsv(events: CsvEvent[]): string { - const lines = [HEADER.join(",")]; - for (const event of events) lines.push(eventCells(event).map(csvCell).join(",")); - return `${lines.join("\r\n")}\r\n`; + const lines = [HEADER.join(",")]; + for (const event of events) + lines.push(eventCells(event).map(csvCell).join(",")); + return `${lines.join("\r\n")}\r\n`; } export function downloadEventsCsv(filename: string, events: CsvEvent[]): void { - const blob = new Blob([eventsToCsv(events)], { type: "text/csv" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); + const blob = new Blob([eventsToCsv(events)], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); } diff --git a/app/features/cv/components/fixture-export.ts b/app/features/cv/components/fixture-export.ts index 302940b3f..1d8cef22e 100644 --- a/app/features/cv/components/fixture-export.ts +++ b/app/features/cv/components/fixture-export.ts @@ -4,190 +4,232 @@ * fixture is review-and-correct instead of data entry. */ -import { DEATH_EVENT_TYPE, type DeathData } from "../core/detectors/death/index"; -import { mainWeaponLabel, stageLabel, weaponLabel } from "./labels"; -import { MAP_START_EVENT_TYPE, type MapStartData } from "../core/detectors/map-start/index"; -import { MINIMAP_EVENT_TYPE, type MinimapData } from "../core/detectors/minimap/index"; +import { + DEATH_EVENT_TYPE, + type DeathData, +} from "../core/detectors/death/index"; +import { + MAP_START_EVENT_TYPE, + type MapStartData, +} from "../core/detectors/map-start/index"; +import { + MINIMAP_EVENT_TYPE, + type MinimapData, +} from "../core/detectors/minimap/index"; import type { ScoreboardData } from "../core/detectors/scoreboard/index"; import { - SCOREBOARD_OWN_EVENT_TYPE, - type ScoreboardOwnData, + SCOREBOARD_OWN_EVENT_TYPE, + type ScoreboardOwnData, } from "../core/detectors/scoreboard-own/index"; import type { ScoreboardReplayData } from "../core/detectors/scoreboard-replay/index"; +import { mainWeaponLabel, stageLabel, weaponLabel } from "./labels"; /** Scoreboard data with the replay extras present when the event has them. */ export type CardData = ScoreboardData & - Partial>; + Partial< + Pick + >; /** Any detector's event payload that can prefill a fixture. */ -export type FixtureData = CardData | DeathData | MapStartData | ScoreboardOwnData | MinimapData; +export type FixtureData = + | CardData + | DeathData + | MapStartData + | ScoreboardOwnData + | MinimapData; function isDeath(_data: FixtureData, eventType: string): _data is DeathData { - return eventType === DEATH_EVENT_TYPE; + return eventType === DEATH_EVENT_TYPE; } function download(name: string, blob: Blob): void { - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = name; - a.click(); - URL.revokeObjectURL(url); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + URL.revokeObjectURL(url); } -function buildExpectedJson(data: FixtureData | null, eventType = "Scoreboard"): string { - if (!data) { - return `${JSON.stringify({ event: "none" }, null, 2)}\n`; - } - if (isDeath(data, eventType)) { - const label = weaponLabel(data.weaponType, data.weaponId); - return `${JSON.stringify( - { - event: eventType, - data: { - ...(label !== null && { weaponLabel: label }), - ...(data.weaponId !== null && { weaponId: data.weaponId }), - ...(data.weaponType !== null && { weaponType: data.weaponType }), - abilities: data.abilities, - ...(data.name !== null && { name: data.name }), - }, - }, - null, - 2, - )}\n`; - } - if (eventType === SCOREBOARD_OWN_EVENT_TYPE) { - const own = data as ScoreboardOwnData; - return `${JSON.stringify( - { - event: eventType, - data: { - ...(own.lobby !== null && { lobby: own.lobby }), - ...(own.mode !== null && { mode: own.mode }), - ...(own.stage !== null && { stage: own.stage, stageLabel: stageLabel(own.stage) }), - ...(own.weaponId !== null && { - weaponLabel: mainWeaponLabel(own.weaponId), - weaponId: own.weaponId, - }), - abilities: own.abilities, - }, - }, - null, - 2, - )}\n`; - } - if (eventType === MINIMAP_EVENT_TYPE) { - const minimap = data as MinimapData; - return `${JSON.stringify( - { - event: eventType, - data: { - ...(minimap.stage !== null && { - stage: minimap.stage, - stageLabel: stageLabel(minimap.stage), - }), - ...(minimap.spectator && { spectator: true }), - teammates: minimap.teammates.map((p) => ({ - slot: p.slot, - name: p.name, - weaponLabel: mainWeaponLabel(p.weaponId), - weaponId: p.weaponId, - abilities: p.abilities, - })), - enemies: minimap.enemies.map((p) => ({ - ...(minimap.spectator && { name: p.name }), - weaponLabel: mainWeaponLabel(p.weaponId), - weaponId: p.weaponId, - abilities: p.abilities, - })), - }, - }, - 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) { - const mapStart = data as MapStartData; - return `${JSON.stringify( - { - event: eventType, - data: { - ...(mapStart.mode !== null && { mode: mapStart.mode }), - ...(mapStart.stage !== null && { - stage: mapStart.stage, - stageLabel: stageLabel(mapStart.stage), - }), - }, - }, - null, - 2, - )}\n`; - } - const card = data as CardData; - return `${JSON.stringify( - { - event: eventType, - data: { - ...(card.lobby !== null && { lobby: card.lobby }), - ...(card.mode !== null && { mode: card.mode }), - ...(card.stage !== null && { stage: card.stage, stageLabel: stageLabel(card.stage) }), - ...(card.timestamp != null && { timestamp: card.timestamp }), - ...(card.replayCode != null && { replayCode: card.replayCode }), - scores: card.scores, - ...(card.matchScores !== undefined && { matchScores: card.matchScores }), - players: card.players.map((p) => ({ - name: p.name, - weaponId: p.weaponId, - paint: p.paint, - ka: p.ka, - d: p.d, - s: p.s, - })), - }, - }, - null, - 2, - )}\n`; +function buildExpectedJson( + data: FixtureData | null, + eventType = "Scoreboard", +): string { + if (!data) { + return `${JSON.stringify({ event: "none" }, null, 2)}\n`; + } + if (isDeath(data, eventType)) { + const label = weaponLabel(data.weaponType, data.weaponId); + return `${JSON.stringify( + { + event: eventType, + data: { + ...(label !== null && { weaponLabel: label }), + ...(data.weaponId !== null && { weaponId: data.weaponId }), + ...(data.weaponType !== null && { weaponType: data.weaponType }), + abilities: data.abilities, + ...(data.name !== null && { name: data.name }), + }, + }, + null, + 2, + )}\n`; + } + if (eventType === SCOREBOARD_OWN_EVENT_TYPE) { + const own = data as ScoreboardOwnData; + return `${JSON.stringify( + { + event: eventType, + data: { + ...(own.lobby !== null && { lobby: own.lobby }), + ...(own.mode !== null && { mode: own.mode }), + ...(own.stage !== null && { + stage: own.stage, + stageLabel: stageLabel(own.stage), + }), + ...(own.weaponId !== null && { + weaponLabel: mainWeaponLabel(own.weaponId), + weaponId: own.weaponId, + }), + abilities: own.abilities, + }, + }, + null, + 2, + )}\n`; + } + if (eventType === MINIMAP_EVENT_TYPE) { + const minimap = data as MinimapData; + return `${JSON.stringify( + { + event: eventType, + data: { + ...(minimap.stage !== null && { + stage: minimap.stage, + stageLabel: stageLabel(minimap.stage), + }), + ...(minimap.spectator && { spectator: true }), + teammates: minimap.teammates.map((p) => ({ + slot: p.slot, + name: p.name, + weaponLabel: mainWeaponLabel(p.weaponId), + weaponId: p.weaponId, + abilities: p.abilities, + })), + enemies: minimap.enemies.map((p) => ({ + ...(minimap.spectator && { name: p.name }), + weaponLabel: mainWeaponLabel(p.weaponId), + weaponId: p.weaponId, + abilities: p.abilities, + })), + }, + }, + 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) { + const mapStart = data as MapStartData; + return `${JSON.stringify( + { + event: eventType, + data: { + ...(mapStart.mode !== null && { mode: mapStart.mode }), + ...(mapStart.stage !== null && { + stage: mapStart.stage, + stageLabel: stageLabel(mapStart.stage), + }), + }, + }, + null, + 2, + )}\n`; + } + const card = data as CardData; + return `${JSON.stringify( + { + event: eventType, + data: { + ...(card.lobby !== null && { lobby: card.lobby }), + ...(card.mode !== null && { mode: card.mode }), + ...(card.stage !== null && { + stage: card.stage, + stageLabel: stageLabel(card.stage), + }), + ...(card.timestamp != null && { timestamp: card.timestamp }), + ...(card.replayCode != null && { replayCode: card.replayCode }), + scores: card.scores, + ...(card.matchScores !== undefined && { + matchScores: card.matchScores, + }), + players: card.players.map((p) => ({ + name: p.name, + weaponId: p.weaponId, + paint: p.paint, + ka: p.ka, + d: p.d, + s: p.s, + })), + }, + }, + null, + 2, + )}\n`; } /** * expected.json alone, for a frame the user already has on disk (Screenshot * page). Null data produces the negative-fixture form. */ -export function downloadExpectedJson(data: FixtureData | null, eventType?: string): void { - download( - "expected.json", - new Blob([buildExpectedJson(data, eventType)], { type: "application/json" }), - ); +export function downloadExpectedJson( + data: FixtureData | null, + eventType?: string, +): void { + download( + "expected.json", + new Blob([buildExpectedJson(data, eventType)], { + type: "application/json", + }), + ); } /** * Fixture export for a live detection: the stored PNG is the byte-exact * frame the detector analyzed, paired with that event's own parse output. */ -export function saveFixtureFromEvent(frame: Blob, data: FixtureData, eventType: string): void { - download("frame.png", frame); - download( - "expected.json", - new Blob([buildExpectedJson(data, eventType)], { type: "application/json" }), - ); +export function saveFixtureFromEvent( + frame: Blob, + data: FixtureData, + eventType: string, +): void { + download("frame.png", frame); + download( + "expected.json", + new Blob([buildExpectedJson(data, eventType)], { + type: "application/json", + }), + ); } export async function saveFixture( - video: HTMLVideoElement, - latest: { type: string; data: FixtureData } | null, + video: HTMLVideoElement, + latest: { type: string; data: FixtureData } | null, ): Promise { - const canvas = document.createElement("canvas"); - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - canvas.getContext("2d")!.drawImage(video, 0, 0); - const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); - if (!blob) throw new Error("could not encode frame"); - download("frame.png", blob); - download( - "expected.json", - new Blob([buildExpectedJson(latest?.data ?? null, latest?.type)], { type: "application/json" }), - ); + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + canvas.getContext("2d")!.drawImage(video, 0, 0); + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, "image/png"), + ); + if (!blob) throw new Error("could not encode frame"); + download("frame.png", blob); + download( + "expected.json", + new Blob([buildExpectedJson(latest?.data ?? null, latest?.type)], { + type: "application/json", + }), + ); } diff --git a/app/features/cv/components/format.ts b/app/features/cv/components/format.ts index 5a5e8ac81..ac7669e3e 100644 --- a/app/features/cv/components/format.ts +++ b/app/features/cv/components/format.ts @@ -1,7 +1,7 @@ /** hh:mm:ss for feed rows, CSV cells, and scan progress. */ export function formatTime(t: number): string { - const h = Math.floor(t / 3600); - const m = Math.floor((t % 3600) / 60); - const s = Math.floor(t % 60); - return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; + const h = Math.floor(t / 3600); + const m = Math.floor((t % 3600) / 60); + const s = Math.floor(t % 60); + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; } diff --git a/app/features/cv/components/labels.ts b/app/features/cv/components/labels.ts index 7fb7f9cb6..e645722f7 100644 --- a/app/features/cv/components/labels.ts +++ b/app/features/cv/components/labels.ts @@ -4,7 +4,7 @@ * into human-readable names for cards, CSV export, and the fixture * exporter's informational *Label fields. */ -import gameMisc from "../../../../locales/en/game-misc.json"; + import type { MainWeaponId, ModeShort, @@ -12,8 +12,12 @@ import type { StageId, SubWeaponId, } from "~/modules/in-game-lists/types"; +import gameMisc from "../../../../locales/en/game-misc.json"; +import { + ALL_WEAPON_ENTRIES, + type WeaponType, +} from "../core/detectors/death/weapon-names"; import type { CvLobby } from "../cv-types"; -import { ALL_WEAPON_ENTRIES, type WeaponType } from "../core/detectors/death/weapon-names"; const misc = gameMisc as Record; @@ -34,7 +38,9 @@ export function mainWeaponLabel(id: MainWeaponId | null): string | null { } export function stageLabel(stageId: StageId | null): string | null { - return stageId === null ? null : (misc[`STAGE_${stageId}`] ?? String(stageId)); + return stageId === null + ? null + : (misc[`STAGE_${stageId}`] ?? String(stageId)); } export function modeLabel(mode: ModeShort | null): string | null { diff --git a/app/features/cv/components/screenshot-handoff.ts b/app/features/cv/components/screenshot-handoff.ts index 77ad3861b..fab20714e 100644 --- a/app/features/cv/components/screenshot-handoff.ts +++ b/app/features/cv/components/screenshot-handoff.ts @@ -5,11 +5,11 @@ let pending: Blob | null = null; export function setScreenshotFrame(frame: Blob): void { - pending = frame; + pending = frame; } export function takeScreenshotFrame(): Blob | null { - const frame = pending; - pending = null; - return frame; + const frame = pending; + pending = null; + return frame; } diff --git a/app/features/cv/components/sendou-ingest.ts b/app/features/cv/components/sendou-ingest.ts index 8937fb459..8f759d78c 100644 --- a/app/features/cv/components/sendou-ingest.ts +++ b/app/features/cv/components/sendou-ingest.ts @@ -22,13 +22,13 @@ const INGEST_URL = "/ingest"; const MAX_EVENTS_PER_REQUEST = 1000; export interface SendouUser { - id: number; - username: string; + id: number; + username: string; } export interface SendResult { - sentBatches: number; - failedBatches: number; + sentBatches: number; + failedBatches: number; } /** @@ -40,45 +40,47 @@ export interface SendResult { * scoreboards first-ingest-wins, so a retry always re-sends its whole batch. */ export async function sendBatches({ - events, - include, - onStatus, + events, + include, + onStatus, }: { - events: readonly StoredEvent[]; - include: (batch: StoredEvent[]) => boolean; - onStatus: () => void; + events: readonly StoredEvent[]; + include: (batch: StoredEvent[]) => boolean; + onStatus: () => void; }): Promise { - const allBatches = buildIngestBatches(events.filter((e) => e.id !== undefined)); - const batches = allBatches.filter(include); - await clearOrphanedQueued(events, allBatches); + const allBatches = buildIngestBatches( + events.filter((e) => e.id !== undefined), + ); + const batches = allBatches.filter(include); + await clearOrphanedQueued(events, allBatches); - const result: SendResult = { sentBatches: 0, failedBatches: 0 }; - for (const batch of batches) { - const ids = batch.map((e) => e.id!); - await updateEventsSend(ids, { state: "sending", at: Date.now() }); - onStatus(); - try { - await postIngestBatch(batch); - await updateEventsSend(ids, { state: "sent", at: Date.now() }); - result.sentBatches++; - } catch (err) { - await updateEventsSend(ids, { - state: "failed", - at: Date.now(), - error: err instanceof Error ? err.message : String(err), - }); - result.failedBatches++; - } - onStatus(); - } - return result; + const result: SendResult = { sentBatches: 0, failedBatches: 0 }; + for (const batch of batches) { + const ids = batch.map((e) => e.id!); + await updateEventsSend(ids, { state: "sending", at: Date.now() }); + onStatus(); + try { + await postIngestBatch(batch); + await updateEventsSend(ids, { state: "sent", at: Date.now() }); + result.sentBatches++; + } catch (err) { + await updateEventsSend(ids, { + state: "failed", + at: Date.now(), + error: err instanceof Error ? err.message : String(err), + }); + result.failedBatches++; + } + onStatus(); + } + return result; } export interface VodResultsSendReport { - sentBatches: number; - totalBatches: number; - /** last failure's message; null when every batch went through */ - error: string | null; + sentBatches: number; + totalBatches: number; + /** last failure's message; null when every batch went through */ + error: string | null; } /** @@ -96,41 +98,47 @@ export interface VodResultsSendReport { * replay scoreboard's own `recordedAt` where present). */ export async function sendVodResults( - events: readonly DetectedEvent[], - onProgress?: (sentBatches: number, totalBatches: number) => void, + events: readonly DetectedEvent[], + onProgress?: (sentBatches: number, totalBatches: number) => void, ): Promise { - const batches = buildIngestBatches(events); - const requests = chunkIngestBatches(batches, MAX_EVENTS_PER_REQUEST); - const detectedAt = Date.now(); + const batches = buildIngestBatches(events); + const requests = chunkIngestBatches(batches, MAX_EVENTS_PER_REQUEST); + const detectedAt = Date.now(); - let sentBatches = 0; - let error: string | null = null; - for (const request of requests) { - try { - await postIngestBatch(request.flat().map((e) => ({ ...e, detectedAt }))); - sentBatches += request.length; - onProgress?.(sentBatches, batches.length); - } catch (err) { - error = err instanceof Error ? err.message : String(err); - } - } - return { sentBatches, totalBatches: batches.length, error }; + let sentBatches = 0; + let error: string | null = null; + for (const request of requests) { + try { + await postIngestBatch(request.flat().map((e) => ({ ...e, detectedAt }))); + sentBatches += request.length; + onProgress?.(sentBatches, batches.length); + } catch (err) { + error = err instanceof Error ? err.message : String(err); + } + } + return { sentBatches, totalBatches: batches.length, error }; } /** The number of /ingest match batches a set of events would produce. */ export function countIngestBatches(events: readonly DetectedEvent[]): number { - return buildIngestBatches(events).length; + return buildIngestBatches(events).length; } -async function postIngestBatch(batch: Array) { - const res = await fetch(INGEST_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ events: batch.slice(0, MAX_EVENTS_PER_REQUEST).map(payloadEvent) }), - }); - if (!res.ok) { - throw new Error(res.status === 401 ? "not logged in to sendou.ink" : await errorText(res)); - } +async function postIngestBatch( + batch: Array, +) { + const res = await fetch(INGEST_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + events: batch.slice(0, MAX_EVENTS_PER_REQUEST).map(payloadEvent), + }), + }); + if (!res.ok) { + throw new Error( + res.status === 401 ? "not logged in to sendou.ink" : await errorText(res), + ); + } } /** @@ -140,59 +148,69 @@ async function postIngestBatch(batch: Array { - const lastBoundaryT = Math.max(...allBatches.map((b) => b.at(-1)!.t), Number.NEGATIVE_INFINITY); - const batchedIds = new Set(allBatches.flat().map((e) => e.id)); - const orphaned = events - .filter( - (e) => - e.send?.state === "queued" && - e.id !== undefined && - !batchedIds.has(e.id) && - e.t <= lastBoundaryT, - ) - .map((e) => e.id!); - if (orphaned.length > 0) await updateEventsSend(orphaned, undefined); + const lastBoundaryT = Math.max( + ...allBatches.map((b) => b.at(-1)!.t), + Number.NEGATIVE_INFINITY, + ); + const batchedIds = new Set(allBatches.flat().map((e) => e.id)); + const orphaned = events + .filter( + (e) => + e.send?.state === "queued" && + e.id !== undefined && + !batchedIds.has(e.id) && + e.t <= lastBoundaryT, + ) + .map((e) => e.id!); + if (orphaned.length > 0) await updateEventsSend(orphaned, undefined); } /** Batch selector: the batch that carries the given stored event. */ export function batchContaining(id: number): (batch: StoredEvent[]) => boolean { - return (batch) => batch.some((e) => e.id === id); + return (batch) => batch.some((e) => e.id === id); } /** Batch selector: batches not yet sent (nor currently sending). */ export function unsentBatches(batch: StoredEvent[]): boolean { - return !batch.some((e) => e.send?.state === "sent" || e.send?.state === "sending"); + return !batch.some( + (e) => e.send?.state === "sent" || e.send?.state === "sending", + ); } function payloadEvent(event: DetectedEvent & { detectedAt?: number }) { - const recordedAt = - event.type === SCOREBOARD_REPLAY_EVENT_TYPE - ? replayRecordedAt(event.data as { timestamp: string | null }, event.detectedAt) - : null; - return { - type: event.type, - t: event.t, - detectedAt: event.detectedAt, - confidence: event.confidence, - data: event.data, // worker events are persisted without debug - ...(recordedAt !== null ? { recordedAt } : null), - }; + const recordedAt = + event.type === SCOREBOARD_REPLAY_EVENT_TYPE + ? replayRecordedAt( + event.data as { timestamp: string | null }, + event.detectedAt, + ) + : null; + return { + type: event.type, + t: event.t, + detectedAt: event.detectedAt, + confidence: event.confidence, + data: event.data, // worker events are persisted without debug + ...(recordedAt !== null ? { recordedAt } : null), + }; } /** The replay's recording time as UTC ms, from the on-screen timestamp. */ function replayRecordedAt( - data: { timestamp: string | null }, - detectedAt: number | undefined, + data: { timestamp: string | null }, + detectedAt: number | undefined, ): number | null { - // anchor the day/month recency disambiguation to when the replay screen - // was seen, not to a possibly much later send/retry - return data.timestamp ? parseReplayTimestamp(data.timestamp, { now: detectedAt }) : null; + // anchor the day/month recency disambiguation to when the replay screen + // was seen, not to a possibly much later send/retry + return data.timestamp + ? parseReplayTimestamp(data.timestamp, { now: detectedAt }) + : null; } async function errorText(res: Response): Promise { - const text = await res.text().catch(() => ""); - return `POST /ingest -> ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`; + const text = await res.text().catch(() => ""); + return `POST /ingest -> ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`; } diff --git a/app/features/cv/components/sendou-upload.ts b/app/features/cv/components/sendou-upload.ts index a5d8ad903..1b39532ac 100644 --- a/app/features/cv/components/sendou-upload.ts +++ b/app/features/cv/components/sendou-upload.ts @@ -10,7 +10,10 @@ * type untouched. */ import JSONCrush from "jsoncrush"; -import { MINIMAP_EVENT_TYPE, type MinimapData } from "../core/detectors/minimap/index"; +import { + MINIMAP_EVENT_TYPE, + type MinimapData, +} from "../core/detectors/minimap/index"; import type { DetectedEvent } from "../core/detectors/types"; import { buildVodMatches } from "../core/vod-matches"; @@ -25,32 +28,34 @@ const VODS_NEW_PATH = "/vods/new"; const MAX_URL_LENGTH = 8000; export interface SendouUpload { - /** prefilled /vods/new path (same-origin); null when nothing usable to send */ - url: string | null; - /** set when matches exist but no usable URL could be built */ - problem: string | null; + /** prefilled /vods/new path (same-origin); null when nothing usable to send */ + url: string | null; + /** set when matches exist but no usable URL could be built */ + problem: string | null; } /** Builds the prefilled /vods/new link for a completed scan's events. */ export function sendouUpload(events: readonly DetectedEvent[]): SendouUpload { - const matches = buildVodMatches(events); - if (matches.length === 0) return { url: null, problem: null }; + const matches = buildVodMatches(events); + if (matches.length === 0) return { url: null, problem: null }; - const isCast = events.some( - (event) => event.type === MINIMAP_EVENT_TYPE && (event.data as MinimapData).spectator, - ); + const isCast = events.some( + (event) => + event.type === MINIMAP_EVENT_TYPE && + (event.data as MinimapData).spectator, + ); - const payload = isCast ? { type: "CAST", matches } : { matches }; - const params = new URLSearchParams(); - params.set("ingest", JSONCrush.crush(JSON.stringify(payload))); - const result = `${VODS_NEW_PATH}?${params.toString()}`; - if (result.length > MAX_URL_LENGTH) { - return { - url: null, - problem: - `prefill URL is ${result.length} chars (limit ~${MAX_URL_LENGTH}) — ` + - `too many matches for a GET query param.`, - }; - } - return { url: result, problem: null }; + const payload = isCast ? { type: "CAST", matches } : { matches }; + const params = new URLSearchParams(); + params.set("ingest", JSONCrush.crush(JSON.stringify(payload))); + const result = `${VODS_NEW_PATH}?${params.toString()}`; + if (result.length > MAX_URL_LENGTH) { + return { + url: null, + problem: + `prefill URL is ${result.length} chars (limit ~${MAX_URL_LENGTH}) — ` + + "too many matches for a GET query param.", + }; + } + return { url: result, problem: null }; } diff --git a/app/features/cv/components/styles.css b/app/features/cv/components/styles.css index 910fa08d1..5fa8bc206 100644 --- a/app/features/cv/components/styles.css +++ b/app/features/cv/components/styles.css @@ -4,527 +4,528 @@ leaks into the host app; design tokens come from sendou.ink's own app/styles/vars.css (the emberz copies of those tokens were dropped). */ .cv-app .app { - max-width: 1400px; - margin: 0 auto; - padding: 0 16px 48px; + max-width: 1400px; + margin: 0 auto; + padding: 0 16px 48px; } .cv-app .topbar { - display: flex; - align-items: center; - gap: 24px; - padding: 12px 0; - border-bottom: var(--border-width) solid var(--color-bg-high); - margin-bottom: 16px; + display: flex; + align-items: center; + gap: 24px; + padding: 12px 0; + border-bottom: var(--border-width) solid var(--color-bg-high); + margin-bottom: 16px; } /* Site title ported from sendou.ink (app/components/layout/index.module.css) */ .cv-app .site-title { - display: flex; - align-items: center; - gap: 8px; + display: flex; + align-items: center; + gap: 8px; } .cv-app .site-logo { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - width: 40px; - height: 36px; - background-color: var(--color-text-accent); - border-radius: var(--radius-field); - font-weight: var(--weight-bold); - color: var(--color-text-inverse); - text-decoration: none; - flex-shrink: 0; - transition: background-color 0.2s; - line-height: 1; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + width: 40px; + height: 36px; + background-color: var(--color-text-accent); + border-radius: var(--radius-field); + font-weight: var(--weight-bold); + color: var(--color-text-inverse); + text-decoration: none; + flex-shrink: 0; + transition: background-color 0.2s; + line-height: 1; } .cv-app .site-logo:focus-visible { - outline: var(--focus-ring); - outline-offset: 1px; + outline: var(--focus-ring); + outline-offset: 1px; } .cv-app .site-logo-s { - font-size: 14px; - position: relative; - top: -4px; + font-size: 14px; + position: relative; + top: -4px; } .cv-app .site-logo-ink { - font-size: 12px; - position: relative; - bottom: -4px; + font-size: 12px; + position: relative; + bottom: -4px; } .cv-app .site-title-separator { - font-size: var(--font-xs); - color: var(--color-text-high); - font-weight: var(--weight-bold); - opacity: 0.5; + font-size: var(--font-xs); + color: var(--color-text-high); + font-weight: var(--weight-bold); + opacity: 0.5; } .cv-app .topbar h1 { - font-size: var(--font-xs); - font-weight: var(--weight-semi); - margin: 0; + font-size: var(--font-xs); + font-weight: var(--weight-semi); + margin: 0; } .cv-app .topbar nav { - display: flex; - gap: 8px; + display: flex; + gap: 8px; +} + +.cv-app .topbar-user a { + color: var(--color-text-high); + font-weight: var(--weight-bold); + text-decoration: none; + padding: 4px 10px; + border-radius: var(--radius-field); + background: var(--color-bg-high); +} + +.cv-app .topbar-user a:hover { + color: var(--color-text); +} + +.cv-app .topbar-user a:focus-visible { + outline: var(--focus-ring); + outline-offset: 1px; } .cv-app .topbar nav a { - color: var(--color-text-high); - font-size: var(--font-xs); - font-weight: var(--weight-bold); - text-decoration: none; - padding: 4px 10px; - border-radius: var(--radius-field); + color: var(--color-text-high); + font-size: var(--font-xs); + font-weight: var(--weight-bold); + text-decoration: none; + padding: 4px 10px; + border-radius: var(--radius-field); } .cv-app .topbar nav a:hover { - color: var(--color-text); + color: var(--color-text); } .cv-app .topbar nav a:focus-visible { - outline: var(--focus-ring); - outline-offset: 1px; + outline: var(--focus-ring); + outline-offset: 1px; } .cv-app .topbar nav a.active { - color: var(--color-text-accent); - background: var(--color-bg-high); + color: var(--color-text-accent); + background: var(--color-bg-high); } /* sendou.ink login info, pinned to the header's right edge */ .cv-app .topbar-user { - margin-left: auto; - font-size: var(--font-xs); - font-weight: var(--weight-semi); + margin-left: auto; + font-size: var(--font-xs); + font-weight: var(--weight-semi); } .cv-app .topbar-username { - color: var(--color-text-accent); + color: var(--color-text-accent); } .cv-app .topbar-user-offline { - color: var(--color-text-high); - opacity: 0.7; -} - -.cv-app .topbar-user a { - color: var(--color-text-high); - font-weight: var(--weight-bold); - text-decoration: none; - padding: 4px 10px; - border-radius: var(--radius-field); - background: var(--color-bg-high); -} - -.cv-app .topbar-user a:hover { - color: var(--color-text); -} - -.cv-app .topbar-user a:focus-visible { - outline: var(--focus-ring); - outline-offset: 1px; + color: var(--color-text-high); + opacity: 0.7; } .cv-app button { - display: inline-flex; - align-items: center; - justify-content: center; - border: var(--border-style-accent); - border-radius: var(--radius-field); - appearance: none; - background: var(--color-text-accent); - color: var(--color-text-inverse); - cursor: pointer; - font-family: inherit; - font-size: var(--font-xs); - font-weight: var(--weight-bold); - padding: 0 var(--field-padding); - height: var(--field-size-sm); - white-space: nowrap; - user-select: none; - outline-color: var(--color-text-accent); + display: inline-flex; + align-items: center; + justify-content: center; + border: var(--border-style-accent); + border-radius: var(--radius-field); + appearance: none; + background: var(--color-text-accent); + color: var(--color-text-inverse); + cursor: pointer; + font-family: inherit; + font-size: var(--font-xs); + font-weight: var(--weight-bold); + padding: 0 var(--field-padding); + height: var(--field-size-sm); + white-space: nowrap; + user-select: none; + outline-color: var(--color-text-accent); } .cv-app button:focus-visible { - outline-style: solid; - outline-width: 2px; - outline-offset: 1px; + outline-style: solid; + outline-width: 2px; + outline-offset: 1px; } .cv-app button:active { - transform: translateY(1px); + transform: translateY(1px); } .cv-app button:disabled { - cursor: not-allowed; - opacity: 0.5; - transform: initial; + cursor: not-allowed; + opacity: 0.5; + transform: initial; } .cv-app select { - appearance: none; - border: var(--border-style); - border-radius: var(--radius-field); - background-color: var(--color-bg); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(145, 145, 145)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); - background-position: center right var(--field-padding); - background-size: 18px auto; - background-repeat: no-repeat; - color: var(--color-text); - cursor: pointer; - font-family: inherit; - font-size: var(--font-xs); - height: var(--field-size-sm); - padding: 0 calc(18px + var(--field-padding) + 0.5rem) 0 var(--field-padding); - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - outline: none; + appearance: none; + border: var(--border-style); + border-radius: var(--radius-field); + background-color: var(--color-bg); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(145, 145, 145)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-position: center right var(--field-padding); + background-size: 18px auto; + background-repeat: no-repeat; + color: var(--color-text); + cursor: pointer; + font-family: inherit; + font-size: var(--font-xs); + height: var(--field-size-sm); + padding: 0 calc(18px + var(--field-padding) + 0.5rem) 0 var(--field-padding); + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + outline: none; } .cv-app select:focus-within { - outline: var(--focus-ring); - outline-offset: 1px; + outline: var(--focus-ring); + outline-offset: 1px; } .cv-app select:disabled { - cursor: not-allowed; - opacity: 0.5; + cursor: not-allowed; + opacity: 0.5; } .cv-app .controls { - display: flex; - gap: 8px; - align-items: center; - margin-bottom: 12px; - flex-wrap: wrap; + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 12px; + flex-wrap: wrap; } .cv-app .status { - padding: 2px 12px; - border-radius: var(--radius-full); - font-size: var(--font-2xs); - font-weight: var(--weight-bold); - border: var(--border-width) solid var(--color-border); + padding: 2px 12px; + border-radius: var(--radius-full); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); + border: var(--border-width) solid var(--color-border); } .cv-app .status.idle { - color: var(--color-text-high); + color: var(--color-text-high); } .cv-app .status.watching { - color: var(--color-info-high); - border-color: var(--color-info-low); - background: var(--color-info-low); + color: var(--color-info-high); + border-color: var(--color-info-low); + background: var(--color-info-low); } .cv-app .status.detected { - color: var(--color-success-high); - border-color: var(--color-success-low); - background: var(--color-success-low); + color: var(--color-success-high); + border-color: var(--color-success-low); + background: var(--color-success-low); } .cv-app .live-layout { - display: grid; - grid-template-columns: minmax(320px, 640px) 1fr; - gap: 16px; - align-items: start; + display: grid; + grid-template-columns: minmax(320px, 640px) 1fr; + gap: 16px; + align-items: start; } -.cv-app video.preview, .cv-app canvas.preview { - width: 100%; - background: #000; - border-radius: var(--radius-box); - border: var(--border-width) solid var(--color-bg-high); +.cv-app video.preview, +.cv-app canvas.preview { + width: 100%; + background: #000; + border-radius: var(--radius-box); + border: var(--border-width) solid var(--color-bg-high); } .cv-app .feed { - display: flex; - flex-direction: column; - gap: 12px; + display: flex; + flex-direction: column; + gap: 12px; } .cv-app .card { - background: var(--color-bg-high); - border-radius: var(--radius-box); - padding: 16px; + background: var(--color-bg-high); + border-radius: var(--radius-box); + padding: 16px; } .cv-app .card .meta { - display: flex; - gap: 12px; - color: var(--color-text-high); - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - margin-bottom: 8px; - align-items: center; + display: flex; + gap: 12px; + color: var(--color-text-high); + font-size: var(--font-2xs); + font-weight: var(--weight-semi); + margin-bottom: 8px; + align-items: center; } .cv-app .card img.thumb { - width: 160px; - border-radius: var(--radius-field); + width: 160px; + border-radius: var(--radius-field); } .cv-app .teams { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; } /* sendou.ink send status strip under a feed card */ .cv-app .send-wrap { - display: flex; - flex-direction: column; - gap: 2px; + display: flex; + flex-direction: column; + gap: 2px; } .cv-app .send-strip { - display: flex; - gap: 12px; - align-items: center; - padding: 4px 16px; - border-radius: var(--radius-box); - background: var(--color-bg-high); - font-size: var(--font-2xs); - font-weight: var(--weight-semi); - color: var(--color-text-high); + display: flex; + gap: 12px; + align-items: center; + padding: 4px 16px; + border-radius: var(--radius-box); + background: var(--color-bg-high); + font-size: var(--font-2xs); + font-weight: var(--weight-semi); + color: var(--color-text-high); } .cv-app .send-strip.sent { - color: var(--color-success-high); - background: var(--color-success-low); + color: var(--color-success-high); + background: var(--color-success-low); } .cv-app .send-strip.failed { - color: var(--color-error-high); - background: var(--color-error-low); + color: var(--color-error-high); + background: var(--color-error-low); } -.cv-app .send-strip.queued, .cv-app .send-strip.sending { - color: var(--color-info-high); - background: var(--color-info-low); -} - -.cv-app .send-strip .error { - color: inherit; - font-weight: var(--weight-body); -} - -.cv-app .team { - border-radius: var(--radius-field); - padding: 8px; - background: var(--color-bg); -} - -.cv-app .team h3 { - margin: 0 0 6px; - font-size: var(--font-2xs); - font-weight: var(--weight-bold); - text-transform: uppercase; - letter-spacing: 0.08em; -} - -.cv-app .team.win h3 { - color: var(--color-error-high); -} - -.cv-app .team.lose h3 { - color: var(--color-info-high); -} - -.cv-app table.players { - width: 100%; - border-collapse: collapse; - font-size: var(--font-xs); -} - -.cv-app table.players td { - padding: 2px 6px; - white-space: nowrap; -} - -.cv-app table.players td.num { - text-align: right; - font-variant-numeric: tabular-nums; -} - -.cv-app img.weapon-icon { - width: 28px; - height: 28px; - vertical-align: middle; - background: #000; - border-radius: 4px; -} - -.cv-app .weapon-cell { - display: inline-flex; - align-items: center; - gap: 4px; -} - -.cv-app .ability-popover { - position: relative; - display: inline-flex; -} - -.cv-app button.ability-trigger { - height: auto; - padding: 2px; - background: var(--color-bg-higher); - border: 1px solid var(--color-border-high); - border-radius: 4px; -} - -.cv-app button.ability-trigger img { - display: block; - width: 18px; - height: 18px; -} - -.cv-app .ability-popover .popover { - position: absolute; - top: calc(100% + 4px); - left: 0; - z-index: 20; - background: var(--color-bg-higher); - border: var(--border-style); - border-radius: var(--radius-field); - padding: 6px; - box-shadow: 0 8px 24px rgb(0 0 0 / 0.5); -} - -.cv-app .vod-list { - display: flex; - flex-direction: column; - gap: 8px; -} - -.cv-app .vod-item { - display: flex; - align-items: center; - gap: 12px; - background: var(--color-bg-high); - border-radius: var(--radius-box); - padding: 12px 16px; -} - -.cv-app .vod-item .name { - font-size: var(--font-sm); - font-weight: var(--weight-bold); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.cv-app .vod-item .score { - flex: 1; -} - -.cv-app .dropzone { - border: var(--border-width) dashed var(--color-border-high); - border-radius: var(--radius-box); - padding: 32px; - text-align: center; - color: var(--color-text-high); - font-size: var(--font-sm); - font-weight: var(--weight-semi); - margin-bottom: 16px; -} - -.cv-app .dropzone.over { - border-color: var(--color-text-accent); - color: var(--color-text-accent); -} - -.cv-app .screenshot-frame { - position: relative; - margin-bottom: 16px; -} - -.cv-app .screenshot-frame canvas { - width: 100%; - border-radius: var(--radius-box); - border: var(--border-width) solid var(--color-bg-high); -} - -.cv-app table.inspector { - width: 100%; - border-collapse: collapse; - font-size: var(--font-xs); -} - -.cv-app table.inspector th { - text-align: left; - color: var(--color-text-high); - font-size: var(--font-2xs); - font-weight: var(--weight-bold); - padding: 6px; - border-bottom: var(--border-width) solid var(--color-border); -} - -.cv-app table.inspector td { - padding: 6px; - border-bottom: 1px solid var(--color-bg-high); - vertical-align: middle; -} - -.cv-app table.inspector canvas { - display: block; - background: #000; - border-radius: 4px; -} - -.cv-app .candidates { - display: flex; - gap: 8px; - align-items: center; -} - -.cv-app .candidates .cand { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - font-size: var(--font-2xs); - color: var(--color-text-high); -} - -.cv-app .candidates .cand:first-child { - color: var(--color-success); +.cv-app .send-strip.queued, +.cv-app .send-strip.sending { + color: var(--color-info-high); + background: var(--color-info-low); } .cv-app .score { - color: var(--color-text-high); - font-size: var(--font-2xs); + color: var(--color-text-high); + font-size: var(--font-2xs); } .cv-app .error { - color: var(--color-error); + color: var(--color-error); } +.cv-app .send-strip .error { + color: inherit; + font-weight: var(--weight-body); +} + +.cv-app .team { + border-radius: var(--radius-field); + padding: 8px; + background: var(--color-bg); +} + +.cv-app .team h3 { + margin: 0 0 6px; + font-size: var(--font-2xs); + font-weight: var(--weight-bold); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.cv-app .team.win h3 { + color: var(--color-error-high); +} + +.cv-app .team.lose h3 { + color: var(--color-info-high); +} + +.cv-app table.players { + width: 100%; + border-collapse: collapse; + font-size: var(--font-xs); +} + +.cv-app table.players td { + padding: 2px 6px; + white-space: nowrap; +} + +.cv-app table.players td.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.cv-app img.weapon-icon { + width: 28px; + height: 28px; + vertical-align: middle; + background: #000; + border-radius: 4px; +} + +.cv-app .weapon-cell { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.cv-app .ability-popover { + position: relative; + display: inline-flex; +} + +.cv-app button.ability-trigger { + height: auto; + padding: 2px; + background: var(--color-bg-higher); + border: 1px solid var(--color-border-high); + border-radius: 4px; +} + +.cv-app button.ability-trigger img { + display: block; + width: 18px; + height: 18px; +} + +.cv-app .ability-popover .popover { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 20; + background: var(--color-bg-higher); + border: var(--border-style); + border-radius: var(--radius-field); + padding: 6px; + box-shadow: 0 8px 24px rgb(0 0 0 / 0.5); +} + +.cv-app .vod-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.cv-app .vod-item { + display: flex; + align-items: center; + gap: 12px; + background: var(--color-bg-high); + border-radius: var(--radius-box); + padding: 12px 16px; +} + +.cv-app .vod-item .name { + font-size: var(--font-sm); + font-weight: var(--weight-bold); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cv-app .vod-item .score { + flex: 1; +} + +.cv-app .dropzone { + border: var(--border-width) dashed var(--color-border-high); + border-radius: var(--radius-box); + padding: 32px; + text-align: center; + color: var(--color-text-high); + font-size: var(--font-sm); + font-weight: var(--weight-semi); + margin-bottom: 16px; +} + +.cv-app .dropzone.over { + border-color: var(--color-text-accent); + color: var(--color-text-accent); +} + +.cv-app .screenshot-frame { + position: relative; + margin-bottom: 16px; +} + +.cv-app .screenshot-frame canvas { + width: 100%; + border-radius: var(--radius-box); + border: var(--border-width) solid var(--color-bg-high); +} + +.cv-app table.inspector { + width: 100%; + border-collapse: collapse; + font-size: var(--font-xs); +} + +.cv-app table.inspector th { + text-align: left; + color: var(--color-text-high); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); + padding: 6px; + border-bottom: var(--border-width) solid var(--color-border); +} + +.cv-app table.inspector td { + padding: 6px; + border-bottom: 1px solid var(--color-bg-high); + vertical-align: middle; +} + +.cv-app table.inspector canvas { + display: block; + background: #000; + border-radius: 4px; +} + +.cv-app .candidates { + display: flex; + gap: 8px; + align-items: center; +} + +.cv-app .candidates .cand { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + font-size: var(--font-2xs); + color: var(--color-text-high); +} + +.cv-app .candidates .cand:first-child { + color: var(--color-success); +} /* internal navigation styled like the sibling action buttons */ .cv-app .link-button { - display: inline-flex; - align-items: center; - padding: 0 var(--field-padding); - height: var(--field-size-sm); - border: var(--border-style); - border-radius: var(--radius-field); - background: var(--color-bg-higher); - color: var(--color-text); - font-size: var(--font-sm); - text-decoration: none; + display: inline-flex; + align-items: center; + padding: 0 var(--field-padding); + height: var(--field-size-sm); + border: var(--border-style); + border-radius: var(--radius-field); + background: var(--color-bg-higher); + color: var(--color-text); + font-size: var(--font-sm); + text-decoration: none; } .cv-app .link-button:focus-visible { - outline: var(--focus-ring); + outline: var(--focus-ring); } diff --git a/app/features/cv/components/thumbnail.ts b/app/features/cv/components/thumbnail.ts index 3ac22560b..57d2986e2 100644 --- a/app/features/cv/components/thumbnail.ts +++ b/app/features/cv/components/thumbnail.ts @@ -1,10 +1,10 @@ /** Small JPEG data-URL thumbnail of an analyzed frame, for feed cards. */ export async function thumbnailFromBlob(frame: Blob): Promise { - const bitmap = await createImageBitmap(frame); - const canvas = document.createElement("canvas"); - canvas.width = 320; - canvas.height = 180; - canvas.getContext("2d")!.drawImage(bitmap, 0, 0, 320, 180); - bitmap.close(); - return canvas.toDataURL("image/jpeg", 0.7); + const bitmap = await createImageBitmap(frame); + const canvas = document.createElement("canvas"); + canvas.width = 320; + canvas.height = 180; + canvas.getContext("2d")!.drawImage(bitmap, 0, 0, 320, 180); + bitmap.close(); + return canvas.toDataURL("image/jpeg", 0.7); } diff --git a/app/features/cv/core/ability-harvest.ts b/app/features/cv/core/ability-harvest.ts index 4e8d3ad30..4ac30e5f7 100644 --- a/app/features/cv/core/ability-harvest.ts +++ b/app/features/cv/core/ability-harvest.ts @@ -9,7 +9,10 @@ import { DEATH_EVENT_TYPE, type DeathData } from "./detectors/death/index"; import { SCOREBOARD_EVENT_TYPES } from "./detectors/registry"; -import type { ScoreboardData, ScoreboardPlayer } from "./detectors/scoreboard/index"; +import type { + ScoreboardData, + ScoreboardPlayer, +} from "./detectors/scoreboard/index"; import type { DetectedEvent } from "./detectors/types"; /** player row index (0-7) → [head, clothes, shoes] ability-id rows */ @@ -21,21 +24,26 @@ export type PlayerAbilityMap = Map; * name+weapon hit wins, then a unique name hit, then a unique weapon hit * (two players on the same weapon with a misread name stay unattributed). */ -function matchPlayer(players: ScoreboardPlayer[], death: DeathData): number | null { - const name = death.name?.trim().toLowerCase() || null; - const indices = players.map((_, i) => i); - const byName = name ? indices.filter((i) => players[i]!.name.trim().toLowerCase() === name) : []; - // scoreboard rows carry main-weapon ids; a sub/special credit says - // nothing about which main the killer holds - const byWeapon = - death.weaponId !== null && death.weaponType === "MAIN" - ? indices.filter((i) => players[i]!.weaponId === death.weaponId) - : []; - const both = byName.filter((i) => byWeapon.includes(i)); - if (both.length > 0) return both[0]!; - if (byName.length === 1) return byName[0]!; - if (byWeapon.length === 1) return byWeapon[0]!; - return null; +function matchPlayer( + players: ScoreboardPlayer[], + death: DeathData, +): number | null { + const name = death.name?.trim().toLowerCase() || null; + const indices = players.map((_, i) => i); + const byName = name + ? indices.filter((i) => players[i]!.name.trim().toLowerCase() === name) + : []; + // scoreboard rows carry main-weapon ids; a sub/special credit says + // nothing about which main the killer holds + const byWeapon = + death.weaponId !== null && death.weaponType === "MAIN" + ? indices.filter((i) => players[i]!.weaponId === death.weaponId) + : []; + const both = byName.filter((i) => byWeapon.includes(i)); + if (both.length > 0) return both[0]!; + if (byName.length === 1) return byName[0]!; + if (byWeapon.length === 1) return byWeapon[0]!; + return null; } /** @@ -43,16 +51,16 @@ function matchPlayer(players: ScoreboardPlayer[], death: DeathData): number | nu * readable ability grid is attributed to a scoreboard player row. */ export function harvestAbilities( - players: ScoreboardPlayer[], - deaths: readonly DeathData[], + players: ScoreboardPlayer[], + deaths: readonly DeathData[], ): PlayerAbilityMap { - const abilities: PlayerAbilityMap = new Map(); - for (const death of deaths) { - if (death.abilities.length === 0) continue; - const index = matchPlayer(players, death); - if (index !== null) abilities.set(index, death.abilities); - } - return abilities; + const abilities: PlayerAbilityMap = new Map(); + for (const death of deaths) { + if (death.abilities.length === 0) continue; + const index = matchPlayer(players, death); + if (index !== null) abilities.set(index, death.abilities); + } + return abilities; } /** @@ -61,20 +69,20 @@ export function harvestAbilities( * without any attributed death are absent from the result. */ export function connectAbilities( - events: readonly DetectedEvent[], + events: readonly DetectedEvent[], ): Map { - const sorted = [...events].sort((a, b) => a.t - b.t); - const result = new Map(); - let pendingDeaths: DeathData[] = []; - for (const event of sorted) { - if (event.type === DEATH_EVENT_TYPE) { - pendingDeaths.push(event.data as DeathData); - } else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) { - const players = (event.data as ScoreboardData).players; - const abilities = harvestAbilities(players, pendingDeaths); - if (abilities.size > 0) result.set(event, abilities); - pendingDeaths = []; - } - } - return result; + const sorted = [...events].sort((a, b) => a.t - b.t); + const result = new Map(); + let pendingDeaths: DeathData[] = []; + for (const event of sorted) { + if (event.type === DEATH_EVENT_TYPE) { + pendingDeaths.push(event.data as DeathData); + } else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) { + const players = (event.data as ScoreboardData).players; + const abilities = harvestAbilities(players, pendingDeaths); + if (abilities.size > 0) result.set(event, abilities); + pendingDeaths = []; + } + } + return result; } diff --git a/app/features/cv/core/batches.ts b/app/features/cv/core/batches.ts index 227c3a455..438b89356 100644 --- a/app/features/cv/core/batches.ts +++ b/app/features/cv/core/batches.ts @@ -16,7 +16,10 @@ import { harvestAbilities } from "./ability-harvest"; import { DEATH_EVENT_TYPE, type DeathData } from "./detectors/death/index"; import { MAP_START_EVENT_TYPE } from "./detectors/map-start/index"; import { SCOREBOARD_EVENT_TYPES } from "./detectors/registry"; -import type { ScoreboardData, ScoreboardPlayer } from "./detectors/scoreboard/index"; +import type { + ScoreboardData, + ScoreboardPlayer, +} from "./detectors/scoreboard/index"; import type { DetectedEvent } from "./detectors/types"; /** The lobby header value private battles (tournament games) carry. */ @@ -30,8 +33,8 @@ const TOURNAMENT_LOBBY = "PRIVATE"; const FALLBACK_WINDOW_SECONDS = 600; export interface IngestScoreboardPlayer extends ScoreboardPlayer { - /** [head, clothes, shoes] ability rows harvested from this match's death screens */ - abilities?: string[][]; + /** [head, clothes, shoes] ability rows harvested from this match's death screens */ + abilities?: string[][]; } /** @@ -45,32 +48,36 @@ export interface IngestScoreboardPlayer extends ScoreboardPlayer { * except the terminating scoreboard, which is shallow-copied for * enrichment. */ -export function buildIngestBatches(events: readonly E[]): E[][] { - const sorted = [...events].sort((a, b) => a.t - b.t); - const batches: E[][] = []; - let open: E[] | null = null; - // deaths since the last boundary with no MapStart to anchor them yet - let orphans: E[] = []; +export function buildIngestBatches( + events: readonly E[], +): E[][] { + const sorted = [...events].sort((a, b) => a.t - b.t); + const batches: E[][] = []; + let open: E[] | null = null; + // deaths since the last boundary with no MapStart to anchor them yet + let orphans: E[] = []; - for (const event of sorted) { - if (event.type === MAP_START_EVENT_TYPE) { - // a new match intro abandons any match whose scoreboard was missed - open = [event]; - orphans = []; - } else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) { - const data = event.data as ScoreboardData; - if (!data.lobby || data.lobby === TOURNAMENT_LOBBY) { - const matchEvents = open ?? orphans.filter((e) => event.t - e.t <= FALLBACK_WINDOW_SECONDS); - batches.push([...matchEvents, enrichScoreboard(event, matchEvents)]); - } - open = null; - orphans = []; - } else if (event.type === DEATH_EVENT_TYPE) { - (open ?? orphans).push(event); - } - } + for (const event of sorted) { + if (event.type === MAP_START_EVENT_TYPE) { + // a new match intro abandons any match whose scoreboard was missed + open = [event]; + orphans = []; + } else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) { + const data = event.data as ScoreboardData; + if (!data.lobby || data.lobby === TOURNAMENT_LOBBY) { + const matchEvents = + open ?? + orphans.filter((e) => event.t - e.t <= FALLBACK_WINDOW_SECONDS); + batches.push([...matchEvents, enrichScoreboard(event, matchEvents)]); + } + open = null; + orphans = []; + } else if (event.type === DEATH_EVENT_TYPE) { + (open ?? orphans).push(event); + } + } - return batches; + return batches; } /** @@ -81,36 +88,39 @@ export function buildIngestBatches(events: readonly E[] * never split across chunks; an oversized lone batch gets its own chunk. */ export function chunkIngestBatches( - batches: readonly E[][], - maxEvents: number, + batches: readonly E[][], + maxEvents: number, ): E[][][] { - const chunks: E[][][] = []; - let current: E[][] = []; - let eventCount = 0; - for (const batch of batches) { - if (current.length > 0 && eventCount + batch.length > maxEvents) { - chunks.push(current); - current = []; - eventCount = 0; - } - current.push(batch); - eventCount += batch.length; - } - if (current.length > 0) chunks.push(current); - return chunks; + const chunks: E[][][] = []; + let current: E[][] = []; + let eventCount = 0; + for (const batch of batches) { + if (current.length > 0 && eventCount + batch.length > maxEvents) { + chunks.push(current); + current = []; + eventCount = 0; + } + current.push(batch); + eventCount += batch.length; + } + if (current.length > 0) chunks.push(current); + return chunks; } -function enrichScoreboard(scoreboard: E, matchEvents: readonly E[]): E { - const deaths = matchEvents - .filter((e) => e.type === DEATH_EVENT_TYPE) - .map((e) => e.data as DeathData); - const data = scoreboard.data as ScoreboardData; - const abilities = harvestAbilities(data.players, deaths); - if (abilities.size === 0) return scoreboard; +function enrichScoreboard( + scoreboard: E, + matchEvents: readonly E[], +): E { + const deaths = matchEvents + .filter((e) => e.type === DEATH_EVENT_TYPE) + .map((e) => e.data as DeathData); + const data = scoreboard.data as ScoreboardData; + const abilities = harvestAbilities(data.players, deaths); + if (abilities.size === 0) return scoreboard; - const players: IngestScoreboardPlayer[] = data.players.map((player, i) => { - const build = abilities.get(i); - return build ? { ...player, abilities: build } : player; - }); - return { ...scoreboard, data: { ...data, players } }; + const players: IngestScoreboardPlayer[] = data.players.map((player, i) => { + const build = abilities.get(i); + return build ? { ...player, abilities: build } : player; + }); + return { ...scoreboard, data: { ...data, players } }; } diff --git a/app/features/cv/core/canonical.ts b/app/features/cv/core/canonical.ts index b84763f5b..058302dfc 100644 --- a/app/features/cv/core/canonical.ts +++ b/app/features/cv/core/canonical.ts @@ -7,8 +7,8 @@ export const CANONICAL_WIDTH = 1920; export const CANONICAL_HEIGHT = 1080; export interface Roi { - x: number; - y: number; - w: number; - h: number; + x: number; + y: number; + w: number; + h: number; } diff --git a/app/features/cv/core/cv.ts b/app/features/cv/core/cv.ts index f01da8009..329ae1c85 100644 --- a/app/features/cv/core/cv.ts +++ b/app/features/cv/core/cv.ts @@ -14,65 +14,73 @@ let cvInstance: CV | null = null; let loading: Promise | null = null; export function loadOpenCV(): Promise { - if (cvInstance) return Promise.resolve(cvInstance); - if (loading) return loading; - const attempt = (async () => { - // The package is patched (patches/@techstark__opencv-js…) to export - // { cvReadyPromise } instead of the bare ready-promise: a thenable (or - // default-wrapped thenable) module.exports leaks `then` through - // vite-node's CJS namespace proxy and crashes every Node-side import. - // Depending on bundler interop we see the wrapper (possibly nested - // under `default`) or, in bundles that took the UMD's non-CJS branch, - // the promise itself. - const raw = cvModule as { - cvReadyPromise?: unknown; - default?: { cvReadyPromise?: unknown }; - } | null; - const mod: unknown = - raw?.cvReadyPromise ?? raw?.default?.cvReadyPromise ?? raw?.default ?? raw; - let cv: CV; - if (mod instanceof Promise) { - cv = await mod; - } else if ((mod as CV).Mat) { - cv = mod as CV; - } else { - await new Promise((resolve) => { - (mod as { onRuntimeInitialized?: () => void }).onRuntimeInitialized = resolve; - }); - cv = mod as CV; - } - cvInstance = cv; - return cv; - })(); - // a failed load must not poison the singleton — clear it so callers can retry - loading = attempt.catch((error) => { - loading = null; - throw error; - }); - return loading; + if (cvInstance) return Promise.resolve(cvInstance); + if (loading) return loading; + const attempt = (async () => { + // The package is patched (patches/@techstark__opencv-js…) to export + // { cvReadyPromise } instead of the bare ready-promise: a thenable (or + // default-wrapped thenable) module.exports leaks `then` through + // vite-node's CJS namespace proxy and crashes every Node-side import. + // Depending on bundler interop we see the wrapper (possibly nested + // under `default`) or, in bundles that took the UMD's non-CJS branch, + // the promise itself. + const raw = cvModule as { + cvReadyPromise?: unknown; + default?: { cvReadyPromise?: unknown }; + } | null; + const mod: unknown = + raw?.cvReadyPromise ?? + raw?.default?.cvReadyPromise ?? + raw?.default ?? + raw; + let cv: CV; + if (mod instanceof Promise) { + cv = await mod; + } else if ((mod as CV).Mat) { + cv = mod as CV; + } else { + await new Promise((resolve) => { + (mod as { onRuntimeInitialized?: () => void }).onRuntimeInitialized = + resolve; + }); + cv = mod as CV; + } + cvInstance = cv; + return cv; + })(); + // a failed load must not poison the singleton — clear it so callers can retry + loading = attempt.catch((error) => { + loading = null; + throw error; + }); + return loading; } export function getCV(): CV { - if (!cvInstance) { - throw new Error("OpenCV not loaded — await loadOpenCV() before using core/"); - } - return cvInstance; + if (!cvInstance) { + throw new Error( + "OpenCV not loaded — await loadOpenCV() before using core/", + ); + } + return cvInstance; } // The bundled type definitions mark the optional mask argument of these as // required; thin wrappers restore the real (mask-less) signatures. export interface MinMaxResult { - minVal: number; - maxVal: number; - minLoc: { x: number; y: number }; - maxLoc: { x: number; y: number }; + minVal: number; + maxVal: number; + minLoc: { x: number; y: number }; + maxLoc: { x: number; y: number }; } export function minMaxLoc(mat: Mat): MinMaxResult { - return (getCV() as unknown as { minMaxLoc(m: Mat): MinMaxResult }).minMaxLoc(mat); + return (getCV() as unknown as { minMaxLoc(m: Mat): MinMaxResult }).minMaxLoc( + mat, + ); } export function meanOf(mat: Mat): number[] { - return (getCV() as unknown as { mean(m: Mat): number[] }).mean(mat); + return (getCV() as unknown as { mean(m: Mat): number[] }).mean(mat); } diff --git a/app/features/cv/core/detectors/death/abilities.ts b/app/features/cv/core/detectors/death/abilities.ts index 4d4205e09..5d70435ba 100644 --- a/app/features/cv/core/detectors/death/abilities.ts +++ b/app/features/cv/core/detectors/death/abilities.ts @@ -18,19 +18,19 @@ import { getCV } from "../../cv"; import type { FrameData } from "../../image"; import { buildTemplateSizes, type WeaponTemplate } from "../scoreboard/weapons"; import { - ABILITY_INK_THRESHOLD, - ABILITY_MAIN_ART_RATIO, - ABILITY_MAIN_SIZES, - ABILITY_SUB_ART_RATIO, - ABILITY_SUB_SIZES, + ABILITY_INK_THRESHOLD, + ABILITY_MAIN_ART_RATIO, + ABILITY_MAIN_SIZES, + ABILITY_SUB_ART_RATIO, + ABILITY_SUB_SIZES, } from "./rois"; /** Badge interior brightness (near-black circle on the dark panel). */ const BADGE_BACKGROUND = 10; export interface AbilityTemplates { - mains: WeaponTemplate[]; - subs: WeaponTemplate[]; + mains: WeaponTemplate[]; + subs: WeaponTemplate[]; } /** @@ -40,46 +40,46 @@ export interface AbilityTemplates { * ratio compares mismatched ink counts. */ export function buildAbilityRole( - icons: { id: string; image: FrameData }[], - sizes: readonly number[], - artRatio: number, - inkThreshold: number = ABILITY_INK_THRESHOLD, + icons: { id: string; image: FrameData }[], + sizes: readonly number[], + artRatio: number, + inkThreshold: number = ABILITY_INK_THRESHOLD, ): WeaponTemplate[] { - const cv = getCV(); - return icons.map(({ id, image }) => { - // composite the icon art over the badge black, padded so the art - // occupies artRatio of the square (as it does of the badge on screen) - const side = Math.round(image.width / artRatio); - const offset = Math.floor((side - image.width) / 2); - const padded = new cv.Mat( - side, - side, - cv.CV_8UC3, - new cv.Scalar(BADGE_BACKGROUND, BADGE_BACKGROUND, BADGE_BACKGROUND), - ); - const dst = padded.data; - const src = image.data; - for (let y = 0; y < image.height; y++) { - for (let x = 0; x < image.width; x++) { - const si = (y * image.width + x) * 4; - const a = src[si + 3]! / 255; - const di = ((y + offset) * side + x + offset) * 3; - dst[di] = Math.round(src[si]! * a + BADGE_BACKGROUND * (1 - a)); - dst[di + 1] = Math.round(src[si + 1]! * a + BADGE_BACKGROUND * (1 - a)); - dst[di + 2] = Math.round(src[si + 2]! * a + BADGE_BACKGROUND * (1 - a)); - } - } - const templateSizes = buildTemplateSizes(padded, sizes, inkThreshold); - padded.delete(); - return { id, sizes: templateSizes }; - }); + const cv = getCV(); + return icons.map(({ id, image }) => { + // composite the icon art over the badge black, padded so the art + // occupies artRatio of the square (as it does of the badge on screen) + const side = Math.round(image.width / artRatio); + const offset = Math.floor((side - image.width) / 2); + const padded = new cv.Mat( + side, + side, + cv.CV_8UC3, + new cv.Scalar(BADGE_BACKGROUND, BADGE_BACKGROUND, BADGE_BACKGROUND), + ); + const dst = padded.data; + const src = image.data; + for (let y = 0; y < image.height; y++) { + for (let x = 0; x < image.width; x++) { + const si = (y * image.width + x) * 4; + const a = src[si + 3]! / 255; + const di = ((y + offset) * side + x + offset) * 3; + dst[di] = Math.round(src[si]! * a + BADGE_BACKGROUND * (1 - a)); + dst[di + 1] = Math.round(src[si + 1]! * a + BADGE_BACKGROUND * (1 - a)); + dst[di + 2] = Math.round(src[si + 2]! * a + BADGE_BACKGROUND * (1 - a)); + } + } + const templateSizes = buildTemplateSizes(padded, sizes, inkThreshold); + padded.delete(); + return { id, sizes: templateSizes }; + }); } export function prepareAbilityTemplates( - icons: { id: string; image: FrameData }[], + icons: { id: string; image: FrameData }[], ): AbilityTemplates { - return { - mains: buildAbilityRole(icons, ABILITY_MAIN_SIZES, ABILITY_MAIN_ART_RATIO), - subs: buildAbilityRole(icons, ABILITY_SUB_SIZES, ABILITY_SUB_ART_RATIO), - }; + return { + mains: buildAbilityRole(icons, ABILITY_MAIN_SIZES, ABILITY_MAIN_ART_RATIO), + subs: buildAbilityRole(icons, ABILITY_SUB_SIZES, ABILITY_SUB_ART_RATIO), + }; } diff --git a/app/features/cv/core/detectors/death/index.ts b/app/features/cv/core/detectors/death/index.ts index 23b4a6395..7ca0e1d33 100644 --- a/app/features/cv/core/detectors/death/index.ts +++ b/app/features/cv/core/detectors/death/index.ts @@ -20,10 +20,19 @@ * decisive margin, and below that the burst icon and the text ranking * corroborate each other (steps 2c/2d in parse). */ -import type { MainWeaponId, SpecialWeaponId, SubWeaponId } from "~/modules/in-game-lists/types"; +import type { + MainWeaponId, + SpecialWeaponId, + SubWeaponId, +} from "~/modules/in-game-lists/types"; import { type CvAbility, toCvAbility } from "../../../cv-types"; import { getCV, type Mat, minMaxLoc } from "../../cv"; -import { type GlyphSet, type RecognizedText, recognizeText, scaleGlyphSet } from "../../glyphs"; +import { + type GlyphSet, + type RecognizedText, + recognizeText, + scaleGlyphSet, +} from "../../glyphs"; import { copyRoi, cropRoi, meanBrightness, type Roi } from "../../image"; import { closestEntry, matchKey, rankBy, rankByRead } from "../../text"; import type { ScoreboardResources } from "../scoreboard/index"; @@ -31,53 +40,57 @@ import { parseName } from "../scoreboard/names"; import { matchWeapon, type WeaponMatch } from "../scoreboard/weapons"; import type { DetectedEvent, Detector, GateResult } from "../types"; import { - DEATH_MESSAGE_TEMPLATES, - type DeathMessageTemplate, - LOCALIZED_WEAPON_NAMES, + DEATH_MESSAGE_TEMPLATES, + type DeathMessageTemplate, + LOCALIZED_WEAPON_NAMES, } from "./localized-messages"; import { - ABILITY_INK_THRESHOLD, - ABILITY_ROWS, - ABILITY_SLOT_MIN_INK, - ABILITY_SUB_XS, - abilityMainRoi, - abilitySubRoi, - BURST_ICON_ROI, - GATE_BURST_PROBES, - GATE_DARK_MAX_MEAN, - GATE_ICON_MIN_MAX, - GATE_PANEL_PROBES, - GATE_TEXT_MAX_FRACTION, - GATE_TEXT_MIN_MAX, - gateAbilityProbe, - JA_CONST_LINE_ROI, - JA_WEAPON_LINE_ROI, - SPLAT_LINE1_ROI, - SPLAT_TEXT_BIN_THRESHOLD, - TAG_NAME_INNER, - TAG_NAME_OUTER, - TAG_NAME_TEXT_HEIGHT, - TAG_TILT_DEG, - WEAPON_LINE_ROI, - WEAPON_TEXT_HEIGHT, + ABILITY_INK_THRESHOLD, + ABILITY_ROWS, + ABILITY_SLOT_MIN_INK, + ABILITY_SUB_XS, + abilityMainRoi, + abilitySubRoi, + BURST_ICON_ROI, + GATE_BURST_PROBES, + GATE_DARK_MAX_MEAN, + GATE_ICON_MIN_MAX, + GATE_PANEL_PROBES, + GATE_TEXT_MAX_FRACTION, + GATE_TEXT_MIN_MAX, + gateAbilityProbe, + JA_CONST_LINE_ROI, + JA_WEAPON_LINE_ROI, + SPLAT_LINE1_ROI, + SPLAT_TEXT_BIN_THRESHOLD, + TAG_NAME_INNER, + TAG_NAME_OUTER, + TAG_NAME_TEXT_HEIGHT, + TAG_TILT_DEG, + WEAPON_LINE_ROI, + WEAPON_TEXT_HEIGHT, } from "./rois"; -import { ALL_WEAPON_ENTRIES, type WeaponEntry, type WeaponType } from "./weapon-names"; +import { + ALL_WEAPON_ENTRIES, + type WeaponEntry, + type WeaponType, +} from "./weapon-names"; export interface DeathData { - /** - * the killer's weapon id — a sendou main/sub/special weapon id, unique - * only within its kind (`weaponType` disambiguates); null if unreadable - */ - weaponId: MainWeaponId | SubWeaponId | SpecialWeaponId | null; - /** which kind of weapon got the splat; null when the weapon is unreadable */ - weaponType: WeaponType | null; - /** - * killer's gear abilities, [head, clothes, shoes] rows of [main, sub...] - * ability ids; rows carry as many sub entries as the gear has slots (1-3) - */ - abilities: CvAbility[][]; - /** killer's splash-tag name; null if unreadable */ - name: string | null; + /** + * the killer's weapon id — a sendou main/sub/special weapon id, unique + * only within its kind (`weaponType` disambiguates); null if unreadable + */ + weaponId: MainWeaponId | SubWeaponId | SpecialWeaponId | null; + /** which kind of weapon got the splat; null when the weapon is unreadable */ + weaponType: WeaponType | null; + /** + * killer's gear abilities, [head, clothes, shoes] rows of [main, sub...] + * ability ids; rows carry as many sub entries as the gear has slots (1-3) + */ + abilities: CvAbility[][]; + /** killer's splash-tag name; null if unreadable */ + name: string | null; } export const DEATH_EVENT_TYPE = "Death"; @@ -130,14 +143,14 @@ const TAG_SPLIT_MIN_FRACTION = 0.15; const TAG_SPLIT_MIN_CHANNEL_DISTANCE = 40; interface WeaponCandidate { - /** the full weapon line as this template renders it, e.g. "Durch Klecksroller" */ - text: string; - entry: WeaponEntry; + /** the full weapon line as this template renders it, e.g. "Durch Klecksroller" */ + text: string; + entry: WeaponEntry; } /** JA templates read through the JA atlas and the swapped-width line ROIs. */ function isJaTemplate(t: DeathMessageTemplate): boolean { - return t.langs.some((lang) => lang.endsWith("ja")); + return t.langs.some((lang) => lang.endsWith("ja")); } /** @@ -147,568 +160,665 @@ function isJaTemplate(t: DeathMessageTemplate): boolean { */ const templateCandidates = new Map(); function candidatesFor(template: DeathMessageTemplate): WeaponCandidate[] { - let candidates = templateCandidates.get(template); - if (candidates) return candidates; - const byName = new Map(ALL_WEAPON_ENTRIES.map((e) => [e.name, e])); - const seen = new Set(); - candidates = []; - const push = (text: string, entry: WeaponEntry | undefined) => { - const k = matchKey(text); - if (!entry || seen.has(k)) return; - seen.add(k); - candidates!.push({ text: template.weaponPre + text + template.weaponPost, entry }); - }; - for (const lang of template.langs) { - for (const { text, name } of LOCALIZED_WEAPON_NAMES[lang] ?? []) push(text, byName.get(name)); - } - for (const entry of ALL_WEAPON_ENTRIES) push(entry.name, entry); - templateCandidates.set(template, candidates); - return candidates; + let candidates = templateCandidates.get(template); + if (candidates) return candidates; + const byName = new Map(ALL_WEAPON_ENTRIES.map((e) => [e.name, e])); + const seen = new Set(); + candidates = []; + const push = (text: string, entry: WeaponEntry | undefined) => { + const k = matchKey(text); + if (!entry || seen.has(k)) return; + seen.add(k); + candidates!.push({ + text: template.weaponPre + text + template.weaponPost, + entry, + }); + }; + for (const lang of template.langs) { + for (const { text, name } of LOCALIZED_WEAPON_NAMES[lang] ?? []) + push(text, byName.get(name)); + } + for (const entry of ALL_WEAPON_ENTRIES) push(entry.name, entry); + templateCandidates.set(template, candidates); + return candidates; } -export function createDeathDetector(resources: ScoreboardResources): Detector { - const cv = getCV(); +export function createDeathDetector( + resources: ScoreboardResources, +): Detector { + const cv = getCV(); - const scaled = (set: GlyphSet | null | undefined, height: number): GlyphSet | null => - set ? scaleGlyphSet(set, height / set.height) : null; + const scaled = ( + set: GlyphSet | null | undefined, + height: number, + ): GlyphSet | null => (set ? scaleGlyphSet(set, height / set.height) : null); - const weaponGlyphs = scaled(resources.deathWeaponGlyphs, WEAPON_TEXT_HEIGHT); - // JA glyphs match at native scale: the atlas mixes fixture crops with - // per-face renders already sized to the on-screen condensed text - const jaGlyphs = resources.deathWeaponJaGlyphs ?? null; - const tagNameGlyphs = scaled(resources.deathTagNameGlyphs, TAG_NAME_TEXT_HEIGHT); - const abilities = resources.abilities ?? null; - const burstWeapons = resources.deathBurstWeapons ?? null; - const mainById = new Map( - ALL_WEAPON_ENTRIES.filter((e) => e.type === "MAIN").map((e) => [e.id, e]), - ); + const weaponGlyphs = scaled(resources.deathWeaponGlyphs, WEAPON_TEXT_HEIGHT); + // JA glyphs match at native scale: the atlas mixes fixture crops with + // per-face renders already sized to the on-screen condensed text + const jaGlyphs = resources.deathWeaponJaGlyphs ?? null; + const tagNameGlyphs = scaled( + resources.deathTagNameGlyphs, + TAG_NAME_TEXT_HEIGHT, + ); + const abilities = resources.abilities ?? null; + const burstWeapons = resources.deathBurstWeapons ?? null; + const mainById = new Map( + ALL_WEAPON_ENTRIES.filter((e) => e.type === "MAIN").map((e) => [e.id, e]), + ); - function gate(frame: Mat): GateResult { - let darkOk = 0; - const darkProbes = [...GATE_BURST_PROBES, ...GATE_PANEL_PROBES]; - for (const roi of darkProbes) { - if (meanBrightness(frame, roi) < GATE_DARK_MAX_MEAN) darkOk++; - } + function gate(frame: Mat): GateResult { + let darkOk = 0; + const darkProbes = [...GATE_BURST_PROBES, ...GATE_PANEL_PROBES]; + for (const roi of darkProbes) { + if (meanBrightness(frame, roi) < GATE_DARK_MAX_MEAN) darkOk++; + } - const gray = new cv.Mat(); - cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); - const line1 = copyRoi(gray, SPLAT_LINE1_ROI); - const { maxVal } = minMaxLoc(line1); - const bin = new cv.Mat(); - cv.threshold(line1, bin, GATE_TEXT_MIN_MAX, 255, cv.THRESH_BINARY); - line1.delete(); - const whiteFraction = cv.countNonZero(bin) / (bin.rows * bin.cols); - bin.delete(); - const textOk = - maxVal > GATE_TEXT_MIN_MAX && whiteFraction > 0.01 && whiteFraction < GATE_TEXT_MAX_FRACTION; + const gray = new cv.Mat(); + cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); + const line1 = copyRoi(gray, SPLAT_LINE1_ROI); + const { maxVal } = minMaxLoc(line1); + const bin = new cv.Mat(); + cv.threshold(line1, bin, GATE_TEXT_MIN_MAX, 255, cv.THRESH_BINARY); + line1.delete(); + const whiteFraction = cv.countNonZero(bin) / (bin.rows * bin.cols); + bin.delete(); + const textOk = + maxVal > GATE_TEXT_MIN_MAX && + whiteFraction > 0.01 && + whiteFraction < GATE_TEXT_MAX_FRACTION; - // max RGB channel, not gray: saturated icon art can be gray-dark (rois.ts) - let iconOk = 0; - for (const row of [0, 1, 2]) { - const probe = copyRoi(frame, gateAbilityProbe(row)); - const d = probe.data; - const ch = probe.channels(); - const n = probe.rows * probe.cols; - let maxCh = 0; - for (let i = 0; i < n; i++) { - const v = Math.max(d[i * ch]!, d[i * ch + 1]!, d[i * ch + 2]!); - if (v > maxCh) maxCh = v; - } - probe.delete(); - if (maxCh > GATE_ICON_MIN_MAX) iconOk++; - } - gray.delete(); + // max RGB channel, not gray: saturated icon art can be gray-dark (rois.ts) + let iconOk = 0; + for (const row of [0, 1, 2]) { + const probe = copyRoi(frame, gateAbilityProbe(row)); + const d = probe.data; + const ch = probe.channels(); + const n = probe.rows * probe.cols; + let maxCh = 0; + for (let i = 0; i < n; i++) { + const v = Math.max(d[i * ch]!, d[i * ch + 1]!, d[i * ch + 2]!); + if (v > maxCh) maxCh = v; + } + probe.delete(); + if (maxCh > GATE_ICON_MIN_MAX) iconOk++; + } + gray.delete(); - const score = (darkOk / darkProbes.length + (textOk ? 1 : 0) + iconOk / 3) / 3; - return { pass: darkOk === darkProbes.length && textOk && iconOk === 3, score }; - } + const score = + (darkOk / darkProbes.length + (textOk ? 1 : 0) + iconOk / 3) / 3; + return { + pass: darkOk === darkProbes.length && textOk && iconOk === 3, + score, + }; + } - /** Crop the tilted tag, rotate it level, and return the name band crop. */ - function levelTagInner(rgb: Mat): Mat { - const outer = copyRoi(rgb, TAG_NAME_OUTER); - const center = new cv.Point(outer.cols / 2, outer.rows / 2); - const m = cv.getRotationMatrix2D(center, -TAG_TILT_DEG, 1); - const rotated = new cv.Mat(); - cv.warpAffine( - outer, - rotated, - m, - new cv.Size(outer.cols, outer.rows), - cv.INTER_LINEAR, - cv.BORDER_REPLICATE, - new cv.Scalar(), - ); - m.delete(); - outer.delete(); - const inner = copyRoi(rotated, TAG_NAME_INNER); - rotated.delete(); - return inner; - } + /** Crop the tilted tag, rotate it level, and return the name band crop. */ + function levelTagInner(rgb: Mat): Mat { + const outer = copyRoi(rgb, TAG_NAME_OUTER); + const center = new cv.Point(outer.cols / 2, outer.rows / 2); + const m = cv.getRotationMatrix2D(center, -TAG_TILT_DEG, 1); + const rotated = new cv.Mat(); + cv.warpAffine( + outer, + rotated, + m, + new cv.Size(outer.cols, outer.rows), + cv.INTER_LINEAR, + cv.BORDER_REPLICATE, + new cv.Scalar(), + ); + m.delete(); + outer.delete(); + const inner = copyRoi(rotated, TAG_NAME_INNER); + rotated.delete(); + return inner; + } - /** Per-channel median color of `inner`, over pixels where mask(i) holds. */ - function medianColor(inner: Mat, mask?: (i: number) => boolean): [number, number, number] { - const n = inner.rows * inner.cols; - const px = inner.data; - const color: [number, number, number] = [0, 0, 0]; - for (let c = 0; c < 3; c++) { - const hist = new Array(256).fill(0); - let total = 0; - for (let i = 0; i < n; i++) { - if (mask && !mask(i)) continue; - hist[px[i * 3 + c]!]!++; - total++; - } - let acc = 0; - let v = 0; - for (; v < 255; v++) { - acc += hist[v]!; - if (acc >= total / 2) break; - } - color[c] = v; - } - return color; - } + /** Per-channel median color of `inner`, over pixels where mask(i) holds. */ + function medianColor( + inner: Mat, + mask?: (i: number) => boolean, + ): [number, number, number] { + const n = inner.rows * inner.cols; + const px = inner.data; + const color: [number, number, number] = [0, 0, 0]; + for (let c = 0; c < 3; c++) { + const hist = new Array(256).fill(0); + let total = 0; + for (let i = 0; i < n; i++) { + if (mask && !mask(i)) continue; + hist[px[i * 3 + c]!]!++; + total++; + } + let acc = 0; + let v = 0; + for (; v < 255; v++) { + acc += hist[v]!; + if (acc >= total / 2) break; + } + color[c] = v; + } + return color; + } - /** - * Dominant colors of `inner`: the most frequent quantized colors (5 bits/ - * channel), each refined to the per-channel median within its bin, with - * the bin's share of the band. A second background estimate besides - * medianColor: independent whole-image channel medians blend distinct - * populations into a color nobody has (a black banner half-covered by - * green art medians to green-ish, turning the banner base itself into - * "ink" that swallows the name), while the bin vote fails the other way - * on textured banner bases, where a flat art blob out-votes any single - * shade of the texture. Neither estimator wins everywhere, so the parse - * tries both and keeps the better read. The runner-up cluster feeds the - * split-banner candidate (see TAG_SPLIT_MIN_FRACTION). - * - * Bins are clustered before ranking: a flat hue that straddles a - * quantization boundary splits into neighbor bins (a split banner's - * yellow half measured 0.090+0.084 as two bins), and unclustered each - * fragment under-reports the hue's real share of the band. - */ - function dominantColors( - inner: Mat, - count: number, - ): { color: [number, number, number]; fraction: number }[] { - const n = inner.rows * inner.cols; - const px = inner.data; - const bins = new Map(); - for (let i = 0; i < n; i++) { - const key = ((px[i * 3]! >> 3) << 10) | ((px[i * 3 + 1]! >> 3) << 5) | (px[i * 3 + 2]! >> 3); - bins.set(key, (bins.get(key) ?? 0) + 1); - } - // greedy cluster of the top bins by quantized-center proximity - const CLUSTER_MAX_CHANNEL_DISTANCE = 24; - const top = [...bins.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8); - const centerOf = (key: number): [number, number, number] => [ - ((key >> 10) << 3) + 4, - (((key >> 5) & 31) << 3) + 4, - ((key & 31) << 3) + 4, - ]; - const clusters: { seed: [number, number, number]; keys: Set; count: number }[] = []; - for (const [key, binCount] of top) { - const c = centerOf(key); - const home = clusters.find((cl) => - cl.seed.every((s, i) => Math.abs(s - c[i]!) <= CLUSTER_MAX_CHANNEL_DISTANCE), - ); - if (home) { - home.keys.add(key); - home.count += binCount; - } else { - clusters.push({ seed: c, keys: new Set([key]), count: binCount }); - } - } - return clusters - .sort((a, b) => b.count - a.count) - .slice(0, count) - .map(({ keys, count: clusterCount }) => { - const inCluster = (i: number) => - keys.has( - ((px[i * 3]! >> 3) << 10) | ((px[i * 3 + 1]! >> 3) << 5) | (px[i * 3 + 2]! >> 3), - ); - return { color: medianColor(inner, inCluster), fraction: clusterCount / n }; - }); - } + /** + * Dominant colors of `inner`: the most frequent quantized colors (5 bits/ + * channel), each refined to the per-channel median within its bin, with + * the bin's share of the band. A second background estimate besides + * medianColor: independent whole-image channel medians blend distinct + * populations into a color nobody has (a black banner half-covered by + * green art medians to green-ish, turning the banner base itself into + * "ink" that swallows the name), while the bin vote fails the other way + * on textured banner bases, where a flat art blob out-votes any single + * shade of the texture. Neither estimator wins everywhere, so the parse + * tries both and keeps the better read. The runner-up cluster feeds the + * split-banner candidate (see TAG_SPLIT_MIN_FRACTION). + * + * Bins are clustered before ranking: a flat hue that straddles a + * quantization boundary splits into neighbor bins (a split banner's + * yellow half measured 0.090+0.084 as two bins), and unclustered each + * fragment under-reports the hue's real share of the band. + */ + function dominantColors( + inner: Mat, + count: number, + ): { color: [number, number, number]; fraction: number }[] { + const n = inner.rows * inner.cols; + const px = inner.data; + const bins = new Map(); + for (let i = 0; i < n; i++) { + const key = + ((px[i * 3]! >> 3) << 10) | + ((px[i * 3 + 1]! >> 3) << 5) | + (px[i * 3 + 2]! >> 3); + bins.set(key, (bins.get(key) ?? 0) + 1); + } + // greedy cluster of the top bins by quantized-center proximity + const CLUSTER_MAX_CHANNEL_DISTANCE = 24; + const top = [...bins.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8); + const centerOf = (key: number): [number, number, number] => [ + ((key >> 10) << 3) + 4, + (((key >> 5) & 31) << 3) + 4, + ((key & 31) << 3) + 4, + ]; + const clusters: { + seed: [number, number, number]; + keys: Set; + count: number; + }[] = []; + for (const [key, binCount] of top) { + const c = centerOf(key); + const home = clusters.find((cl) => + cl.seed.every( + (s, i) => Math.abs(s - c[i]!) <= CLUSTER_MAX_CHANNEL_DISTANCE, + ), + ); + if (home) { + home.keys.add(key); + home.count += binCount; + } else { + clusters.push({ seed: c, keys: new Set([key]), count: binCount }); + } + } + return clusters + .sort((a, b) => b.count - a.count) + .slice(0, count) + .map(({ keys, count: clusterCount }) => { + const inCluster = (i: number) => + keys.has( + ((px[i * 3]! >> 3) << 10) | + ((px[i * 3 + 1]! >> 3) << 5) | + (px[i * 3 + 2]! >> 3), + ); + return { + color: medianColor(inner, inCluster), + fraction: clusterCount / n, + }; + }); + } - /** - * Text-ness map of the name band: per-pixel max-channel distance from - * the nearest of `colors` (one color for solid banners; the two hues of - * a split banner). Banner art and text color vary per player — including - * pairs like pink text on a light-blue banner with almost no luminance - * contrast — so "differs from the dominant banner color" is the primary - * signal, not brightness in any fixed channel or polarity. `invert` - * flips the polarity to *closeness* for the text-color refinement pass. - */ - function distanceBand( - inner: Mat, - colors: readonly [number, number, number][], - invert: boolean, - ): Mat { - const n = inner.rows * inner.cols; - const px = inner.data; - const band = new cv.Mat(inner.rows, inner.cols, cv.CV_8UC1); - const out = band.data; - for (let i = 0; i < n; i++) { - let d = 255; - for (const color of colors) { - const dc = Math.max( - Math.abs(px[i * 3]! - color[0]), - Math.abs(px[i * 3 + 1]! - color[1]), - Math.abs(px[i * 3 + 2]! - color[2]), - ); - if (dc < d) d = dc; - } - out[i] = invert ? 255 - d : d; - } - return band; - } + /** + * Text-ness map of the name band: per-pixel max-channel distance from + * the nearest of `colors` (one color for solid banners; the two hues of + * a split banner). Banner art and text color vary per player — including + * pairs like pink text on a light-blue banner with almost no luminance + * contrast — so "differs from the dominant banner color" is the primary + * signal, not brightness in any fixed channel or polarity. `invert` + * flips the polarity to *closeness* for the text-color refinement pass. + */ + function distanceBand( + inner: Mat, + colors: readonly [number, number, number][], + invert: boolean, + ): Mat { + const n = inner.rows * inner.cols; + const px = inner.data; + const band = new cv.Mat(inner.rows, inner.cols, cv.CV_8UC1); + const out = band.data; + for (let i = 0; i < n; i++) { + let d = 255; + for (const color of colors) { + const dc = Math.max( + Math.abs(px[i * 3]! - color[0]), + Math.abs(px[i * 3 + 1]! - color[1]), + Math.abs(px[i * 3 + 2]! - color[2]), + ); + if (dc < d) d = dc; + } + out[i] = invert ? 255 - d : d; + } + return band; + } - /** - * Zero out ink components that touch the band border. Busy banner art - * (collages, prints) also differs from the median banner color, but the - * art always continues past the name band and so touches its edges, - * while the name is laid out inside it (fixture extremes: dakuten at - * y=3, a 'y' descender ending 2px above the bottom). Left in place, an - * edge blob merges into a glyph's column segment and corrupts the read. - */ - function clearBorderBlobs(band: Mat, threshold: number): void { - const bin = new cv.Mat(); - cv.threshold(band, bin, threshold, 255, cv.THRESH_BINARY); - const labels = new cv.Mat(); - const stats = new cv.Mat(); - const centroids = new cv.Mat(); - const count = cv.connectedComponentsWithStats(bin, labels, stats, centroids, 8); - bin.delete(); - centroids.delete(); - const s = stats.data32S; - const touchesBorder = new Uint8Array(count); - for (let i = 1; i < count; i++) { - const left = s[i * 5 + cv.CC_STAT_LEFT]!; - const top = s[i * 5 + cv.CC_STAT_TOP]!; - const right = left + s[i * 5 + cv.CC_STAT_WIDTH]!; - const bottom = top + s[i * 5 + cv.CC_STAT_HEIGHT]!; - touchesBorder[i] = - left === 0 || top === 0 || right === band.cols || bottom === band.rows ? 1 : 0; - } - stats.delete(); - const lab = labels.data32S; - const out = band.data; - for (let i = 0; i < out.length; i++) { - if (touchesBorder[lab[i]!]!) out[i] = 0; - } - labels.delete(); - } + /** + * Zero out ink components that touch the band border. Busy banner art + * (collages, prints) also differs from the median banner color, but the + * art always continues past the name band and so touches its edges, + * while the name is laid out inside it (fixture extremes: dakuten at + * y=3, a 'y' descender ending 2px above the bottom). Left in place, an + * edge blob merges into a glyph's column segment and corrupts the read. + */ + function clearBorderBlobs(band: Mat, threshold: number): void { + const bin = new cv.Mat(); + cv.threshold(band, bin, threshold, 255, cv.THRESH_BINARY); + const labels = new cv.Mat(); + const stats = new cv.Mat(); + const centroids = new cv.Mat(); + const count = cv.connectedComponentsWithStats( + bin, + labels, + stats, + centroids, + 8, + ); + bin.delete(); + centroids.delete(); + const s = stats.data32S; + const touchesBorder = new Uint8Array(count); + for (let i = 1; i < count; i++) { + const left = s[i * 5 + cv.CC_STAT_LEFT]!; + const top = s[i * 5 + cv.CC_STAT_TOP]!; + const right = left + s[i * 5 + cv.CC_STAT_WIDTH]!; + const bottom = top + s[i * 5 + cv.CC_STAT_HEIGHT]!; + touchesBorder[i] = + left === 0 || top === 0 || right === band.cols || bottom === band.rows + ? 1 + : 0; + } + stats.delete(); + const lab = labels.data32S; + const out = band.data; + for (let i = 0; i < out.length; i++) { + if (touchesBorder[lab[i]!]!) out[i] = 0; + } + labels.delete(); + } - function parse(frame: Mat, t: number): DetectedEvent[] { - const gray = new cv.Mat(); - cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); - const rgb = new cv.Mat(); - cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB); + function parse(frame: Mat, t: number): DetectedEvent[] { + const gray = new cv.Mat(); + cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); + const rgb = new cv.Mat(); + cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB); - const confidences: number[] = []; + const confidences: number[] = []; - // 1. read both burst lines and find the language template whose constant - // line reads back best — a gate hit matching none is a lookalike. - // Latin templates read the standard line boxes with the Latin atlas; - // JA templates read the swapped-width JA boxes (weapon line 1 wide, - // constant line 2 narrow — see rois.ts) with the JA atlas, so the two - // scripts never compete inside one glyph set. - let line1: RecognizedText | null = null; - let line2: RecognizedText | null = null; - let jaWeaponLine: RecognizedText | null = null; - let jaConstLine: RecognizedText | null = null; - let template: DeathMessageTemplate | null = null; - let line1Score = 0; - if (weaponGlyphs) { - const readLine = (roi: Roi, glyphs: GlyphSet) => { - const crop = cropRoi(gray, roi); - const read = recognizeText(crop, glyphs, { - binThreshold: SPLAT_TEXT_BIN_THRESHOLD, - minCharScore: 0.3, - }); - crop.delete(); - return read; - }; - line1 = readLine(SPLAT_LINE1_ROI, weaponGlyphs); - line2 = readLine(WEAPON_LINE_ROI, weaponGlyphs); - if (jaGlyphs) { - jaWeaponLine = readLine(JA_WEAPON_LINE_ROI, jaGlyphs); - jaConstLine = readLine(JA_CONST_LINE_ROI, jaGlyphs); - } - for (const t of DEATH_MESSAGE_TEMPLATES) { - let constReading: string; - if (isJaTemplate(t)) { - if (!jaConstLine) continue; - constReading = jaConstLine.text; - } else { - constReading = t.weaponLine === 1 ? line2.text : line1.text; - } - const score = closestEntry(constReading, [t.constText])?.score ?? 0; - if (score > line1Score) { - line1Score = score; - template = t; - } - } - if (!template || line1Score < LINE1_MIN_SCORE) { - gray.delete(); - rgb.delete(); - return []; - } - } + // 1. read both burst lines and find the language template whose constant + // line reads back best — a gate hit matching none is a lookalike. + // Latin templates read the standard line boxes with the Latin atlas; + // JA templates read the swapped-width JA boxes (weapon line 1 wide, + // constant line 2 narrow — see rois.ts) with the JA atlas, so the two + // scripts never compete inside one glyph set. + let line1: RecognizedText | null = null; + let line2: RecognizedText | null = null; + let jaWeaponLine: RecognizedText | null = null; + let jaConstLine: RecognizedText | null = null; + let template: DeathMessageTemplate | null = null; + let line1Score = 0; + if (weaponGlyphs) { + const readLine = (roi: Roi, glyphs: GlyphSet) => { + const crop = cropRoi(gray, roi); + const read = recognizeText(crop, glyphs, { + binThreshold: SPLAT_TEXT_BIN_THRESHOLD, + minCharScore: 0.3, + }); + crop.delete(); + return read; + }; + line1 = readLine(SPLAT_LINE1_ROI, weaponGlyphs); + line2 = readLine(WEAPON_LINE_ROI, weaponGlyphs); + if (jaGlyphs) { + jaWeaponLine = readLine(JA_WEAPON_LINE_ROI, jaGlyphs); + jaConstLine = readLine(JA_CONST_LINE_ROI, jaGlyphs); + } + for (const t of DEATH_MESSAGE_TEMPLATES) { + let constReading: string; + if (isJaTemplate(t)) { + if (!jaConstLine) continue; + constReading = jaConstLine.text; + } else { + constReading = t.weaponLine === 1 ? line2.text : line1.text; + } + const score = closestEntry(constReading, [t.constText])?.score ?? 0; + if (score > line1Score) { + line1Score = score; + template = t; + } + } + if (!template || line1Score < LINE1_MIN_SCORE) { + gray.delete(); + rgb.delete(); + return []; + } + } - // 2. the other line carries the weapon name; snap it to the template - // language's names (localized + canonical English) - let weapon: string | null = null; - let weaponId: DeathData["weaponId"] = null; - let weaponType: WeaponType | null = null; - let weaponScore = 0; - let weaponRaw: RecognizedText | null = null; - let plainRanked: { entry: WeaponCandidate; score: number }[] = []; - const accept = (entry: WeaponEntry, score: number) => { - weapon = entry.name; - weaponId = Number(entry.id) as NonNullable; - weaponType = entry.type; - weaponScore = score; - }; - if (weaponGlyphs && template) { - weaponRaw = isJaTemplate(template) ? jaWeaponLine : template.weaponLine === 1 ? line1 : line2; - const reading = weaponRaw!.text; - if (reading) plainRanked = rankBy(reading, candidatesFor(template), (c) => c.text); - const match = plainRanked[0]; - if (match) { - weaponScore = match.score; - if (match.score >= WEAPON_MIN_SCORE) accept(match.entry.entry, match.score); - } - } + // 2. the other line carries the weapon name; snap it to the template + // language's names (localized + canonical English) + let weapon: string | null = null; + let weaponId: DeathData["weaponId"] = null; + let weaponType: WeaponType | null = null; + let weaponScore = 0; + let weaponRaw: RecognizedText | null = null; + let plainRanked: { entry: WeaponCandidate; score: number }[] = []; + const accept = (entry: WeaponEntry, score: number) => { + weapon = entry.name; + weaponId = Number(entry.id) as NonNullable; + weaponType = entry.type; + weaponScore = score; + }; + if (weaponGlyphs && template) { + weaponRaw = isJaTemplate(template) + ? jaWeaponLine + : template.weaponLine === 1 + ? line1 + : line2; + const reading = weaponRaw!.text; + if (reading) + plainRanked = rankBy(reading, candidatesFor(template), (c) => c.text); + const match = plainRanked[0]; + if (match) { + weaponScore = match.score; + if (match.score >= WEAPON_MIN_SCORE) + accept(match.entry.entry, match.score); + } + } - // 2b. text can be unreadable while the burst's weapon icon is intact - // (the WIPEOUT banner covers the weapon name line), so fall back to - // matching the icon against the main-weapon set at burst size. Only a - // decisive match is trusted: fixture positives score 0.55+ while the - // best off-target frame (icon displaced by a rainmaker line) hits 0.48. - let burstIcon: WeaponMatch | null = null; - if (weapon === null && burstWeapons) { - const crop = cropRoi(rgb, BURST_ICON_ROI); - burstIcon = matchWeapon(crop, burstWeapons); - crop.delete(); - const entry = - burstIcon.score >= BURST_ICON_MIN_SCORE ? mainById.get(burstIcon.id) : undefined; - if (entry) accept(entry, burstIcon.score); - } + // 2b. text can be unreadable while the burst's weapon icon is intact + // (the WIPEOUT banner covers the weapon name line), so fall back to + // matching the icon against the main-weapon set at burst size. Only a + // decisive match is trusted: fixture positives score 0.55+ while the + // best off-target frame (icon displaced by a rainmaker line) hits 0.48. + let burstIcon: WeaponMatch | null = null; + if (weapon === null && burstWeapons) { + const crop = cropRoi(rgb, BURST_ICON_ROI); + burstIcon = matchWeapon(crop, burstWeapons); + crop.delete(); + const entry = + burstIcon.score >= BURST_ICON_MIN_SCORE + ? mainById.get(burstIcon.id) + : undefined; + if (entry) accept(entry, burstIcon.score); + } - // 2c. low-fidelity captures (720p upscaled to canonical) garble the - // per-segment top-1 read enough that the plain snap stays under - // WEAPON_MIN_SCORE, while the correct glyphs sit at rank 2-3 of the - // segments' candidate lists. Re-rank through those lists (rankByRead) - // and accept the top weapon when it clears the field decisively. - let latticeTop: { entry: WeaponEntry; score: number; margin: number } | null = null; - let latticeRanked: { entry: WeaponCandidate; score: number }[] = []; - if (weapon === null && weaponGlyphs && template && weaponRaw!.chars.length > 0) { - latticeRanked = rankByRead(weaponRaw!.chars, candidatesFor(template), (c) => c.text); - const top = latticeRanked[0]!; - // margin vs the nearest *other* weapon: the same weapon rides both - // its localized and English candidate lines - const runner = latticeRanked.find((r) => r.entry.entry !== top.entry.entry); - latticeTop = { - entry: top.entry.entry, - score: top.score, - margin: top.score - (runner?.score ?? 0), - }; - if (latticeTop.score >= LATTICE_MIN_SCORE && latticeTop.margin >= LATTICE_MIN_MARGIN) { - accept(latticeTop.entry, latticeTop.score); - } - } + // 2c. low-fidelity captures (720p upscaled to canonical) garble the + // per-segment top-1 read enough that the plain snap stays under + // WEAPON_MIN_SCORE, while the correct glyphs sit at rank 2-3 of the + // segments' candidate lists. Re-rank through those lists (rankByRead) + // and accept the top weapon when it clears the field decisively. + let latticeTop: { + entry: WeaponEntry; + score: number; + margin: number; + } | null = null; + let latticeRanked: { entry: WeaponCandidate; score: number }[] = []; + if ( + weapon === null && + weaponGlyphs && + template && + weaponRaw!.chars.length > 0 + ) { + latticeRanked = rankByRead( + weaponRaw!.chars, + candidatesFor(template), + (c) => c.text, + ); + const top = latticeRanked[0]!; + // margin vs the nearest *other* weapon: the same weapon rides both + // its localized and English candidate lines + const runner = latticeRanked.find( + (r) => r.entry.entry !== top.entry.entry, + ); + latticeTop = { + entry: top.entry.entry, + score: top.score, + margin: top.score - (runner?.score ?? 0), + }; + if ( + latticeTop.score >= LATTICE_MIN_SCORE && + latticeTop.margin >= LATTICE_MIN_MARGIN + ) { + accept(latticeTop.entry, latticeTop.score); + } + } - // 2d. neither signal is decisive alone, but if the burst icon's main - // weapon is also the text's best guess (plain or lattice ranking, - // within EPS of that ranking's top), the independent agreement is - // decisive together. - if (weapon === null && burstIcon && burstIcon.score >= BURST_ICON_CORROBORATE_MIN_SCORE) { - const entry = mainById.get(burstIcon.id); - if (entry) { - const bestFor = (ranked: { entry: WeaponCandidate; score: number }[]) => - ranked.reduce((s, r) => (r.entry.entry === entry ? Math.max(s, r.score) : s), 0); - const supported = - (plainRanked.length > 0 && - bestFor(plainRanked) >= plainRanked[0]!.score - CORROBORATE_EPS) || - (latticeRanked.length > 0 && - bestFor(latticeRanked) >= latticeRanked[0]!.score - CORROBORATE_EPS); - if (supported) accept(entry, burstIcon.score); - } - } - if (weaponGlyphs && template) confidences.push(weaponScore); + // 2d. neither signal is decisive alone, but if the burst icon's main + // weapon is also the text's best guess (plain or lattice ranking, + // within EPS of that ranking's top), the independent agreement is + // decisive together. + if ( + weapon === null && + burstIcon && + burstIcon.score >= BURST_ICON_CORROBORATE_MIN_SCORE + ) { + const entry = mainById.get(burstIcon.id); + if (entry) { + const bestFor = (ranked: { entry: WeaponCandidate; score: number }[]) => + ranked.reduce( + (s, r) => (r.entry.entry === entry ? Math.max(s, r.score) : s), + 0, + ); + const supported = + (plainRanked.length > 0 && + bestFor(plainRanked) >= plainRanked[0]!.score - CORROBORATE_EPS) || + (latticeRanked.length > 0 && + bestFor(latticeRanked) >= + latticeRanked[0]!.score - CORROBORATE_EPS); + if (supported) accept(entry, burstIcon.score); + } + } + if (weaponGlyphs && template) confidences.push(weaponScore); - // 3. ability grid; rows carry 1-3 sub circles (left-aligned, as many - // as the gear has slots), so a sub box without badge ink ends the row - const abilityRows: CvAbility[][] = []; - const abilityDebug: (WeaponMatch | null)[][] = []; - if (abilities) { - for (let row = 0; row < ABILITY_ROWS; row++) { - const ids: CvAbility[] = []; - const debug: (WeaponMatch | null)[] = []; - const mainCrop = cropRoi(rgb, abilityMainRoi(row)); - const main = matchWeapon(mainCrop, abilities.mains, { - inkThreshold: ABILITY_INK_THRESHOLD, - }); - mainCrop.delete(); - ids.push(toCvAbility(main.id) ?? "UNKNOWN"); - debug.push(main); - confidences.push(Math.max(0, main.score)); - for (let slot = 0; slot < ABILITY_SUB_XS.length; slot++) { - const crop = copyRoi(rgb, abilitySubRoi(row, slot)); - const d = crop.data; - const n = crop.rows * crop.cols; - let ink = 0; - for (let i = 0; i < n; i++) { - const v = Math.max(d[i * 3]!, d[i * 3 + 1]!, d[i * 3 + 2]!); - if (v > ABILITY_INK_THRESHOLD) ink++; - } - if (ink < ABILITY_SLOT_MIN_INK) { - crop.delete(); - break; - } - const sub = matchWeapon(crop, abilities.subs, { - inkThreshold: ABILITY_INK_THRESHOLD, - }); - crop.delete(); - ids.push(toCvAbility(sub.id) ?? "UNKNOWN"); - debug.push(sub); - confidences.push(Math.max(0, sub.score)); - } - abilityRows.push(ids); - abilityDebug.push(debug); - } - } + // 3. ability grid; rows carry 1-3 sub circles (left-aligned, as many + // as the gear has slots), so a sub box without badge ink ends the row + const abilityRows: CvAbility[][] = []; + const abilityDebug: (WeaponMatch | null)[][] = []; + if (abilities) { + for (let row = 0; row < ABILITY_ROWS; row++) { + const ids: CvAbility[] = []; + const debug: (WeaponMatch | null)[] = []; + const mainCrop = cropRoi(rgb, abilityMainRoi(row)); + const main = matchWeapon(mainCrop, abilities.mains, { + inkThreshold: ABILITY_INK_THRESHOLD, + }); + mainCrop.delete(); + ids.push(toCvAbility(main.id) ?? "UNKNOWN"); + debug.push(main); + confidences.push(Math.max(0, main.score)); + for (let slot = 0; slot < ABILITY_SUB_XS.length; slot++) { + const crop = copyRoi(rgb, abilitySubRoi(row, slot)); + const d = crop.data; + const n = crop.rows * crop.cols; + let ink = 0; + for (let i = 0; i < n; i++) { + const v = Math.max(d[i * 3]!, d[i * 3 + 1]!, d[i * 3 + 2]!); + if (v > ABILITY_INK_THRESHOLD) ink++; + } + if (ink < ABILITY_SLOT_MIN_INK) { + crop.delete(); + break; + } + const sub = matchWeapon(crop, abilities.subs, { + inkThreshold: ABILITY_INK_THRESHOLD, + }); + crop.delete(); + ids.push(toCvAbility(sub.id) ?? "UNKNOWN"); + debug.push(sub); + confidences.push(Math.max(0, sub.score)); + } + abilityRows.push(ids); + abilityDebug.push(debug); + } + } - // 4. splash-tag name, two passes per background candidate: distance - // from the estimated banner color, then — because busy banner art - // (collages, prints) also differs from that estimate, merging with - // glyphs and surviving as fake ones — closeness to the text color - // estimated from pass 1's ink. Whichever pass reads back more - // confidently wins: solid banners stay on pass 1, art-heavy banners - // recover on pass 2. Both background estimators run (see - // dominantColor) and the better-reading candidate wins the same way. - let name: string | null = null; - let nameConfidence = 0; - let nameRaw = ""; - let tagBackground: [number, number, number] | null = null; - let tagTextColor: [number, number, number] | null = null; - if (tagNameGlyphs) { - const spaceGap = Math.max(7, Math.round(tagNameGlyphs.medianWidth * 0.55)); - const inner = levelTagInner(rgb); - const readWithBackground = (backgrounds: readonly [number, number, number][]) => { - const band = distanceBand(inner, backgrounds, false); - cv.normalize(band, band, 0, 255, cv.NORM_MINMAX); - clearBorderBlobs(band, TAG_NAME_BIN_THRESHOLD); - let parsed = parseName(band, tagNameGlyphs, { - spaceGap, - binThreshold: TAG_NAME_BIN_THRESHOLD, - }); + // 4. splash-tag name, two passes per background candidate: distance + // from the estimated banner color, then — because busy banner art + // (collages, prints) also differs from that estimate, merging with + // glyphs and surviving as fake ones — closeness to the text color + // estimated from pass 1's ink. Whichever pass reads back more + // confidently wins: solid banners stay on pass 1, art-heavy banners + // recover on pass 2. Both background estimators run (see + // dominantColor) and the better-reading candidate wins the same way. + let name: string | null = null; + let nameConfidence = 0; + let nameRaw = ""; + let tagBackground: [number, number, number] | null = null; + let tagTextColor: [number, number, number] | null = null; + if (tagNameGlyphs) { + const spaceGap = Math.max( + 7, + Math.round(tagNameGlyphs.medianWidth * 0.55), + ); + const inner = levelTagInner(rgb); + const readWithBackground = ( + backgrounds: readonly [number, number, number][], + ) => { + const band = distanceBand(inner, backgrounds, false); + cv.normalize(band, band, 0, 255, cv.NORM_MINMAX); + clearBorderBlobs(band, TAG_NAME_BIN_THRESHOLD); + let parsed = parseName(band, tagNameGlyphs, { + spaceGap, + binThreshold: TAG_NAME_BIN_THRESHOLD, + }); - let textColor: [number, number, number] | null = null; - const ink = band.data; - let inkCount = 0; - for (let i = 0; i < ink.length; i++) if (ink[i]! > TAG_NAME_BIN_THRESHOLD) inkCount++; - if (inkCount >= TAG_NAME_REFINE_MIN_INK) { - textColor = medianColor(inner, (i) => ink[i]! > TAG_NAME_BIN_THRESHOLD); - const refined = distanceBand(inner, [textColor], true); - clearBorderBlobs(refined, TAG_NAME_REFINE_BIN_THRESHOLD); - const reparsed = parseName(refined, tagNameGlyphs, { - spaceGap, - binThreshold: TAG_NAME_REFINE_BIN_THRESHOLD, - }); - refined.delete(); - if (reparsed.confidence > parsed.confidence) parsed = reparsed; - } - band.delete(); - return { parsed, background: backgrounds[0]!, textColor }; - }; + let textColor: [number, number, number] | null = null; + const ink = band.data; + let inkCount = 0; + for (let i = 0; i < ink.length; i++) + if (ink[i]! > TAG_NAME_BIN_THRESHOLD) inkCount++; + if (inkCount >= TAG_NAME_REFINE_MIN_INK) { + textColor = medianColor( + inner, + (i) => ink[i]! > TAG_NAME_BIN_THRESHOLD, + ); + const refined = distanceBand(inner, [textColor], true); + clearBorderBlobs(refined, TAG_NAME_REFINE_BIN_THRESHOLD); + const reparsed = parseName(refined, tagNameGlyphs, { + spaceGap, + binThreshold: TAG_NAME_REFINE_BIN_THRESHOLD, + }); + refined.delete(); + if (reparsed.confidence > parsed.confidence) parsed = reparsed; + } + band.delete(); + return { parsed, background: backgrounds[0]!, textColor }; + }; - const median = medianColor(inner); - const dominants = dominantColors(inner, 2); - const dominant = dominants[0]!.color; - const candidates: [number, number, number][][] = [[median]]; - if (dominant.some((c, i) => Math.abs(c - median[i]!) > 8)) candidates.push([dominant]); - const second = dominants[1]; - if ( - second && - second.fraction >= TAG_SPLIT_MIN_FRACTION && - second.color.some((c, i) => Math.abs(c - dominant[i]!) > TAG_SPLIT_MIN_CHANNEL_DISTANCE) - ) { - candidates.push([dominant, second.color]); - } - // an empty read never beats one that produced glyphs (an estimate - // landing on the text color blanks the whole band, and recognizeText - // reports a segment-less band as confidence 1), and near-tied - // confidences resolve to the longer read: confidence is the *min* - // char score, so a background estimate that erases most of the name - // can still read its two surviving glyphs immaculately — more - // recognized glyphs is the better read when neither is clearly worse - const NEAR_TIE = 0.03; - const beats = (a: { parsed: { name: string; confidence: number } }, b: typeof a) => { - const aRead = a.parsed.name.length > 0 ? 1 : 0; - const bRead = b.parsed.name.length > 0 ? 1 : 0; - if (aRead !== bRead) return aRead - bRead; - if (Math.abs(a.parsed.confidence - b.parsed.confidence) <= NEAR_TIE) { - return a.parsed.name.length - b.parsed.name.length; - } - return a.parsed.confidence - b.parsed.confidence; - }; - let best = readWithBackground(candidates[0]!); - for (const backgrounds of candidates.slice(1)) { - const alt = readWithBackground(backgrounds); - if (beats(alt, best) > 0) best = alt; - } - inner.delete(); + const median = medianColor(inner); + const dominants = dominantColors(inner, 2); + const dominant = dominants[0]!.color; + const candidates: [number, number, number][][] = [[median]]; + if (dominant.some((c, i) => Math.abs(c - median[i]!) > 8)) + candidates.push([dominant]); + const second = dominants[1]; + if ( + second && + second.fraction >= TAG_SPLIT_MIN_FRACTION && + second.color.some( + (c, i) => Math.abs(c - dominant[i]!) > TAG_SPLIT_MIN_CHANNEL_DISTANCE, + ) + ) { + candidates.push([dominant, second.color]); + } + // an empty read never beats one that produced glyphs (an estimate + // landing on the text color blanks the whole band, and recognizeText + // reports a segment-less band as confidence 1), and near-tied + // confidences resolve to the longer read: confidence is the *min* + // char score, so a background estimate that erases most of the name + // can still read its two surviving glyphs immaculately — more + // recognized glyphs is the better read when neither is clearly worse + const NEAR_TIE = 0.03; + const beats = ( + a: { parsed: { name: string; confidence: number } }, + b: typeof a, + ) => { + const aRead = a.parsed.name.length > 0 ? 1 : 0; + const bRead = b.parsed.name.length > 0 ? 1 : 0; + if (aRead !== bRead) return aRead - bRead; + if (Math.abs(a.parsed.confidence - b.parsed.confidence) <= NEAR_TIE) { + return a.parsed.name.length - b.parsed.name.length; + } + return a.parsed.confidence - b.parsed.confidence; + }; + let best = readWithBackground(candidates[0]!); + for (const backgrounds of candidates.slice(1)) { + const alt = readWithBackground(backgrounds); + if (beats(alt, best) > 0) best = alt; + } + inner.delete(); - tagBackground = best.background; - tagTextColor = best.textColor; - nameRaw = best.parsed.raw.text; - if (best.parsed.name.length > 0) name = best.parsed.name; - nameConfidence = best.parsed.confidence; - confidences.push(nameConfidence); - } + tagBackground = best.background; + tagTextColor = best.textColor; + nameRaw = best.parsed.raw.text; + if (best.parsed.name.length > 0) name = best.parsed.name; + nameConfidence = best.parsed.confidence; + confidences.push(nameConfidence); + } - gray.delete(); - rgb.delete(); + gray.delete(); + rgb.delete(); - const confidence = - confidences.length > 0 ? confidences.reduce((a, b) => a + b, 0) / confidences.length : 0; + const confidence = + confidences.length > 0 + ? confidences.reduce((a, b) => a + b, 0) / confidences.length + : 0; - return [ - { - type: DEATH_EVENT_TYPE, - t, - confidence, - data: { weaponId, weaponType, abilities: abilityRows, name }, - debug: { - weaponName: weapon, - line1: line1?.text, - line2: line2?.text, - jaWeaponLine: jaWeaponLine?.text, - jaConstLine: jaConstLine?.text, - line1Score, - messageLangs: template?.langs, - weaponRaw: weaponRaw?.text, - weaponScore, - weaponLattice: latticeTop && { - name: latticeTop.entry.name, - score: latticeTop.score, - margin: latticeTop.margin, - }, - burstIcon: burstIcon && { id: burstIcon.id, score: burstIcon.score, top: burstIcon.top }, - abilityRows: abilityDebug.map((row) => - row.map((m) => m && { top: m.top, score: m.score }), - ), - nameRaw, - nameScore: nameConfidence, - tagBackground, - tagTextColor, - }, - }, - ]; - } + return [ + { + type: DEATH_EVENT_TYPE, + t, + confidence, + data: { weaponId, weaponType, abilities: abilityRows, name }, + debug: { + weaponName: weapon, + line1: line1?.text, + line2: line2?.text, + jaWeaponLine: jaWeaponLine?.text, + jaConstLine: jaConstLine?.text, + line1Score, + messageLangs: template?.langs, + weaponRaw: weaponRaw?.text, + weaponScore, + weaponLattice: latticeTop && { + name: latticeTop.entry.name, + score: latticeTop.score, + margin: latticeTop.margin, + }, + burstIcon: burstIcon && { + id: burstIcon.id, + score: burstIcon.score, + top: burstIcon.top, + }, + abilityRows: abilityDebug.map((row) => + row.map((m) => m && { top: m.top, score: m.score }), + ), + nameRaw, + nameScore: nameConfidence, + tagBackground, + tagTextColor, + }, + }, + ]; + } - return { id: "death", gate, parse }; + return { id: "death", gate, parse }; } diff --git a/app/features/cv/core/detectors/death/localized-messages.ts b/app/features/cv/core/detectors/death/localized-messages.ts index 792624ad1..d38f9d934 100644 --- a/app/features/cv/core/detectors/death/localized-messages.ts +++ b/app/features/cv/core/detectors/death/localized-messages.ts @@ -7,142 +7,115 @@ */ export interface DeathMessageTemplate { - /** languages sharing this exact template */ - langs: readonly string[]; - /** which burst line carries the weapon name */ - weaponLine: 1 | 2; - /** the constant text on the other line (parse-time confirmation) */ - constText: string; - /** constant text around the weapon name on its own line */ - weaponPre: string; - weaponPost: string; + /** languages sharing this exact template */ + langs: readonly string[]; + /** which burst line carries the weapon name */ + weaponLine: 1 | 2; + /** the constant text on the other line (parse-time confirmation) */ + constText: string; + /** constant text around the weapon name on its own line */ + weaponPre: string; + weaponPost: string; } export const DEATH_MESSAGE_TEMPLATES: readonly DeathMessageTemplate[] = [ - { - "langs": [ - "CNzh" - ], - "weaponLine": 1, - "constText": "而阵亡!", - "weaponPre": "因", - "weaponPost": "" - }, - { - "langs": [ - "EUde" - ], - "weaponLine": 1, - "constText": "erledigt!", - "weaponPre": "Durch ", - "weaponPost": "" - }, - { - "langs": [ - "EUen", - "USen" - ], - "weaponLine": 2, - "constText": "Splatted by", - "weaponPre": "", - "weaponPost": "!" - }, - { - "langs": [ - "EUes" - ], - "weaponLine": 2, - "constText": "Te han liquidado con:", - "weaponPre": "", - "weaponPost": "" - }, - { - "langs": [ - "EUfr" - ], - "weaponLine": 2, - "constText": "Élimination par", - "weaponPre": "", - "weaponPost": " !" - }, - { - "langs": [ - "EUit" - ], - "weaponLine": 2, - "constText": "Arma dello splat:", - "weaponPre": "", - "weaponPost": "!" - }, - { - "langs": [ - "EUnl" - ], - "weaponLine": 2, - "constText": "Uitgeschakeld door:", - "weaponPre": "", - "weaponPost": "!" - }, - { - "langs": [ - "EUru" - ], - "weaponLine": 2, - "constText": "Тебя плюхнули оружием", - "weaponPre": "\"", - "weaponPost": "\"!" - }, - { - "langs": [ - "JPja" - ], - "weaponLine": 1, - "constText": "やられた!", - "weaponPre": "", - "weaponPost": " で" - }, - { - "langs": [ - "KRko" - ], - "weaponLine": 1, - "constText": "당했다!", - "weaponPre": "", - "weaponPost": "에" - }, - { - "langs": [ - "TWzh" - ], - "weaponLine": 1, - "constText": "陣亡!", - "weaponPre": "遭", - "weaponPost": "擊倒" - }, - { - "langs": [ - "USes" - ], - "weaponLine": 2, - "constText": "Te reventaron con:", - "weaponPre": "", - "weaponPost": "" - }, - { - "langs": [ - "USfr" - ], - "weaponLine": 2, - "constText": "Élimination par", - "weaponPre": "", - "weaponPost": "!" - } + { + langs: ["CNzh"], + weaponLine: 1, + constText: "而阵亡!", + weaponPre: "因", + weaponPost: "", + }, + { + langs: ["EUde"], + weaponLine: 1, + constText: "erledigt!", + weaponPre: "Durch ", + weaponPost: "", + }, + { + langs: ["EUen", "USen"], + weaponLine: 2, + constText: "Splatted by", + weaponPre: "", + weaponPost: "!", + }, + { + langs: ["EUes"], + weaponLine: 2, + constText: "Te han liquidado con:", + weaponPre: "", + weaponPost: "", + }, + { + langs: ["EUfr"], + weaponLine: 2, + constText: "Élimination par", + weaponPre: "", + weaponPost: " !", + }, + { + langs: ["EUit"], + weaponLine: 2, + constText: "Arma dello splat:", + weaponPre: "", + weaponPost: "!", + }, + { + langs: ["EUnl"], + weaponLine: 2, + constText: "Uitgeschakeld door:", + weaponPre: "", + weaponPost: "!", + }, + { + langs: ["EUru"], + weaponLine: 2, + constText: "Тебя плюхнули оружием", + weaponPre: '"', + weaponPost: '"!', + }, + { + langs: ["JPja"], + weaponLine: 1, + constText: "やられた!", + weaponPre: "", + weaponPost: " で", + }, + { + langs: ["KRko"], + weaponLine: 1, + constText: "당했다!", + weaponPre: "", + weaponPost: "에", + }, + { + langs: ["TWzh"], + weaponLine: 1, + constText: "陣亡!", + weaponPre: "遭", + weaponPost: "擊倒", + }, + { + langs: ["USes"], + weaponLine: 2, + constText: "Te reventaron con:", + weaponPre: "", + weaponPost: "", + }, + { + langs: ["USfr"], + weaponLine: 2, + constText: "Élimination par", + weaponPre: "", + weaponPost: "!", + }, ]; export interface LocalizedWeaponName { - text: string; - /** canonical English entry name (weapon-names.ts) */ - name: string; + text: string; + /** canonical English entry name (weapon-names.ts) */ + name: string; } /** @@ -150,9794 +123,9794 @@ export interface LocalizedWeaponName { * (identical ones are omitted; match against the canonical set too). */ export const LOCALIZED_WEAPON_NAMES: Readonly< - Record + Record > = { - "CNzh": [ - { - "text": "快速爆破枪 精英", - "name": "Rapid Blaster Pro" - }, - { - "text": "快速爆破枪 精英装饰", - "name": "Rapid Blaster Pro Deco" - }, - { - "text": "快速爆破枪 精英WNTR", - "name": "Rapid Blaster Pro WNT-R" - }, - { - "text": "冲涂爆破枪", - "name": "Clash Blaster" - }, - { - "text": "冲涂爆破枪 新型", - "name": "Clash Blaster Neo" - }, - { - "text": "快速爆破枪", - "name": "Rapid Blaster" - }, - { - "text": "快速爆破枪 装饰", - "name": "Rapid Blaster Deco" - }, - { - "text": "远距爆破枪", - "name": "Range Blaster" - }, - { - "text": "远距爆破枪 改装", - "name": "Custom Range Blaster" - }, - { - "text": "火热爆破枪", - "name": "Blaster" - }, - { - "text": "火热爆破枪 改装", - "name": "Custom Blaster" - }, - { - "text": "火热爆破枪 艳", - "name": "Gleamz Blaster" - }, - { - "text": "S-BLAST92", - "name": "S-BLAST '92" - }, - { - "text": "S-BLAST91", - "name": "S-BLAST '91" - }, - { - "text": "新星爆破枪", - "name": "Luna Blaster" - }, - { - "text": "新星爆破枪 新型", - "name": "Luna Blaster Neo" - }, - { - "text": "秩序爆破枪 复制", - "name": "Order Blaster Replica" - }, - { - "text": "文森", - "name": "Painbrush" - }, - { - "text": "文森·新艺术", - "name": "Painbrush Nouveau" - }, - { - "text": "文森BRNZ", - "name": "Painbrush BRN-Z" - }, - { - "text": "巴勃罗", - "name": "Inkbrush" - }, - { - "text": "巴勃罗·新艺术", - "name": "Inkbrush Nouveau" - }, - { - "text": "北斋", - "name": "Octobrush" - }, - { - "text": "北斋·新艺术", - "name": "Octobrush Nouveau" - }, - { - "text": "北斋 彗", - "name": "Cometz Octobrush" - }, - { - "text": "秩序排刷 复制", - "name": "Orderbrush Replica" - }, - { - "text": "高压油管枪", - "name": "Goo Tuber" - }, - { - "text": "高压油管枪 改装", - "name": "Custom Goo Tuber" - }, - { - "text": "14式竹筒枪·甲", - "name": "Bamboozler 14 Mk I" - }, - { - "text": "14式竹筒枪·乙", - "name": "Bamboozler 14 Mk II" - }, - { - "text": "4K准星枪", - "name": "E-liter 4K Scope" - }, - { - "text": "4K准星枪 改装", - "name": "Custom E-liter 4K Scope" - }, - { - "text": "公升4K", - "name": "E-liter 4K" - }, - { - "text": "公升4K 改装", - "name": "Custom E-liter 4K" - }, - { - "text": "斯普拉准星枪", - "name": "Splatterscope" - }, - { - "text": "斯普拉准星枪 联名", - "name": "Z+F Splatterscope" - }, - { - "text": "斯普拉准星枪FRST", - "name": "Splatterscope CAM-O" - }, - { - "text": "斯普拉蓄力狙击枪", - "name": "Splat Charger" - }, - { - "text": "斯普拉蓄力狙击枪 联名", - "name": "Z+F Splat Charger" - }, - { - "text": "斯普拉蓄力狙击枪FRST", - "name": "Splat Charger CAM-O" - }, - { - "text": "秩序蓄力狙击枪 复制", - "name": "Order Charger Replica" - }, - { - "text": "R-PEN/5H", - "name": "Snipewriter 5H" - }, - { - "text": "R-PEN/5B", - "name": "Snipewriter 5B" - }, - { - "text": "鱿快洁α", - "name": "Classic Squiffer" - }, - { - "text": "鱿快洁β", - "name": "New Squiffer" - }, - { - "text": "双重清洁枪", - "name": "Dualie Squelchers" - }, - { - "text": "双重清洁枪 改装", - "name": "Custom Dualie Squelchers" - }, - { - "text": "双重清洁枪 蹄", - "name": "Hoofz Dualie Squelchers" - }, - { - "text": "开尔文525", - "name": "Glooga Dualies" - }, - { - "text": "开尔文525 装饰", - "name": "Glooga Dualies Deco" - }, - { - "text": "灭熄FF", - "name": "Douser Dualies FF" - }, - { - "text": "灭熄FF 改装", - "name": "Custom Douser Dualies FF" - }, - { - "text": "斯普拉机动枪", - "name": "Splat Dualies" - }, - { - "text": "斯普拉机动枪 联名", - "name": "Enperry Splat Dualies" - }, - { - "text": "斯普拉机动枪 耀", - "name": "Twinklez Splat Dualies" - }, - { - "text": "秩序机动枪 复制", - "name": "Order Dualie Replicas" - }, - { - "text": "溅镀枪", - "name": "Dapple Dualies" - }, - { - "text": "溅镀枪·新艺术", - "name": "Dapple Dualies Nouveau" - }, - { - "text": "溅镀枪OWL", - "name": "Dapple Dualies NOC-T" - }, - { - "text": "四重弹跳手枪 黑", - "name": "Dark Tetra Dualies" - }, - { - "text": "四重弹跳手枪 白", - "name": "Light Tetra Dualies" - }, - { - "text": "碳纤维滚筒", - "name": "Carbon Roller" - }, - { - "text": "碳纤维滚筒 装饰", - "name": "Carbon Roller Deco" - }, - { - "text": "碳纤维滚筒ANGL", - "name": "Carbon Roller ANG-L" - }, - { - "text": "电动马达滚筒", - "name": "Dynamo Roller" - }, - { - "text": "电动马达滚筒 高磁波", - "name": "Gold Dynamo Roller" - }, - { - "text": "电动马达滚筒 冥", - "name": "Starz Dynamo Roller" - }, - { - "text": "可变式滚筒", - "name": "Flingza Roller" - }, - { - "text": "可变式滚筒 金属箔", - "name": "Foil Flingza Roller" - }, - { - "text": "斯普拉滚筒", - "name": "Splat Roller" - }, - { - "text": "斯普拉滚筒 联名", - "name": "Krak-On Splat Roller" - }, - { - "text": "秩序滚筒 复制", - "name": "Order Roller Replica" - }, - { - "text": "宽滚筒", - "name": "Big Swig Roller" - }, - { - "text": "宽滚筒 联名", - "name": "Big Swig Roller Express" - }, - { - "text": "宽滚筒 惑", - "name": "Planetz Big Swig Roller" - }, - { - "text": "巨齿刮水刀 薄荷", - "name": "Mint Decavitator" - }, - { - "text": "巨齿刮水刀 墨黑", - "name": "Charcoal Decavitator" - }, - { - "text": "雨刷刮水刀", - "name": "Splatana Wiper" - }, - { - "text": "雨刷刮水刀 装饰", - "name": "Splatana Wiper Deco" - }, - { - "text": "雨刷刮水刀RUST", - "name": "Splatana Wiper RUS-T" - }, - { - "text": "工作刮水刀", - "name": "Splatana Stamper" - }, - { - "text": "工作刮水刀·新艺术", - "name": "Splatana Stamper Nouveau" - }, - { - "text": "工作刮水刀 封", - "name": "Stickerz Splatana Stamper" - }, - { - "text": "秩序刮水刀 复制", - "name": "Order Splatana Replica" - }, - { - "text": "特工配件", - "name": "Undercover Brella" - }, - { - "text": "特工配件 姐妹", - "name": "Undercover Sorella Brella" - }, - { - "text": "特工配件 缭", - "name": "Patternz Undercover Brella" - }, - { - "text": "24式可替换伞·甲", - "name": "Recycled Brella 24 Mk I" - }, - { - "text": "24式可替换伞·乙", - "name": "Recycled Brella 24 Mk II" - }, - { - "text": "遮阳防空伞", - "name": "Splat Brella" - }, - { - "text": "遮阳防空伞 姐妹", - "name": "Sorella Brella" - }, - { - "text": "秩序防空伞 复制", - "name": "Order Brella Replica" - }, - { - "text": "露营防空伞", - "name": "Tenta Brella" - }, - { - "text": "露营防空伞 姐妹", - "name": "Tenta Sorella Brella" - }, - { - "text": "露营防空伞CREM", - "name": "Tenta Brella CRE-M" - }, - { - "text": "专业模型枪MG", - "name": "Aerospray MG" - }, - { - "text": "专业模型枪RG", - "name": "Aerospray RG" - }, - { - "text": "专业模型枪 彩", - "name": "Colorz Aerospray" - }, - { - "text": "顶尖射击枪", - "name": "Splattershot Pro" - }, - { - "text": "顶尖射击枪 联名", - "name": "Forge Splattershot Pro" - }, - { - "text": "顶尖射击枪FRZN", - "name": "Splattershot Pro FRZ-N" - }, - { - "text": "新叶射击枪", - "name": "Splattershot Jr." - }, - { - "text": "枫叶射击枪", - "name": "Custom Splattershot Jr." - }, - { - "text": "开瓶喷泉枪", - "name": "Squeezer" - }, - { - "text": "开瓶喷泉枪 金属箔", - "name": "Foil Squeezer" - }, - { - "text": ".52加仑", - "name": ".52 Gal" - }, - { - "text": ".52加仑 装饰", - "name": ".52 Gal Deco" - }, - { - "text": ".96加仑", - "name": ".96 Gal" - }, - { - "text": ".96加仑 装饰", - "name": ".96 Gal Deco" - }, - { - "text": ".96加仑 爪", - "name": "Clawz .96 Gal" - }, - { - "text": "喷射清洁枪", - "name": "Jet Squelcher" - }, - { - "text": "喷射清洁枪 改装", - "name": "Custom Jet Squelcher" - }, - { - "text": "喷射清洁枪COBR", - "name": "Jet Squelcher COB-R" - }, - { - "text": "斯普拉射击枪", - "name": "Splattershot" - }, - { - "text": "斯普拉射击枪 联名", - "name": "Tentatek Splattershot" - }, - { - "text": "斯普拉射击枪 煌", - "name": "Glamorz Splattershot" - }, - { - "text": "英雄射击枪 复制", - "name": "Hero Shot Replica" - }, - { - "text": "秩序射击枪 复制", - "name": "Order Shot Replica" - }, - { - "text": "章鱼射击枪 复制", - "name": "Octo Shot Replica" - }, - { - "text": "窄域标记枪", - "name": "Splash-o-matic" - }, - { - "text": "窄域标记枪 新型", - "name": "Neo Splash-o-matic" - }, - { - "text": "窄域标记枪GECK", - "name": "Splash-o-matic GCK-O" - }, - { - "text": "太空射击枪", - "name": "Splattershot Nova" - }, - { - "text": "太空射击枪 联名", - "name": "Annaki Splattershot Nova" - }, - { - "text": "N-ZAP85", - "name": "N-ZAP '85" - }, - { - "text": "N-ZAP89", - "name": "N-ZAP '89" - }, - { - "text": "广域标记枪", - "name": "Sploosh-o-matic" - }, - { - "text": "广域标记枪 新型", - "name": "Neo Sploosh-o-matic" - }, - { - "text": "H3卷管枪", - "name": "H-3 Nozzlenose" - }, - { - "text": "H3卷管枪D", - "name": "H-3 Nozzlenose D" - }, - { - "text": "H3卷管枪SNAK", - "name": "H-3 Nozzlenose VIP-R" - }, - { - "text": "L3卷管枪", - "name": "L-3 Nozzlenose" - }, - { - "text": "L3卷管枪D", - "name": "L-3 Nozzlenose D" - }, - { - "text": "L3卷管枪 箔", - "name": "Glitterz L-3 Nozzlenose" - }, - { - "text": "满溢泡澡泼桶", - "name": "Bloblobber" - }, - { - "text": "满溢泡澡泼桶 装饰", - "name": "Bloblobber Deco" - }, - { - "text": "洗笔桶", - "name": "Tri-Slosher" - }, - { - "text": "洗笔桶·新艺术", - "name": "Tri-Slosher Nouveau" - }, - { - "text": "洗笔桶ASH", - "name": "Tri-Slosher ASH-N" - }, - { - "text": "墨瀑淋", - "name": "Dread Wringer" - }, - { - "text": "墨瀑淋D", - "name": "Dread Wringer D" - }, - { - "text": "墨瀑淋 角", - "name": "Hornz Dread Wringer" - }, - { - "text": "回旋泼桶", - "name": "Sloshing Machine" - }, - { - "text": "回旋泼桶 新型", - "name": "Sloshing Machine Neo" - }, - { - "text": "飞溅泼桶", - "name": "Slosher" - }, - { - "text": "飞溅泼桶 装饰", - "name": "Slosher Deco" - }, - { - "text": "秩序泼桶 复制", - "name": "Order Slosher Replica" - }, - { - "text": "爆炸泼桶", - "name": "Explosher" - }, - { - "text": "爆炸泼桶 改装", - "name": "Custom Explosher" - }, - { - "text": "圆珠笔", - "name": "Ballpoint Splatling" - }, - { - "text": "圆珠笔·新艺术", - "name": "Ballpoint Splatling Nouveau" - }, - { - "text": "审查者", - "name": "Heavy Edit Splatling" - }, - { - "text": "审查者·新艺术", - "name": "Heavy Edit Splatling Nouveau" - }, - { - "text": "消防栓旋转枪", - "name": "Hydra Splatling" - }, - { - "text": "消防栓旋转枪 改装", - "name": "Custom Hydra Splatling" - }, - { - "text": "消防栓旋转枪 压", - "name": "Torrentz Hydra Splatling" - }, - { - "text": "斯普拉旋转枪", - "name": "Mini Splatling" - }, - { - "text": "斯普拉旋转枪 联名", - "name": "Zink Mini Splatling" - }, - { - "text": "斯普拉旋转枪PYTN", - "name": "Mini Splatling RTL-R" - }, - { - "text": "鹦鹉螺号47", - "name": "Nautilus 47" - }, - { - "text": "鹦鹉螺号79", - "name": "Nautilus 79" - }, - { - "text": "桶装旋转枪", - "name": "Heavy Splatling" - }, - { - "text": "桶装旋转枪 装饰", - "name": "Heavy Splatling Deco" - }, - { - "text": "秩序旋转枪 复制", - "name": "Order Splatling Replica" - }, - { - "text": "邦普V", - "name": "Wellstring V" - }, - { - "text": "邦普V 改装", - "name": "Custom Wellstring V" - }, - { - "text": "三发猎鱼弓", - "name": "Tri-Stringer" - }, - { - "text": "三发猎鱼弓 联名", - "name": "Inkline Tri-Stringer" - }, - { - "text": "三发猎鱼弓 灯", - "name": "Bulbz Tri-Stringer" - }, - { - "text": "秩序猎鱼弓 复制", - "name": "Order Stringer Replica" - }, - { - "text": "LACT-450", - "name": "REEF-LUX 450" - }, - { - "text": "LACT-450 装饰", - "name": "REEF-LUX 450 Deco" - }, - { - "text": "LACT-450MILK", - "name": "REEF-LUX 450 MIL-K" - }, - { - "text": "跳跃信标", - "name": "Squid Beakon" - }, - { - "text": "冰壶炸弹", - "name": "Curling Bomb" - }, - { - "text": "碳酸炸弹", - "name": "Fizzy Bomb" - }, - { - "text": "快速炸弹", - "name": "Burst Bomb" - }, - { - "text": "机器人炸弹", - "name": "Autobomb" - }, - { - "text": "斯普拉炸弹", - "name": "Splat Bomb" - }, - { - "text": "吸盘炸弹", - "name": "Suction Bomb" - }, - { - "text": "鱼雷", - "name": "Torpedo" - }, - { - "text": "标线器", - "name": "Angle Shooter" - }, - { - "text": "定点侦测器", - "name": "Point Sensor" - }, - { - "text": "毒雾", - "name": "Toxic Mist" - }, - { - "text": "斯普拉防护墙", - "name": "Splash Wall" - }, - { - "text": "洒墨器", - "name": "Sprinkler" - }, - { - "text": "墨汁陷阱", - "name": "Ink Mine" - }, - { - "text": "吸墨机", - "name": "Ink Vac" - }, - { - "text": "帝王鱿鱼", - "name": "Kraken Royale" - }, - { - "text": "螃蟹坦克", - "name": "Crab Tank" - }, - { - "text": "浮墨幕墙", - "name": "Splattercolor Screen" - }, - { - "text": "能量站", - "name": "Tacticooler" - }, - { - "text": "诱饵烟花", - "name": "Super Chump" - }, - { - "text": "巨大防护罩", - "name": "Big Bubbler" - }, - { - "text": "墨雨云", - "name": "Ink Storm" - }, - { - "text": "喷射背包", - "name": "Inkjet" - }, - { - "text": "喇叭镭射5.1ch", - "name": "Killer Wail 5.1" - }, - { - "text": "多重导弹", - "name": "Tenta Missiles" - }, - { - "text": "赞气弹", - "name": "Booyah Bomb" - }, - { - "text": "终极着陆", - "name": "Triple Splashdown" - }, - { - "text": "弹跳声呐", - "name": "Wave Breaker" - }, - { - "text": "鲨鱼坐骑", - "name": "Reefslider" - }, - { - "text": "触手喷射", - "name": "Zipcaster" - }, - { - "text": "三重龙卷风", - "name": "Triple Inkstrike" - }, - { - "text": "终极发射", - "name": "Trizooka" - }, - { - "text": "终极印章", - "name": "Ultra Stamp" - } - ], - "EUde": [ - { - "text": "Turbo-Blaster Plus", - "name": "Rapid Blaster Pro" - }, - { - "text": "Turbo-Blaster Plus Deko", - "name": "Rapid Blaster Pro Deco" - }, - { - "text": "Turbo-Blaster Plus WNT-R", - "name": "Rapid Blaster Pro WNT-R" - }, - { - "text": "Kontra-Blaster", - "name": "Clash Blaster" - }, - { - "text": "Kontra-Blaster Neo", - "name": "Clash Blaster Neo" - }, - { - "text": "Turbo-Blaster", - "name": "Rapid Blaster" - }, - { - "text": "Turbo-Blaster Deko", - "name": "Rapid Blaster Deco" - }, - { - "text": "Fern-Blaster", - "name": "Range Blaster" - }, - { - "text": "Fern-Blaster SE", - "name": "Custom Range Blaster" - }, - { - "text": "Blaster SE", - "name": "Custom Blaster" - }, - { - "text": "Blaster GLAM", - "name": "Gleamz Blaster" - }, - { - "text": "S-BLAST92", - "name": "S-BLAST '92" - }, - { - "text": "S-BLAST91", - "name": "S-BLAST '91" - }, - { - "text": "Luna-Blaster", - "name": "Luna Blaster" - }, - { - "text": "Luna-Blaster Neo", - "name": "Luna Blaster Neo" - }, - { - "text": "Ordnung-Blaster Replik", - "name": "Order Blaster Replica" - }, - { - "text": "Fächerfärber", - "name": "Painbrush" - }, - { - "text": "Fächerfärber Fresco", - "name": "Painbrush Nouveau" - }, - { - "text": "Fächerfärber BRN-Z", - "name": "Painbrush BRN-Z" - }, - { - "text": "Quasto", - "name": "Inkbrush" - }, - { - "text": "Quasto Fresco", - "name": "Inkbrush Nouveau" - }, - { - "text": "Kalligraf", - "name": "Octobrush" - }, - { - "text": "Kalligraf Fresco", - "name": "Octobrush Nouveau" - }, - { - "text": "Kalligraf Astro", - "name": "Cometz Octobrush" - }, - { - "text": "Ordnung-Kalligraf Replik", - "name": "Orderbrush Replica" - }, - { - "text": "T-Tuber", - "name": "Goo Tuber" - }, - { - "text": "T-Tuber SE", - "name": "Custom Goo Tuber" - }, - { - "text": "Klotzer 14-A", - "name": "Bamboozler 14 Mk I" - }, - { - "text": "Klotzer 14-B", - "name": "Bamboozler 14 Mk II" - }, - { - "text": "Ziel-E-liter 4K", - "name": "E-liter 4K Scope" - }, - { - "text": "Ziel-E-liter 4K SE", - "name": "Custom E-liter 4K Scope" - }, - { - "text": "E-liter 4K SE", - "name": "Custom E-liter 4K" - }, - { - "text": "Ziel-Konzentrator", - "name": "Splatterscope" - }, - { - "text": "Zekkori-Ziel-Konzentrator", - "name": "Z+F Splatterscope" - }, - { - "text": "Ziel-Konzentrator TAR-N", - "name": "Splatterscope CAM-O" - }, - { - "text": "Klecks-Konzentrator", - "name": "Splat Charger" - }, - { - "text": "Zekkori-Klecks-Konzentrator", - "name": "Z+F Splat Charger" - }, - { - "text": "Klecks-Konzentrator TAR-N", - "name": "Splat Charger CAM-O" - }, - { - "text": "Ordnung-Konzentrator Replik", - "name": "Order Charger Replica" - }, - { - "text": "R-BLR/5H", - "name": "Snipewriter 5H" - }, - { - "text": "R-BLR/5B", - "name": "Snipewriter 5B" - }, - { - "text": "Sepiator α", - "name": "Classic Squiffer" - }, - { - "text": "Sepiator β", - "name": "New Squiffer" - }, - { - "text": "Dual-Platscher", - "name": "Dualie Squelchers" - }, - { - "text": "Dual-Platscher SE", - "name": "Custom Dualie Squelchers" - }, - { - "text": "Dual-Platscher ZBR", - "name": "Hoofz Dualie Squelchers" - }, - { - "text": "Kelvin 525", - "name": "Glooga Dualies" - }, - { - "text": "Kelvin 525 Deko", - "name": "Glooga Dualies Deco" - }, - { - "text": "Lösch-Doppler FW", - "name": "Douser Dualies FF" - }, - { - "text": "Lösch-Doppler FW SE", - "name": "Custom Douser Dualies FF" - }, - { - "text": "Klecks-Doppler", - "name": "Splat Dualies" - }, - { - "text": "Enperry-Klecks-Doppler", - "name": "Enperry Splat Dualies" - }, - { - "text": "Glitz-Klecks-Doppler", - "name": "Twinklez Splat Dualies" - }, - { - "text": "Ordnung-Doppler Replik", - "name": "Order Dualie Replicas" - }, - { - "text": "Sprenkler", - "name": "Dapple Dualies" - }, - { - "text": "Sprenkler Fresco", - "name": "Dapple Dualies Nouveau" - }, - { - "text": "Sprenkler U-HU", - "name": "Dapple Dualies NOC-T" - }, - { - "text": "Quadhopper Noir", - "name": "Dark Tetra Dualies" - }, - { - "text": "Quadhopper Blanc", - "name": "Light Tetra Dualies" - }, - { - "text": "Karbonroller", - "name": "Carbon Roller" - }, - { - "text": "Karbonroller Deko", - "name": "Carbon Roller Deco" - }, - { - "text": "Karbonroller ANG-LR", - "name": "Carbon Roller ANG-L" - }, - { - "text": "Dynaroller", - "name": "Dynamo Roller" - }, - { - "text": "Dynaroller Tesla", - "name": "Gold Dynamo Roller" - }, - { - "text": "Dynaroller Galaxa", - "name": "Starz Dynamo Roller" - }, - { - "text": "Flex-Roller", - "name": "Flingza Roller" - }, - { - "text": "Flex-Roller Fol", - "name": "Foil Flingza Roller" - }, - { - "text": "Klecksroller", - "name": "Splat Roller" - }, - { - "text": "Medusa-Klecksroller", - "name": "Krak-On Splat Roller" - }, - { - "text": "Ordnung-Roller Replik", - "name": "Order Roller Replica" - }, - { - "text": "Breitroller", - "name": "Big Swig Roller" - }, - { - "text": "Breitroller Express", - "name": "Big Swig Roller Express" - }, - { - "text": "Breitroller Exzell", - "name": "Planetz Big Swig Roller" - }, - { - "text": "Minz-Prophylator", - "name": "Mint Decavitator" - }, - { - "text": "Carbo-Prophylator", - "name": "Charcoal Decavitator" - }, - { - "text": "Wischer-Splatana", - "name": "Splatana Wiper" - }, - { - "text": "Wischer-Splatana Deko", - "name": "Splatana Wiper Deco" - }, - { - "text": "Wischer-Splatana RST-G", - "name": "Splatana Wiper RUS-T" - }, - { - "text": "Stempel-Splatana", - "name": "Splatana Stamper" - }, - { - "text": "Stempel-Splatana Fresco", - "name": "Splatana Stamper Nouveau" - }, - { - "text": "Stempel-Splatana Sticker", - "name": "Stickerz Splatana Stamper" - }, - { - "text": "Ordnung-Splatana Replik", - "name": "Order Splatana Replica" - }, - { - "text": "UnderCover", - "name": "Undercover Brella" - }, - { - "text": "Sorella-UnderCover", - "name": "Undercover Sorella Brella" - }, - { - "text": "Flair-UnderCover", - "name": "Patternz Undercover Brella" - }, - { - "text": "Bast-Pluviator 24-A", - "name": "Recycled Brella 24 Mk I" - }, - { - "text": "Bast-Pluviator 24-B", - "name": "Recycled Brella 24 Mk II" - }, - { - "text": "Parapluviator", - "name": "Splat Brella" - }, - { - "text": "Sorella-Parapluviator", - "name": "Sorella Brella" - }, - { - "text": "Ordnung-Pluviator Replik", - "name": "Order Brella Replica" - }, - { - "text": "Camp-Pluviator", - "name": "Tenta Brella" - }, - { - "text": "Sorella-Camp-Pluviator", - "name": "Tenta Sorella Brella" - }, - { - "text": "Camp-Pluviator CR-MA", - "name": "Tenta Brella CRE-M" - }, - { - "text": "Airbrush MG", - "name": "Aerospray MG" - }, - { - "text": "Airbrush RG", - "name": "Aerospray RG" - }, - { - "text": "Airbrush RGB", - "name": "Colorz Aerospray" - }, - { - "text": "Profi-Kleckser", - "name": "Splattershot Pro" - }, - { - "text": "Focus-Profi-Kleckser", - "name": "Forge Splattershot Pro" - }, - { - "text": "Profi-Kleckser CH-LL", - "name": "Splattershot Pro FRZ-N" - }, - { - "text": "Junior-Kleckser", - "name": "Splattershot Jr." - }, - { - "text": "Junior-Kleckser Plus", - "name": "Custom Splattershot Jr." - }, - { - "text": "Quetscher", - "name": "Squeezer" - }, - { - "text": "Quetscher Fol", - "name": "Foil Squeezer" - }, - { - "text": ".52 Gallon", - "name": ".52 Gal" - }, - { - "text": ".52 Gallon Deko", - "name": ".52 Gal Deco" - }, - { - "text": ".96 Gallon", - "name": ".96 Gal" - }, - { - "text": ".96 Gallon Deko", - "name": ".96 Gal Deco" - }, - { - "text": ".96 Gallon Leo", - "name": "Clawz .96 Gal" - }, - { - "text": "Platscher", - "name": "Jet Squelcher" - }, - { - "text": "Platscher SE", - "name": "Custom Jet Squelcher" - }, - { - "text": "Platscher KB-RA", - "name": "Jet Squelcher COB-R" - }, - { - "text": "Kleckser", - "name": "Splattershot" - }, - { - "text": "Tentatek-Kleckser", - "name": "Tentatek Splattershot" - }, - { - "text": "Chroma-Kleckser", - "name": "Glamorz Splattershot" - }, - { - "text": "Heldenwaffe Replik", - "name": "Hero Shot Replica" - }, - { - "text": "Ordnung-Kleckser Replik", - "name": "Order Shot Replica" - }, - { - "text": "Okto-Kleckser Replik", - "name": "Octo Shot Replica" - }, - { - "text": "Fein-Disperser", - "name": "Splash-o-matic" - }, - { - "text": "Fein-Disperser Neo", - "name": "Neo Splash-o-matic" - }, - { - "text": "Fein-Disperser GK-KO", - "name": "Splash-o-matic GCK-O" - }, - { - "text": "Kosmo-Kleckser", - "name": "Splattershot Nova" - }, - { - "text": "Annaki-Kosmo-Kleckser", - "name": "Annaki Splattershot Nova" - }, - { - "text": "N-ZAP85", - "name": "N-ZAP '85" - }, - { - "text": "N-ZAP89", - "name": "N-ZAP '89" - }, - { - "text": "Disperser", - "name": "Sploosh-o-matic" - }, - { - "text": "Disperser Neo", - "name": "Neo Sploosh-o-matic" - }, - { - "text": "S3 Tintenwerfer", - "name": "H-3 Nozzlenose" - }, - { - "text": "S3 Tintenwerfer D", - "name": "H-3 Nozzlenose D" - }, - { - "text": "S3 Tintenwerfer VIP-R", - "name": "H-3 Nozzlenose VIP-R" - }, - { - "text": "L3 Tintenwerfer", - "name": "L-3 Nozzlenose" - }, - { - "text": "L3 Tintenwerfer D", - "name": "L-3 Nozzlenose D" - }, - { - "text": "L3 Tintenwerfer Oro", - "name": "Glitterz L-3 Nozzlenose" - }, - { - "text": "Wannen-Schwapper", - "name": "Bloblobber" - }, - { - "text": "Wannen-Schwapper Deko", - "name": "Bloblobber Deco" - }, - { - "text": "3R-Schwapper", - "name": "Tri-Slosher" - }, - { - "text": "3R-Schwapper Fresco", - "name": "Tri-Slosher Nouveau" - }, - { - "text": "3R-Schwapper OX-D", - "name": "Tri-Slosher ASH-N" - }, - { - "text": "Schwappwascher", - "name": "Dread Wringer" - }, - { - "text": "Schwappwascher D", - "name": "Dread Wringer D" - }, - { - "text": "Schwappwascher Horn", - "name": "Hornz Dread Wringer" - }, - { - "text": "Trommel-Schwapper", - "name": "Sloshing Machine" - }, - { - "text": "Trommel-Schwapper Neo", - "name": "Sloshing Machine Neo" - }, - { - "text": "Schwapper", - "name": "Slosher" - }, - { - "text": "Schwapper Deko", - "name": "Slosher Deco" - }, - { - "text": "Ordnung-Schwapper Replik", - "name": "Order Slosher Replica" - }, - { - "text": "Knall-Schwapper", - "name": "Explosher" - }, - { - "text": "Knall-Schwapper SE", - "name": "Custom Explosher" - }, - { - "text": "Kuli-Splatling", - "name": "Ballpoint Splatling" - }, - { - "text": "Kuli-Splatling Fresco", - "name": "Ballpoint Splatling Nouveau" - }, - { - "text": "Kartuschierer", - "name": "Heavy Edit Splatling" - }, - { - "text": "Kartuschierer Fresco", - "name": "Heavy Edit Splatling Nouveau" - }, - { - "text": "Hydrant", - "name": "Hydra Splatling" - }, - { - "text": "Hydrant SE", - "name": "Custom Hydra Splatling" - }, - { - "text": "Hydrant ATÜ", - "name": "Torrentz Hydra Splatling" - }, - { - "text": "Klecks-Splatling", - "name": "Mini Splatling" - }, - { - "text": "Sagitron-Klecks-Splatling", - "name": "Zink Mini Splatling" - }, - { - "text": "Klecks-Splatling KLP-R", - "name": "Mini Splatling RTL-R" - }, - { - "text": "Splatling", - "name": "Heavy Splatling" - }, - { - "text": "Splatling Deko", - "name": "Heavy Splatling Deco" - }, - { - "text": "Ordnung-Splatling Replik", - "name": "Order Splatling Replica" - }, - { - "text": "Penta-Pumper", - "name": "Wellstring V" - }, - { - "text": "Penta-Pumper SE", - "name": "Custom Wellstring V" - }, - { - "text": "Alpomar-Tri-Stringer", - "name": "Inkline Tri-Stringer" - }, - { - "text": "Prisma-Tri-Stringer", - "name": "Bulbz Tri-Stringer" - }, - { - "text": "Ordnung-Stringer Replik", - "name": "Order Stringer Replica" - }, - { - "text": "LACT-450", - "name": "REEF-LUX 450" - }, - { - "text": "LACT-450 Deko", - "name": "REEF-LUX 450 Deco" - }, - { - "text": "LACT-450 Q-MLCH", - "name": "REEF-LUX 450 MIL-K" - }, - { - "text": "Sprungboje", - "name": "Squid Beakon" - }, - { - "text": "Curling-Bombe", - "name": "Curling Bomb" - }, - { - "text": "Sprudel-Bombe", - "name": "Fizzy Bomb" - }, - { - "text": "Insta-Bombe", - "name": "Burst Bomb" - }, - { - "text": "Robo-Bombe", - "name": "Autobomb" - }, - { - "text": "Klecks-Bombe", - "name": "Splat Bomb" - }, - { - "text": "Haftbombe", - "name": "Suction Bomb" - }, - { - "text": "Winkelmarker", - "name": "Angle Shooter" - }, - { - "text": "Detektor", - "name": "Point Sensor" - }, - { - "text": "Sepitox-Nebel", - "name": "Toxic Mist" - }, - { - "text": "Tintenwall", - "name": "Splash Wall" - }, - { - "text": "Tintenmine", - "name": "Ink Mine" - }, - { - "text": "Tintegrator", - "name": "Ink Vac" - }, - { - "text": "Tintentyrann", - "name": "Kraken Royale" - }, - { - "text": "Krabbenpanzer", - "name": "Crab Tank" - }, - { - "text": "Unsichtbarriere", - "name": "Splattercolor Screen" - }, - { - "text": "Tranktank", - "name": "Tacticooler" - }, - { - "text": "Bluffbomber", - "name": "Super Chump" - }, - { - "text": "Kugelschild Pro", - "name": "Big Bubbler" - }, - { - "text": "Tintenschauer", - "name": "Ink Storm" - }, - { - "text": "Tintendüser", - "name": "Inkjet" - }, - { - "text": "Heulboje 5.1", - "name": "Killer Wail 5.1" - }, - { - "text": "Schwarmraketen", - "name": "Tenta Missiles" - }, - { - "text": "Cool-Kugel", - "name": "Booyah Bomb" - }, - { - "text": "Tri-Tintenschock", - "name": "Triple Splashdown" - }, - { - "text": "Schauerwelle", - "name": "Wave Breaker" - }, - { - "text": "Haihammer", - "name": "Reefslider" - }, - { - "text": "Haftsprung", - "name": "Zipcaster" - }, - { - "text": "Tri-Tintferno", - "name": "Triple Inkstrike" - }, - { - "text": "Ultra-Stempel", - "name": "Ultra Stamp" - } - ], - "EUes": [ - { - "text": "Superdevastador", - "name": "Rapid Blaster Pro" - }, - { - "text": "Superdevastador DX", - "name": "Rapid Blaster Pro Deco" - }, - { - "text": "Superdevastador INV", - "name": "Rapid Blaster Pro WNT-R" - }, - { - "text": "Turbodevastador", - "name": "Clash Blaster" - }, - { - "text": "Turbodevastador neo", - "name": "Clash Blaster Neo" - }, - { - "text": "Devastador exprés", - "name": "Rapid Blaster" - }, - { - "text": "Devastador exprés DX", - "name": "Rapid Blaster Deco" - }, - { - "text": "Teledevastador", - "name": "Range Blaster" - }, - { - "text": "Teledevastador SP", - "name": "Custom Range Blaster" - }, - { - "text": "Devastador", - "name": "Blaster" - }, - { - "text": "Devastador SP", - "name": "Custom Blaster" - }, - { - "text": "Devastador LUX", - "name": "Gleamz Blaster" - }, - { - "text": "D-VAST 92", - "name": "S-BLAST '92" - }, - { - "text": "D-VAST 91", - "name": "S-BLAST '91" - }, - { - "text": "Ultradevastador", - "name": "Luna Blaster" - }, - { - "text": "Ultradevastador neo", - "name": "Luna Blaster Neo" - }, - { - "text": "Devastador orden (réplica)", - "name": "Order Blaster Replica" - }, - { - "text": "Brocha abanico", - "name": "Painbrush" - }, - { - "text": "Brocha abanico maestra", - "name": "Painbrush Nouveau" - }, - { - "text": "Brocha abanico BNC", - "name": "Painbrush BRN-Z" - }, - { - "text": "Pincel", - "name": "Inkbrush" - }, - { - "text": "Pincel maestro", - "name": "Inkbrush Nouveau" - }, - { - "text": "Brocha", - "name": "Octobrush" - }, - { - "text": "Brocha maestra", - "name": "Octobrush Nouveau" - }, - { - "text": "Brocha cometa", - "name": "Cometz Octobrush" - }, - { - "text": "Brocha orden (réplica)", - "name": "Orderbrush Replica" - }, - { - "text": "Tubofusil", - "name": "Goo Tuber" - }, - { - "text": "Tubofusil SP", - "name": "Custom Goo Tuber" - }, - { - "text": "Bambufusil 14-I", - "name": "Bamboozler 14 Mk I" - }, - { - "text": "Bambufusil 14-II", - "name": "Bamboozler 14 Mk II" - }, - { - "text": "Telentintador 4K", - "name": "E-liter 4K Scope" - }, - { - "text": "Telentintador 4K SP", - "name": "Custom E-liter 4K Scope" - }, - { - "text": "Entintador 4K", - "name": "E-liter 4K" - }, - { - "text": "Entintador 4K SP", - "name": "Custom E-liter 4K" - }, - { - "text": "Cargatintas con mira", - "name": "Splatterscope" - }, - { - "text": "Cargatintas con mira Ezkkor", - "name": "Z+F Splatterscope" - }, - { - "text": "Cargatintas con mira BSQ", - "name": "Splatterscope CAM-O" - }, - { - "text": "Cargatintas", - "name": "Splat Charger" - }, - { - "text": "Cargatintas Ezkkor", - "name": "Z+F Splat Charger" - }, - { - "text": "Cargatintas BSQ", - "name": "Splat Charger CAM-O" - }, - { - "text": "Cargatintas orden (réplica)", - "name": "Order Charger Replica" - }, - { - "text": "Cargaminas 5H", - "name": "Snipewriter 5H" - }, - { - "text": "Cargaminas 5B", - "name": "Snipewriter 5B" - }, - { - "text": "Kalarrapid α", - "name": "Classic Squiffer" - }, - { - "text": "Kalarrapid β", - "name": "New Squiffer" - }, - { - "text": "Barredora dual", - "name": "Dualie Squelchers" - }, - { - "text": "Barredora dual SP", - "name": "Custom Dualie Squelchers" - }, - { - "text": "Barredora dual rayas", - "name": "Hoofz Dualie Squelchers" - }, - { - "text": "Fundidora 525 dual", - "name": "Glooga Dualies" - }, - { - "text": "Fundidora 525 dual DX", - "name": "Glooga Dualies Deco" - }, - { - "text": "Extintor Dual FF", - "name": "Douser Dualies FF" - }, - { - "text": "Extintor Dual FF SP", - "name": "Custom Douser Dualies FF" - }, - { - "text": "Difusor dual", - "name": "Splat Dualies" - }, - { - "text": "Difusor dual B", - "name": "Enperry Splat Dualies" - }, - { - "text": "Difusor dual chispa", - "name": "Twinklez Splat Dualies" - }, - { - "text": "Difusor dual orden (réplica)", - "name": "Order Dualie Replicas" - }, - { - "text": "Atomizador dual", - "name": "Dapple Dualies" - }, - { - "text": "Atomizador dual maestro", - "name": "Dapple Dualies Nouveau" - }, - { - "text": "Atomizador dual búho", - "name": "Dapple Dualies NOC-T" - }, - { - "text": "Motatrónic dual negro", - "name": "Dark Tetra Dualies" - }, - { - "text": "Motatrónic dual blanco", - "name": "Light Tetra Dualies" - }, - { - "text": "Rodillo de carbono", - "name": "Carbon Roller" - }, - { - "text": "Rodillo de carbono DX", - "name": "Carbon Roller Deco" - }, - { - "text": "Rodillo de carbono ABSL", - "name": "Carbon Roller ANG-L" - }, - { - "text": "Dinamorrodillo", - "name": "Dynamo Roller" - }, - { - "text": "Dinamorrodillo T", - "name": "Gold Dynamo Roller" - }, - { - "text": "Dinamorrodillo cosmos", - "name": "Starz Dynamo Roller" - }, - { - "text": "Rodillo versátil", - "name": "Flingza Roller" - }, - { - "text": "Rodillo versátil B", - "name": "Foil Flingza Roller" - }, - { - "text": "Rodillo básico", - "name": "Splat Roller" - }, - { - "text": "Rodillo básico B", - "name": "Krak-On Splat Roller" - }, - { - "text": "Rodillo orden (réplica)", - "name": "Order Roller Replica" - }, - { - "text": "Rodillo mágnum", - "name": "Big Swig Roller" - }, - { - "text": "Rodillo mágnum B", - "name": "Big Swig Roller Express" - }, - { - "text": "Rodillo mágnum SAT", - "name": "Planetz Big Swig Roller" - }, - { - "text": "Odontintador menta", - "name": "Mint Decavitator" - }, - { - "text": "Odontintador carbón", - "name": "Charcoal Decavitator" - }, - { - "text": "Azotintador brisa", - "name": "Splatana Wiper" - }, - { - "text": "Azotintador brisa DX", - "name": "Splatana Wiper Deco" - }, - { - "text": "Azotintador brisa óxido", - "name": "Splatana Wiper RUS-T" - }, - { - "text": "Azotintador pro", - "name": "Splatana Stamper" - }, - { - "text": "Azotintador pro maestro", - "name": "Splatana Stamper Nouveau" - }, - { - "text": "Azotintador pro pegatas", - "name": "Stickerz Splatana Stamper" - }, - { - "text": "Azotintador orden (réplica)", - "name": "Order Splatana Replica" - }, - { - "text": "Paratintas clásico", - "name": "Undercover Brella" - }, - { - "text": "Paratintas clásico B", - "name": "Undercover Sorella Brella" - }, - { - "text": "Paratintas clásico MIX", - "name": "Patternz Undercover Brella" - }, - { - "text": "Prototintas 24-I", - "name": "Recycled Brella 24 Mk I" - }, - { - "text": "Prototintas 24-II", - "name": "Recycled Brella 24 Mk II" - }, - { - "text": "Paratintas", - "name": "Splat Brella" - }, - { - "text": "Paratintas B", - "name": "Sorella Brella" - }, - { - "text": "Paratintas orden (réplica)", - "name": "Order Brella Replica" - }, - { - "text": "Paratintas maxi", - "name": "Tenta Brella" - }, - { - "text": "Paratintas maxi B", - "name": "Tenta Sorella Brella" - }, - { - "text": "Paratintas maxi crema", - "name": "Tenta Brella CRE-M" - }, - { - "text": "Aerógrafo pro", - "name": "Aerospray MG" - }, - { - "text": "Aerógrafo plus", - "name": "Aerospray RG" - }, - { - "text": "Aerógrafo arcoíris", - "name": "Colorz Aerospray" - }, - { - "text": "Lanzatintas plus", - "name": "Splattershot Pro" - }, - { - "text": "Lanzatintas plus B", - "name": "Forge Splattershot Pro" - }, - { - "text": "Lanzatintas plus gélido", - "name": "Splattershot Pro FRZ-N" - }, - { - "text": "Lanzatintas novato", - "name": "Splattershot Jr." - }, - { - "text": "Lanzatintas novato B", - "name": "Custom Splattershot Jr." - }, - { - "text": "Tintopresor", - "name": "Squeezer" - }, - { - "text": "Tintopresor B", - "name": "Foil Squeezer" - }, - { - "text": "Salpicadora 2000", - "name": ".52 Gal" - }, - { - "text": "Salpicadora 2000 DX", - "name": ".52 Gal Deco" - }, - { - "text": "Salpicadora 3000", - "name": ".96 Gal" - }, - { - "text": "Salpicadora 3000 DX", - "name": ".96 Gal Deco" - }, - { - "text": "Salpicadora 3000 garra", - "name": "Clawz .96 Gal" - }, - { - "text": "Megabarredora", - "name": "Jet Squelcher" - }, - { - "text": "Megabarredora SP", - "name": "Custom Jet Squelcher" - }, - { - "text": "Megabarredora cobra", - "name": "Jet Squelcher COB-R" - }, - { - "text": "Lanzatintas", - "name": "Splattershot" - }, - { - "text": "Lanzatintas B", - "name": "Tentatek Splattershot" - }, - { - "text": "Lanzatintas ZAS", - "name": "Glamorz Splattershot" - }, - { - "text": "Pistola de élite (réplica)", - "name": "Hero Shot Replica" - }, - { - "text": "Lanzatintas orden (réplica)", - "name": "Order Shot Replica" - }, - { - "text": "Pistola octariana (réplica)", - "name": "Octo Shot Replica" - }, - { - "text": "Marcador fino", - "name": "Splash-o-matic" - }, - { - "text": "Marcador fino neo", - "name": "Neo Splash-o-matic" - }, - { - "text": "Marcador fino geco", - "name": "Splash-o-matic GCK-O" - }, - { - "text": "Lanzatintas espacial", - "name": "Splattershot Nova" - }, - { - "text": "Lanzatintas espacial B", - "name": "Annaki Splattershot Nova" - }, - { - "text": "N-ZAP 85", - "name": "N-ZAP '85" - }, - { - "text": "N-ZAP 89", - "name": "N-ZAP '89" - }, - { - "text": "Marcador", - "name": "Sploosh-o-matic" - }, - { - "text": "Marcador neo", - "name": "Neo Sploosh-o-matic" - }, - { - "text": "Tintambor pesado", - "name": "H-3 Nozzlenose" - }, - { - "text": "Tintambor pesado D", - "name": "H-3 Nozzlenose D" - }, - { - "text": "Tintambor pesado áspid", - "name": "H-3 Nozzlenose VIP-R" - }, - { - "text": "Tintambor ligero", - "name": "L-3 Nozzlenose" - }, - { - "text": "Tintambor ligero D", - "name": "L-3 Nozzlenose D" - }, - { - "text": "Tintambor ligero chapa", - "name": "Glitterz L-3 Nozzlenose" - }, - { - "text": "Derramatic baño", - "name": "Bloblobber" - }, - { - "text": "Derramatic baño DX", - "name": "Bloblobber Deco" - }, - { - "text": "Derramatic triple", - "name": "Tri-Slosher" - }, - { - "text": "Derramatic triple maestro", - "name": "Tri-Slosher Nouveau" - }, - { - "text": "Derramatic triple ceniza", - "name": "Tri-Slosher ASH-N" - }, - { - "text": "Friegamatic", - "name": "Dread Wringer" - }, - { - "text": "Friegamatic D", - "name": "Dread Wringer D" - }, - { - "text": "Friegamatic asta", - "name": "Hornz Dread Wringer" - }, - { - "text": "Derramatic centrífugo", - "name": "Sloshing Machine" - }, - { - "text": "Derramatic centrífugo neo", - "name": "Sloshing Machine Neo" - }, - { - "text": "Derramatic", - "name": "Slosher" - }, - { - "text": "Derramatic DX", - "name": "Slosher Deco" - }, - { - "text": "Derramatic orden (réplica)", - "name": "Order Slosher Replica" - }, - { - "text": "Derramatic turbo", - "name": "Explosher" - }, - { - "text": "Derramatic turbo SP", - "name": "Custom Explosher" - }, - { - "text": "Ametrallógrafo", - "name": "Ballpoint Splatling" - }, - { - "text": "Ametrallógrafo maestro", - "name": "Ballpoint Splatling Nouveau" - }, - { - "text": "Corrigetintas", - "name": "Heavy Edit Splatling" - }, - { - "text": "Corrigetintas maestro", - "name": "Heavy Edit Splatling Nouveau" - }, - { - "text": "Extintador", - "name": "Hydra Splatling" - }, - { - "text": "Extintador SP", - "name": "Custom Hydra Splatling" - }, - { - "text": "Extintador presión", - "name": "Torrentz Hydra Splatling" - }, - { - "text": "Tintralladora ligera", - "name": "Mini Splatling" - }, - { - "text": "Tintralladora ligera B", - "name": "Zink Mini Splatling" - }, - { - "text": "Tintralladora ligera pitón", - "name": "Mini Splatling RTL-R" - }, - { - "text": "Subtralladora 47", - "name": "Nautilus 47" - }, - { - "text": "Subtralladora 79", - "name": "Nautilus 79" - }, - { - "text": "Tintralladora", - "name": "Heavy Splatling" - }, - { - "text": "Tintralladora DX", - "name": "Heavy Splatling Deco" - }, - { - "text": "Tintralladora orden (réplica)", - "name": "Order Splatling Replica" - }, - { - "text": "Arcrotensor V", - "name": "Wellstring V" - }, - { - "text": "Arcrotensor V SP", - "name": "Custom Wellstring V" - }, - { - "text": "Arcromatizador triple", - "name": "Tri-Stringer" - }, - { - "text": "Arcromatizador triple B", - "name": "Inkline Tri-Stringer" - }, - { - "text": "Arcromatizador triple LUZ", - "name": "Bulbz Tri-Stringer" - }, - { - "text": "Arcromatizador orden (réplica)", - "name": "Order Stringer Replica" - }, - { - "text": "AR-CR0 450", - "name": "REEF-LUX 450" - }, - { - "text": "AR-CR0 450 DX", - "name": "REEF-LUX 450 Deco" - }, - { - "text": "AR-CR0 450 lácteo", - "name": "REEF-LUX 450 MIL-K" - }, - { - "text": "Baliza transportadora", - "name": "Squid Beakon" - }, - { - "text": "Bomba deslizante", - "name": "Curling Bomb" - }, - { - "text": "Bomba carbónica", - "name": "Fizzy Bomb" - }, - { - "text": "Bomba rápida", - "name": "Burst Bomb" - }, - { - "text": "Robobomba", - "name": "Autobomb" - }, - { - "text": "Bomba básica", - "name": "Splat Bomb" - }, - { - "text": "Bomba ventosa", - "name": "Suction Bomb" - }, - { - "text": "Tiralíneas", - "name": "Angle Shooter" - }, - { - "text": "Rastreador", - "name": "Point Sensor" - }, - { - "text": "Nebulizador", - "name": "Toxic Mist" - }, - { - "text": "Telón de tinta", - "name": "Splash Wall" - }, - { - "text": "Aspersor", - "name": "Sprinkler" - }, - { - "text": "Bomba trampa", - "name": "Ink Mine" - }, - { - "text": "Aspiratinta", - "name": "Ink Vac" - }, - { - "text": "Calamar imperial", - "name": "Kraken Royale" - }, - { - "text": "Cangrejobot", - "name": "Crab Tank" - }, - { - "text": "Pantintalla", - "name": "Splattercolor Screen" - }, - { - "text": "Dispensabebidas", - "name": "Tacticooler" - }, - { - "text": "Multiseñuelos", - "name": "Super Chump" - }, - { - "text": "Megaburbuja", - "name": "Big Bubbler" - }, - { - "text": "Atormentador", - "name": "Ink Storm" - }, - { - "text": "Propulsor", - "name": "Inkjet" - }, - { - "text": "Tintófono 5.1", - "name": "Killer Wail 5.1" - }, - { - "text": "Lanzamisiles", - "name": "Tenta Missiles" - }, - { - "text": "Bola genializante", - "name": "Booyah Bomb" - }, - { - "text": "Puñetazos explosivos", - "name": "Triple Splashdown" - }, - { - "text": "Emiteondas", - "name": "Wave Breaker" - }, - { - "text": "Flotiburón", - "name": "Reefslider" - }, - { - "text": "Gancho tentacular", - "name": "Zipcaster" - }, - { - "text": "Tornado triple", - "name": "Triple Inkstrike" - }, - { - "text": "Tintazuca triple", - "name": "Trizooka" - }, - { - "text": "Ultraselladora", - "name": "Ultra Stamp" - } - ], - "EUfr": [ - { - "text": "Turboblaster pro", - "name": "Rapid Blaster Pro" - }, - { - "text": "Turboblaster pro chic", - "name": "Rapid Blaster Pro Deco" - }, - { - "text": "Turboblaster pro POL-R", - "name": "Rapid Blaster Pro WNT-R" - }, - { - "text": "Rafablaster", - "name": "Clash Blaster" - }, - { - "text": "Rafablaster Néo", - "name": "Clash Blaster Neo" - }, - { - "text": "Turboblaster", - "name": "Rapid Blaster" - }, - { - "text": "Turboblaster chic", - "name": "Rapid Blaster Deco" - }, - { - "text": "Éclablaster XL", - "name": "Range Blaster" - }, - { - "text": "Éclablaster XL griffé", - "name": "Custom Range Blaster" - }, - { - "text": "Éclablaster", - "name": "Blaster" - }, - { - "text": "Éclablaster modifié", - "name": "Custom Blaster" - }, - { - "text": "Éclablaster mirapex", - "name": "Gleamz Blaster" - }, - { - "text": "S-Blaster '92", - "name": "S-BLAST '92" - }, - { - "text": "S-Blaster '91", - "name": "S-BLAST '91" - }, - { - "text": "Proxiblaster", - "name": "Luna Blaster" - }, - { - "text": "Proxiblaster Néo", - "name": "Luna Blaster Neo" - }, - { - "text": "Blaster d'Ordre (réplique)", - "name": "Order Blaster Replica" - }, - { - "text": "Épinceau écaïe", - "name": "Painbrush" - }, - { - "text": "Épinceau écaïe nuancé", - "name": "Painbrush Nouveau" - }, - { - "text": "Épinceau écaïe BRN-Z", - "name": "Painbrush BRN-Z" - }, - { - "text": "Épinceau", - "name": "Inkbrush" - }, - { - "text": "Épinceau nuancé", - "name": "Inkbrush Nouveau" - }, - { - "text": "Épinceau brosse", - "name": "Octobrush" - }, - { - "text": "Épinceau brosse nuancé", - "name": "Octobrush Nouveau" - }, - { - "text": "Épinceau brosse stellapex", - "name": "Cometz Octobrush" - }, - { - "text": "Pinceau d'Ordre (réplique)", - "name": "Orderbrush Replica" - }, - { - "text": "Détubeur", - "name": "Goo Tuber" - }, - { - "text": "Détubeur modifié", - "name": "Custom Goo Tuber" - }, - { - "text": "Bimbamboum Mk I", - "name": "Bamboozler 14 Mk I" - }, - { - "text": "Bimbamboum Mk II", - "name": "Bamboozler 14 Mk II" - }, - { - "text": "Extraceur + zoom", - "name": "E-liter 4K Scope" - }, - { - "text": "Extraceur + zoom modifié", - "name": "Custom E-liter 4K Scope" - }, - { - "text": "Extraceur +", - "name": "E-liter 4K" - }, - { - "text": "Extraceur + modifié", - "name": "Custom E-liter 4K" - }, - { - "text": "Concentraceur zoom", - "name": "Splatterscope" - }, - { - "text": "Concentraceur zoom E+F", - "name": "Z+F Splatterscope" - }, - { - "text": "Concentraceur zoom CAM-O", - "name": "Splatterscope CAM-O" - }, - { - "text": "Concentraceur", - "name": "Splat Charger" - }, - { - "text": "Concentraceur E+F", - "name": "Z+F Splat Charger" - }, - { - "text": "Concentraceur CAM-O", - "name": "Splat Charger CAM-O" - }, - { - "text": "Fusil d'Ordre (réplique)", - "name": "Order Charger Replica" - }, - { - "text": "Crayon X - 5H", - "name": "Snipewriter 5H" - }, - { - "text": "Crayon X - 5B", - "name": "Snipewriter 5B" - }, - { - "text": "Décap'express Alpha", - "name": "Classic Squiffer" - }, - { - "text": "Décap'express Bêta", - "name": "New Squiffer" - }, - { - "text": "Double nettoyeur", - "name": "Dualie Squelchers" - }, - { - "text": "Double nettoyeur modifié", - "name": "Custom Dualie Squelchers" - }, - { - "text": "Double nettoyeur zébrapex", - "name": "Hoofz Dualie Squelchers" - }, - { - "text": "Double Kelvin 525", - "name": "Glooga Dualies" - }, - { - "text": "Double Kelvin 525 chic", - "name": "Glooga Dualies Deco" - }, - { - "text": "Double ignifugeur", - "name": "Douser Dualies FF" - }, - { - "text": "Double ignifugeur modifié", - "name": "Custom Douser Dualies FF" - }, - { - "text": "Double encreur", - "name": "Splat Dualies" - }, - { - "text": "Double encreur griffé", - "name": "Enperry Splat Dualies" - }, - { - "text": "Double encreur flambapex", - "name": "Twinklez Splat Dualies" - }, - { - "text": "Arme double d'Ordre (réplique)", - "name": "Order Dualie Replicas" - }, - { - "text": "Double moucheteur", - "name": "Dapple Dualies" - }, - { - "text": "Double moucheteur nuancé", - "name": "Dapple Dualies Nouveau" - }, - { - "text": "Double moucheteur NOC-T", - "name": "Dapple Dualies NOC-T" - }, - { - "text": "Double voltigeur noir", - "name": "Dark Tetra Dualies" - }, - { - "text": "Double voltigeur blanc", - "name": "Light Tetra Dualies" - }, - { - "text": "Rouleau carbone", - "name": "Carbon Roller" - }, - { - "text": "Rouleau carbone chic", - "name": "Carbon Roller Deco" - }, - { - "text": "Rouleau carbone BOD-RWA", - "name": "Carbon Roller ANG-L" - }, - { - "text": "Dynamo-rouleau", - "name": "Dynamo Roller" - }, - { - "text": "Dynamo-rouleau tesla", - "name": "Gold Dynamo Roller" - }, - { - "text": "Dynamo-rouleau galapex", - "name": "Starz Dynamo Roller" - }, - { - "text": "Flexi-rouleau", - "name": "Flingza Roller" - }, - { - "text": "Flexi-rouleau alu", - "name": "Foil Flingza Roller" - }, - { - "text": "Rouleau", - "name": "Splat Roller" - }, - { - "text": "Rouleau griffé", - "name": "Krak-On Splat Roller" - }, - { - "text": "Rouleau d'Ordre (réplique)", - "name": "Order Roller Replica" - }, - { - "text": "Rouleau large", - "name": "Big Swig Roller" - }, - { - "text": "Rouleau large Xpress", - "name": "Big Swig Roller Express" - }, - { - "text": "Rouleau large astrapex", - "name": "Planetz Big Swig Roller" - }, - { - "text": "Sabrifrice menthe", - "name": "Mint Decavitator" - }, - { - "text": "Sabrifrice charbon", - "name": "Charcoal Decavitator" - }, - { - "text": "Éclatana Doto", - "name": "Splatana Wiper" - }, - { - "text": "Éclatana Doto chic", - "name": "Splatana Wiper Deco" - }, - { - "text": "Éclatana Doto OXY-D", - "name": "Splatana Wiper RUS-T" - }, - { - "text": "Éclatana d'estampe", - "name": "Splatana Stamper" - }, - { - "text": "Éclatana d'estampe nuancé", - "name": "Splatana Stamper Nouveau" - }, - { - "text": "Éclatana d'estampe décapex", - "name": "Stickerz Splatana Stamper" - }, - { - "text": "Éclatana d'Ordre (réplique)", - "name": "Order Splatana Replica" - }, - { - "text": "Para-encre espion", - "name": "Undercover Brella" - }, - { - "text": "Para-encre espion Sorella", - "name": "Undercover Sorella Brella" - }, - { - "text": "Para-encre espion amalgapex", - "name": "Patternz Undercover Brella" - }, - { - "text": "Para-encre Ré-gen 24 Mk I", - "name": "Recycled Brella 24 Mk I" - }, - { - "text": "Para-encre Ré-gen 24 Mk II", - "name": "Recycled Brella 24 Mk II" - }, - { - "text": "Para-encre", - "name": "Splat Brella" - }, - { - "text": "Para-encre Sorella", - "name": "Sorella Brella" - }, - { - "text": "Para-encre d'Ordre (réplique)", - "name": "Order Brella Replica" - }, - { - "text": "Para-encre XL", - "name": "Tenta Brella" - }, - { - "text": "Para-encre XL Sorella", - "name": "Tenta Sorella Brella" - }, - { - "text": "Para-encre XL CRM-A", - "name": "Tenta Brella CRE-M" - }, - { - "text": "Aérogun", - "name": "Aerospray MG" - }, - { - "text": "Aérogun premium", - "name": "Aerospray RG" - }, - { - "text": "Aérogun chromapex", - "name": "Colorz Aerospray" - }, - { - "text": "Liquidateur pro", - "name": "Splattershot Pro" - }, - { - "text": "Liquidateur pro griffé", - "name": "Forge Splattershot Pro" - }, - { - "text": "Liquidateur pro GLA-C", - "name": "Splattershot Pro FRZ-N" - }, - { - "text": "Liquidateur Jr.", - "name": "Splattershot Jr." - }, - { - "text": "Liquidateur Sr.", - "name": "Custom Splattershot Jr." - }, - { - "text": "Compresseur", - "name": "Squeezer" - }, - { - "text": "Compresseur alu", - "name": "Foil Squeezer" - }, - { - "text": "Calibre 2000", - "name": ".52 Gal" - }, - { - "text": "Calibre 2000 chic", - "name": ".52 Gal Deco" - }, - { - "text": "Calibre 3000", - "name": ".96 Gal" - }, - { - "text": "Calibre 3000 chic", - "name": ".96 Gal Deco" - }, - { - "text": "Calibre 3000 guépapex", - "name": "Clawz .96 Gal" - }, - { - "text": "Nettoyeur XL", - "name": "Jet Squelcher" - }, - { - "text": "Nettoyeur XL modifié", - "name": "Custom Jet Squelcher" - }, - { - "text": "Nettoyeur XL COB-R", - "name": "Jet Squelcher COB-R" - }, - { - "text": "Liquidateur", - "name": "Splattershot" - }, - { - "text": "Liquidateur griffé", - "name": "Tentatek Splattershot" - }, - { - "text": "Liquidateur glamapex", - "name": "Glamorz Splattershot" - }, - { - "text": "Lanceur héroïque (réplique)", - "name": "Hero Shot Replica" - }, - { - "text": "Lanceur d'Ordre (réplique)", - "name": "Order Shot Replica" - }, - { - "text": "Lanceur Octaling (réplique)", - "name": "Octo Shot Replica" - }, - { - "text": "Marqueur léger", - "name": "Splash-o-matic" - }, - { - "text": "Marqueur léger Néo", - "name": "Neo Splash-o-matic" - }, - { - "text": "Marqueur léger G-KO", - "name": "Splash-o-matic GCK-O" - }, - { - "text": "Lanceur spatial", - "name": "Splattershot Nova" - }, - { - "text": "Lanceur spatial Aroz", - "name": "Annaki Splattershot Nova" - }, - { - "text": "N-ZAP 85", - "name": "N-ZAP '85" - }, - { - "text": "N-ZAP 89", - "name": "N-ZAP '89" - }, - { - "text": "Marqueur lourd", - "name": "Sploosh-o-matic" - }, - { - "text": "Marqueur lourd Néo", - "name": "Neo Sploosh-o-matic" - }, - { - "text": "Arroseur lourd", - "name": "H-3 Nozzlenose" - }, - { - "text": "Arroseur lourd Cétacé", - "name": "H-3 Nozzlenose D" - }, - { - "text": "Arroseur lourd VIP-R", - "name": "H-3 Nozzlenose VIP-R" - }, - { - "text": "Arroseur léger", - "name": "L-3 Nozzlenose" - }, - { - "text": "Arroseur léger Cétacé", - "name": "L-3 Nozzlenose D" - }, - { - "text": "Arroseur léger éclapex", - "name": "Glitterz L-3 Nozzlenose" - }, - { - "text": "Bassineur", - "name": "Bloblobber" - }, - { - "text": "Bassineur chic", - "name": "Bloblobber Deco" - }, - { - "text": "Dépoteur", - "name": "Tri-Slosher" - }, - { - "text": "Dépoteur nuancé", - "name": "Tri-Slosher Nouveau" - }, - { - "text": "Dépoteur 100-DRÉ", - "name": "Tri-Slosher ASH-N" - }, - { - "text": "Seau S-Horreur", - "name": "Dread Wringer" - }, - { - "text": "Seau S-Horreur Cétacé", - "name": "Dread Wringer D" - }, - { - "text": "Seau S-Horreur kérapex", - "name": "Hornz Dread Wringer" - }, - { - "text": "Encrifugeur", - "name": "Sloshing Machine" - }, - { - "text": "Encrifugeur Néo", - "name": "Sloshing Machine Neo" - }, - { - "text": "Seauceur", - "name": "Slosher" - }, - { - "text": "Seauceur chic", - "name": "Slosher Deco" - }, - { - "text": "Seau d'Ordre (réplique)", - "name": "Order Slosher Replica" - }, - { - "text": "Détoneur", - "name": "Explosher" - }, - { - "text": "Détoneur modifié", - "name": "Custom Explosher" - }, - { - "text": "Badigeonneur stylo", - "name": "Ballpoint Splatling" - }, - { - "text": "Badigeonneur stylo nuancé", - "name": "Ballpoint Splatling Nouveau" - }, - { - "text": "Correcteur", - "name": "Heavy Edit Splatling" - }, - { - "text": "Correcteur nuancé", - "name": "Heavy Edit Splatling Nouveau" - }, - { - "text": "Exteinteur", - "name": "Hydra Splatling" - }, - { - "text": "Exteinteur modifié", - "name": "Custom Hydra Splatling" - }, - { - "text": "Exteinteur cascapex", - "name": "Torrentz Hydra Splatling" - }, - { - "text": "Badigeonneur XS", - "name": "Mini Splatling" - }, - { - "text": "Badigeonneur XS griffé", - "name": "Zink Mini Splatling" - }, - { - "text": "Badigeonneur XS VN-1", - "name": "Mini Splatling RTL-R" - }, - { - "text": "Badigeonneur", - "name": "Heavy Splatling" - }, - { - "text": "Badigeonneur chic", - "name": "Heavy Splatling Deco" - }, - { - "text": "Badigeonneur d'Ordre (réplique)", - "name": "Order Splatling Replica" - }, - { - "text": "Multisperceur V", - "name": "Wellstring V" - }, - { - "text": "Multisperceur V modifié", - "name": "Custom Wellstring V" - }, - { - "text": "Trisperceur", - "name": "Tri-Stringer" - }, - { - "text": "Trisperceur Abysma", - "name": "Inkline Tri-Stringer" - }, - { - "text": "Trisperceur prismapex", - "name": "Bulbz Tri-Stringer" - }, - { - "text": "Transperceur d'Ordre (réplique)", - "name": "Order Stringer Replica" - }, - { - "text": "Coralux 450", - "name": "REEF-LUX 450" - }, - { - "text": "Coralux 450 chic", - "name": "REEF-LUX 450 Deco" - }, - { - "text": "Coralux 450 LACTO", - "name": "REEF-LUX 450 MIL-K" - }, - { - "text": "Balise de saut", - "name": "Squid Beakon" - }, - { - "text": "Bombe curling", - "name": "Curling Bomb" - }, - { - "text": "Bombe soda", - "name": "Fizzy Bomb" - }, - { - "text": "Bombe ballon", - "name": "Burst Bomb" - }, - { - "text": "Bombe robot", - "name": "Autobomb" - }, - { - "text": "Bombe splash", - "name": "Splat Bomb" - }, - { - "text": "Bombe gluante", - "name": "Suction Bomb" - }, - { - "text": "Bentorpille", - "name": "Torpedo" - }, - { - "text": "Ricocheur", - "name": "Angle Shooter" - }, - { - "text": "Détecteur", - "name": "Point Sensor" - }, - { - "text": "Brume toxique", - "name": "Toxic Mist" - }, - { - "text": "Mur d'encre", - "name": "Splash Wall" - }, - { - "text": "Fontaine", - "name": "Sprinkler" - }, - { - "text": "Mine", - "name": "Ink Mine" - }, - { - "text": "Aspirencre", - "name": "Ink Vac" - }, - { - "text": "Kraken royal", - "name": "Kraken Royale" - }, - { - "text": "Crabe d'assaut", - "name": "Crab Tank" - }, - { - "text": "Barrière barbouillée", - "name": "Splattercolor Screen" - }, - { - "text": "Districool", - "name": "Tacticooler" - }, - { - "text": "Multi-leurres", - "name": "Super Chump" - }, - { - "text": "Super bouclier", - "name": "Big Bubbler" - }, - { - "text": "Pluie d'encre", - "name": "Ink Storm" - }, - { - "text": "Chromo-jet", - "name": "Inkjet" - }, - { - "text": "Haut-perceur 5.1", - "name": "Killer Wail 5.1" - }, - { - "text": "Multi-missile", - "name": "Tenta Missiles" - }, - { - "text": "Jolizator", - "name": "Booyah Bomb" - }, - { - "text": "Triple choc chromatique", - "name": "Triple Splashdown" - }, - { - "text": "Sonar paf", - "name": "Wave Breaker" - }, - { - "text": "Cavalsquale", - "name": "Reefslider" - }, - { - "text": "Super Mollusque", - "name": "Zipcaster" - }, - { - "text": "Trimissile tornade", - "name": "Triple Inkstrike" - }, - { - "text": "Lance-rafales", - "name": "Trizooka" - }, - { - "text": "Ultra-tamponneur", - "name": "Ultra Stamp" - } - ], - "EUit": [ - { - "text": "Blaster élite", - "name": "Rapid Blaster Pro" - }, - { - "text": "Blaster élite DX", - "name": "Rapid Blaster Pro Deco" - }, - { - "text": "Blaster élite INV-N", - "name": "Rapid Blaster Pro WNT-R" - }, - { - "text": "Blaster da mischia", - "name": "Clash Blaster" - }, - { - "text": "Blaster da mischia Neo", - "name": "Clash Blaster Neo" - }, - { - "text": "Blaster rapido", - "name": "Rapid Blaster" - }, - { - "text": "Blaster rapido DX", - "name": "Rapid Blaster Deco" - }, - { - "text": "Blaster a distanza", - "name": "Range Blaster" - }, - { - "text": "Blaster a distanza CM", - "name": "Custom Range Blaster" - }, - { - "text": "Blaster CM", - "name": "Custom Blaster" - }, - { - "text": "Blaster glam-bon", - "name": "Gleamz Blaster" - }, - { - "text": "S-BLAST92", - "name": "S-BLAST '92" - }, - { - "text": "S-BLAST91", - "name": "S-BLAST '91" - }, - { - "text": "Blaster lunar", - "name": "Luna Blaster" - }, - { - "text": "Blaster lunar Neo", - "name": "Luna Blaster Neo" - }, - { - "text": "Blaster Ordine replica", - "name": "Order Blaster Replica" - }, - { - "text": "Tonnoretto", - "name": "Painbrush" - }, - { - "text": "Tonnoretto master", - "name": "Painbrush Nouveau" - }, - { - "text": "Tonnoretto BRN-Z", - "name": "Painbrush BRN-Z" - }, - { - "text": "Calamaravaggio", - "name": "Inkbrush" - }, - { - "text": "Calamaravaggio master", - "name": "Inkbrush Nouveau" - }, - { - "text": "Tinturicchio", - "name": "Octobrush" - }, - { - "text": "Tinturicchio master", - "name": "Octobrush Nouveau" - }, - { - "text": "Tinturicchio comet-bon", - "name": "Cometz Octobrush" - }, - { - "text": "Pennello Ordine replica", - "name": "Orderbrush Replica" - }, - { - "text": "Tintubator", - "name": "Goo Tuber" - }, - { - "text": "Tintubator CM", - "name": "Custom Goo Tuber" - }, - { - "text": "Bimbamboom 14-I", - "name": "Bamboozler 14 Mk I" - }, - { - "text": "Bimbamboom 14-II", - "name": "Bamboozler 14 Mk II" - }, - { - "text": "Tinter con mirino 4K", - "name": "E-liter 4K Scope" - }, - { - "text": "Tinter con mirino 4K CM", - "name": "Custom E-liter 4K Scope" - }, - { - "text": "Tinter a carica 4K", - "name": "E-liter 4K" - }, - { - "text": "Tinter a carica 4K CM", - "name": "Custom E-liter 4K" - }, - { - "text": "Splatter con mirino", - "name": "Splatterscope" - }, - { - "text": "Splatter con mirino logo", - "name": "Z+F Splatterscope" - }, - { - "text": "Splatter con mirino FRS-T", - "name": "Splatterscope CAM-O" - }, - { - "text": "Splatter a carica", - "name": "Splat Charger" - }, - { - "text": "Splatter a carica logo", - "name": "Z+F Splat Charger" - }, - { - "text": "Splatter a carica FRS-T", - "name": "Splat Charger CAM-O" - }, - { - "text": "Splatter Ordine replica", - "name": "Order Charger Replica" - }, - { - "text": "Lapis cecchino 5H", - "name": "Snipewriter 5H" - }, - { - "text": "Lapis cecchino 5B", - "name": "Snipewriter 5B" - }, - { - "text": "Kalamarapid α", - "name": "Classic Squiffer" - }, - { - "text": "Kalamarapid β", - "name": "New Squiffer" - }, - { - "text": "Sweeper duplo", - "name": "Dualie Squelchers" - }, - { - "text": "Sweeper duplo CM", - "name": "Custom Dualie Squelchers" - }, - { - "text": "Sweeper duplo zebra-bon", - "name": "Hoofz Dualie Squelchers" - }, - { - "text": "Kelvin duplo 525", - "name": "Glooga Dualies" - }, - { - "text": "Kelvin duplo 525 DX", - "name": "Glooga Dualies Deco" - }, - { - "text": "Repolper estintori FF", - "name": "Douser Dualies FF" - }, - { - "text": "Repolper estintori FF CM", - "name": "Custom Douser Dualies FF" - }, - { - "text": "Repolper duplo", - "name": "Splat Dualies" - }, - { - "text": "Repolper duplo logo", - "name": "Enperry Splat Dualies" - }, - { - "text": "Repolper duplo metal-bon", - "name": "Twinklez Splat Dualies" - }, - { - "text": "Repolper Ordine replica", - "name": "Order Dualie Replicas" - }, - { - "text": "Ripolpella duplo", - "name": "Dapple Dualies" - }, - { - "text": "Ripolpella duplo master", - "name": "Dapple Dualies Nouveau" - }, - { - "text": "Ripolpella duplo NOT-T", - "name": "Dapple Dualies NOC-T" - }, - { - "text": "Moscarpino duplo nero", - "name": "Dark Tetra Dualies" - }, - { - "text": "Moscarpino duplo bianco", - "name": "Light Tetra Dualies" - }, - { - "text": "Rullo di carbonio", - "name": "Carbon Roller" - }, - { - "text": "Rullo di carbonio DX", - "name": "Carbon Roller Deco" - }, - { - "text": "Rullo di carbonio PSC-AB", - "name": "Carbon Roller ANG-L" - }, - { - "text": "Rullo dinamo", - "name": "Dynamo Roller" - }, - { - "text": "Rullo dinamo dorato", - "name": "Gold Dynamo Roller" - }, - { - "text": "Rullo dinamo galax-bon", - "name": "Starz Dynamo Roller" - }, - { - "text": "Flexi-rullo", - "name": "Flingza Roller" - }, - { - "text": "Flexi-rullo B", - "name": "Foil Flingza Roller" - }, - { - "text": "Rullo splat", - "name": "Splat Roller" - }, - { - "text": "Rullo splat logo", - "name": "Krak-On Splat Roller" - }, - { - "text": "Rullo Ordine replica", - "name": "Order Roller Replica" - }, - { - "text": "Ampiorullo", - "name": "Big Swig Roller" - }, - { - "text": "Ampiorullo Express", - "name": "Big Swig Roller Express" - }, - { - "text": "Ampiorullo astra-bon", - "name": "Planetz Big Swig Roller" - }, - { - "text": "Spazzolama menta", - "name": "Mint Decavitator" - }, - { - "text": "Spazzolama carbone", - "name": "Charcoal Decavitator" - }, - { - "text": "Tergilama", - "name": "Splatana Wiper" - }, - { - "text": "Tergilama DX", - "name": "Splatana Wiper Deco" - }, - { - "text": "Tergilama RUG-N", - "name": "Splatana Wiper RUS-T" - }, - { - "text": "Splattalama", - "name": "Splatana Stamper" - }, - { - "text": "Splattalama master", - "name": "Splatana Stamper Nouveau" - }, - { - "text": "Splattalama adesi-bon", - "name": "Stickerz Splatana Stamper" - }, - { - "text": "Lama Ordine replica", - "name": "Order Splatana Replica" - }, - { - "text": "Bombrello", - "name": "Undercover Brella" - }, - { - "text": "Bombrello Solerra", - "name": "Undercover Sorella Brella" - }, - { - "text": "Bombrello sgargia-bon", - "name": "Patternz Undercover Brella" - }, - { - "text": "Sparasole riciclo 24-I", - "name": "Recycled Brella 24 Mk I" - }, - { - "text": "Sparasole riciclo 24-II", - "name": "Recycled Brella 24 Mk II" - }, - { - "text": "Sparasole", - "name": "Splat Brella" - }, - { - "text": "Sparasole Solerra", - "name": "Sorella Brella" - }, - { - "text": "Sparasole Ordine replica", - "name": "Order Brella Replica" - }, - { - "text": "Sparatenda", - "name": "Tenta Brella" - }, - { - "text": "Sparatenda Solerra", - "name": "Tenta Sorella Brella" - }, - { - "text": "Sparatenda CRM-A", - "name": "Tenta Brella CRE-M" - }, - { - "text": "Aerografo", - "name": "Aerospray MG" - }, - { - "text": "Aerografo élite", - "name": "Aerospray RG" - }, - { - "text": "Aerografo color-bon", - "name": "Colorz Aerospray" - }, - { - "text": "Splasher élite", - "name": "Splattershot Pro" - }, - { - "text": "Splasher élite logo", - "name": "Forge Splattershot Pro" - }, - { - "text": "Splasher élite GLA-C", - "name": "Splattershot Pro FRZ-N" - }, - { - "text": "Sparacolore recluta", - "name": "Splattershot Jr." - }, - { - "text": "Sparacolore logo", - "name": "Custom Splattershot Jr." - }, - { - "text": "Strizzer", - "name": "Squeezer" - }, - { - "text": "Strizzer B", - "name": "Foil Squeezer" - }, - { - "text": "Calibro 2000", - "name": ".52 Gal" - }, - { - "text": "Calibro 2000 DX", - "name": ".52 Gal Deco" - }, - { - "text": "Calibro 3000", - "name": ".96 Gal" - }, - { - "text": "Calibro 3000 DX", - "name": ".96 Gal Deco" - }, - { - "text": "Calibro 3000 leo-bon", - "name": "Clawz .96 Gal" - }, - { - "text": "Sweeper", - "name": "Jet Squelcher" - }, - { - "text": "Sweeper CM", - "name": "Custom Jet Squelcher" - }, - { - "text": "Sweeper COB-R", - "name": "Jet Squelcher COB-R" - }, - { - "text": "Splasher", - "name": "Splattershot" - }, - { - "text": "Splasher logo", - "name": "Tentatek Splattershot" - }, - { - "text": "Splasher iride-bon", - "name": "Glamorz Splattershot" - }, - { - "text": "Pistola élite replica", - "name": "Hero Shot Replica" - }, - { - "text": "Pistola Ordine replica", - "name": "Order Shot Replica" - }, - { - "text": "Octosplasher replica", - "name": "Octo Shot Replica" - }, - { - "text": "Marker d'assalto", - "name": "Splash-o-matic" - }, - { - "text": "Marker d'assalto Neo", - "name": "Neo Splash-o-matic" - }, - { - "text": "Marker d'assalto GEC-O", - "name": "Splash-o-matic GCK-O" - }, - { - "text": "Splasher Nova", - "name": "Splattershot Nova" - }, - { - "text": "Splasher Nova Annak", - "name": "Annaki Splattershot Nova" - }, - { - "text": "N-ZAP85", - "name": "N-ZAP '85" - }, - { - "text": "N-ZAP89", - "name": "N-ZAP '89" - }, - { - "text": "Marker", - "name": "Sploosh-o-matic" - }, - { - "text": "Marker Neo", - "name": "Neo Sploosh-o-matic" - }, - { - "text": "Triplete", - "name": "H-3 Nozzlenose" - }, - { - "text": "Triplete D", - "name": "H-3 Nozzlenose D" - }, - { - "text": "Triplete VIP-R", - "name": "H-3 Nozzlenose VIP-R" - }, - { - "text": "Triplete compatto", - "name": "L-3 Nozzlenose" - }, - { - "text": "Triplete compatto D", - "name": "L-3 Nozzlenose D" - }, - { - "text": "Triplete compatto lucci-bon", - "name": "Glitterz L-3 Nozzlenose" - }, - { - "text": "Secchiostro vasca", - "name": "Bloblobber" - }, - { - "text": "Secchiostro vasca DX", - "name": "Bloblobber Deco" - }, - { - "text": "Secchiostro triplo", - "name": "Tri-Slosher" - }, - { - "text": "Secchiostro triplo master", - "name": "Tri-Slosher Nouveau" - }, - { - "text": "Secchiostro triplo POL-V", - "name": "Tri-Slosher ASH-N" - }, - { - "text": "Secchiapavimenti", - "name": "Dread Wringer" - }, - { - "text": "Secchiapavimenti D", - "name": "Dread Wringer D" - }, - { - "text": "Secchiapavimenti acu-bon", - "name": "Hornz Dread Wringer" - }, - { - "text": "Secchiostro centrifuga", - "name": "Sloshing Machine" - }, - { - "text": "Secchiostro centrifuga Neo", - "name": "Sloshing Machine Neo" - }, - { - "text": "Secchiostro", - "name": "Slosher" - }, - { - "text": "Secchiostro DX", - "name": "Slosher Deco" - }, - { - "text": "Secchiostro Ordine replica", - "name": "Order Slosher Replica" - }, - { - "text": "Termosecchiostro", - "name": "Explosher" - }, - { - "text": "Termosecchiostro master", - "name": "Custom Explosher" - }, - { - "text": "Tetrapenna", - "name": "Ballpoint Splatling" - }, - { - "text": "Tetrapenna master", - "name": "Ballpoint Splatling Nouveau" - }, - { - "text": "Splatling a pennarelli", - "name": "Heavy Edit Splatling" - }, - { - "text": "Splatling a pennarelli master", - "name": "Heavy Edit Splatling Nouveau" - }, - { - "text": "Idrante", - "name": "Hydra Splatling" - }, - { - "text": "Idrante CM", - "name": "Custom Hydra Splatling" - }, - { - "text": "Idrante spray-bon", - "name": "Torrentz Hydra Splatling" - }, - { - "text": "Mini splatling logo", - "name": "Zink Mini Splatling" - }, - { - "text": "Mini splatling PIT-N", - "name": "Mini Splatling RTL-R" - }, - { - "text": "Splatling a tanica", - "name": "Heavy Splatling" - }, - { - "text": "Splatling a tanica DX", - "name": "Heavy Splatling Deco" - }, - { - "text": "Splatling Ordine replica", - "name": "Order Splatling Replica" - }, - { - "text": "Fontanarco V", - "name": "Wellstring V" - }, - { - "text": "Fontanarco V CM", - "name": "Custom Wellstring V" - }, - { - "text": "Tri-calamarco", - "name": "Tri-Stringer" - }, - { - "text": "Tri-calamarco Maremont", - "name": "Inkline Tri-Stringer" - }, - { - "text": "Tri-calamarco LED-bon", - "name": "Bulbz Tri-Stringer" - }, - { - "text": "Calamarco Ordine replica", - "name": "Order Stringer Replica" - }, - { - "text": "ARCO-RAL 450", - "name": "REEF-LUX 450" - }, - { - "text": "ARCO-RAL 450 DX", - "name": "REEF-LUX 450 Deco" - }, - { - "text": "ARCO-RAL 450 LAT-T", - "name": "REEF-LUX 450 MIL-K" - }, - { - "text": "Trasferitore", - "name": "Squid Beakon" - }, - { - "text": "Bomba curling", - "name": "Curling Bomb" - }, - { - "text": "Bomba a gassosa", - "name": "Fizzy Bomb" - }, - { - "text": "Granata", - "name": "Burst Bomb" - }, - { - "text": "Robo-bomba", - "name": "Autobomb" - }, - { - "text": "Bomba splash", - "name": "Splat Bomb" - }, - { - "text": "Appiccibomba", - "name": "Suction Bomb" - }, - { - "text": "Torpedinatore", - "name": "Torpedo" - }, - { - "text": "Sparalinee", - "name": "Angle Shooter" - }, - { - "text": "Cimice", - "name": "Point Sensor" - }, - { - "text": "Nebbia tossica", - "name": "Toxic Mist" - }, - { - "text": "Muro di colore", - "name": "Splash Wall" - }, - { - "text": "Spruzzatore", - "name": "Sprinkler" - }, - { - "text": "Mina", - "name": "Ink Mine" - }, - { - "text": "Vampinchiostro", - "name": "Ink Vac" - }, - { - "text": "Kraken reale", - "name": "Kraken Royale" - }, - { - "text": "Granchio armato", - "name": "Crab Tank" - }, - { - "text": "Cortina ingannevole", - "name": "Splattercolor Screen" - }, - { - "text": "Rinfrigorente", - "name": "Tacticooler" - }, - { - "text": "Diversivo esplosivo", - "name": "Super Chump" - }, - { - "text": "Gran bolla scudo", - "name": "Big Bubbler" - }, - { - "text": "Pioggia di colore", - "name": "Ink Storm" - }, - { - "text": "Jet splat", - "name": "Inkjet" - }, - { - "text": "Tintofono 5.1", - "name": "Killer Wail 5.1" - }, - { - "text": "Lanciarazzi", - "name": "Tenta Missiles" - }, - { - "text": "Granbotto", - "name": "Booyah Bomb" - }, - { - "text": "Triplo vernischianto", - "name": "Triple Splashdown" - }, - { - "text": "Sonar esplosivo", - "name": "Wave Breaker" - }, - { - "text": "Motosqualo", - "name": "Reefslider" - }, - { - "text": "Molluscatto", - "name": "Zipcaster" - }, - { - "text": "Triplo tornado", - "name": "Triple Inkstrike" - }, - { - "text": "Ultraturbinator", - "name": "Trizooka" - }, - { - "text": "Mega timbro", - "name": "Ultra Stamp" - } - ], - "EUnl": [ - { - "text": "Flitsblaster Pro", - "name": "Rapid Blaster Pro" - }, - { - "text": "Flitsblaster Pro Deco", - "name": "Rapid Blaster Pro Deco" - }, - { - "text": "Flitsblaster Pro WNT-R", - "name": "Rapid Blaster Pro WNT-R" - }, - { - "text": "Knalblaster", - "name": "Clash Blaster" - }, - { - "text": "Knalblaster Neo", - "name": "Clash Blaster Neo" - }, - { - "text": "Flitsblaster", - "name": "Rapid Blaster" - }, - { - "text": "Flitsblaster Deco", - "name": "Rapid Blaster Deco" - }, - { - "text": "Teleblaster", - "name": "Range Blaster" - }, - { - "text": "Gemodde Teleblaster", - "name": "Custom Range Blaster" - }, - { - "text": "Klodderblaster", - "name": "Blaster" - }, - { - "text": "Gemodde Klodderblaster", - "name": "Custom Blaster" - }, - { - "text": "Klodderblaster Schÿn", - "name": "Gleamz Blaster" - }, - { - "text": "Ordeblaster (replica)", - "name": "Order Blaster Replica" - }, - { - "text": "Kwistkwast", - "name": "Painbrush" - }, - { - "text": "Kwistkwast Nouveau", - "name": "Painbrush Nouveau" - }, - { - "text": "Kwistkwast BRN-S", - "name": "Painbrush BRN-Z" - }, - { - "text": "Inktkwast", - "name": "Inkbrush" - }, - { - "text": "Inktkwast Nouveau", - "name": "Inkbrush Nouveau" - }, - { - "text": "Octokwast", - "name": "Octobrush" - }, - { - "text": "Octokwast Nouveau", - "name": "Octobrush Nouveau" - }, - { - "text": "Octokwast Kösmös", - "name": "Cometz Octobrush" - }, - { - "text": "Ordekwast (replica)", - "name": "Orderbrush Replica" - }, - { - "text": "Spetbuizer", - "name": "Goo Tuber" - }, - { - "text": "Gemodde Spetbuizer", - "name": "Custom Goo Tuber" - }, - { - "text": "Bamboem 14-A", - "name": "Bamboozler 14 Mk I" - }, - { - "text": "Bamboem 14-B", - "name": "Bamboozler 14 Mk II" - }, - { - "text": "E-liter 4K met vizier", - "name": "E-liter 4K Scope" - }, - { - "text": "Gemodde E-liter 4K met vizier", - "name": "Custom E-liter 4K Scope" - }, - { - "text": "Gemodde E-liter 4K", - "name": "Custom E-liter 4K" - }, - { - "text": "Schelpschutter", - "name": "Splatterscope" - }, - { - "text": "Zekkofin Schelpschutter", - "name": "Z+F Splatterscope" - }, - { - "text": "Schelpschutter CAM-O", - "name": "Splatterscope CAM-O" - }, - { - "text": "Bloplader", - "name": "Splat Charger" - }, - { - "text": "Zekkofin Bloplader", - "name": "Z+F Splat Charger" - }, - { - "text": "Bloplader CAM-O", - "name": "Splat Charger CAM-O" - }, - { - "text": "Ordelader (replica)", - "name": "Order Charger Replica" - }, - { - "text": "Schotlood 5H", - "name": "Snipewriter 5H" - }, - { - "text": "Schotlood 5B", - "name": "Snipewriter 5B" - }, - { - "text": "Octopoets", - "name": "Classic Squiffer" - }, - { - "text": "Octopoets Ultra", - "name": "New Squiffer" - }, - { - "text": "Dubbelplonzers", - "name": "Dualie Squelchers" - }, - { - "text": "Gemodde Dubbelplonzers", - "name": "Custom Dualie Squelchers" - }, - { - "text": "Dubbelplonzers Zëbra", - "name": "Hoofz Dualie Squelchers" - }, - { - "text": "Kelvin 525", - "name": "Glooga Dualies" - }, - { - "text": "Kelvin 525 Deco", - "name": "Glooga Dualies Deco" - }, - { - "text": "Blusknallers BW", - "name": "Douser Dualies FF" - }, - { - "text": "Gemodde Blusknallers BW", - "name": "Custom Douser Dualies FF" - }, - { - "text": "Dubbelknallers", - "name": "Splat Dualies" - }, - { - "text": "Enperry Dubbelknallers", - "name": "Enperry Splat Dualies" - }, - { - "text": "Dubbelknallers Fönkel", - "name": "Twinklez Splat Dualies" - }, - { - "text": "Ordeknallers (replica)", - "name": "Order Dualie Replicas" - }, - { - "text": "Dubbelspatters", - "name": "Dapple Dualies" - }, - { - "text": "Dubbelspatters Nouveau", - "name": "Dapple Dualies Nouveau" - }, - { - "text": "Dubbelspatters VLR-M", - "name": "Dapple Dualies NOC-T" - }, - { - "text": "Quadhoppers Zwart", - "name": "Dark Tetra Dualies" - }, - { - "text": "Quadhoppers Wit", - "name": "Light Tetra Dualies" - }, - { - "text": "Carbonroller HNG-L", - "name": "Carbon Roller ANG-L" - }, - { - "text": "Gouden Dynamoroller", - "name": "Gold Dynamo Roller" - }, - { - "text": "Dynamoroller Aströ", - "name": "Starz Dynamo Roller" - }, - { - "text": "Flexroller", - "name": "Flingza Roller" - }, - { - "text": "Foil Flexroller", - "name": "Foil Flingza Roller" - }, - { - "text": "Klodderroller", - "name": "Splat Roller" - }, - { - "text": "Krak-On Klodderroller", - "name": "Krak-On Splat Roller" - }, - { - "text": "Orderoller (replica)", - "name": "Order Roller Replica" - }, - { - "text": "Breedroller", - "name": "Big Swig Roller" - }, - { - "text": "Breedroller Impuls", - "name": "Big Swig Roller Express" - }, - { - "text": "Breedroller Fütür", - "name": "Planetz Big Swig Roller" - }, - { - "text": "Schrobbehak PM", - "name": "Mint Decavitator" - }, - { - "text": "Schrobbehak HK", - "name": "Charcoal Decavitator" - }, - { - "text": "Wisserbladzwieper", - "name": "Splatana Wiper" - }, - { - "text": "Wisserbladzwieper Deco", - "name": "Splatana Wiper Deco" - }, - { - "text": "Wisserbladzwieper O-XID", - "name": "Splatana Wiper RUS-T" - }, - { - "text": "Stempelzwieper", - "name": "Splatana Stamper" - }, - { - "text": "Stempelzwieper Nouveau", - "name": "Splatana Stamper Nouveau" - }, - { - "text": "Stempelzwieper Pläkkerz", - "name": "Stickerz Splatana Stamper" - }, - { - "text": "Ordezwieper (replica)", - "name": "Order Splatana Replica" - }, - { - "text": "Paraplenzer", - "name": "Undercover Brella" - }, - { - "text": "Sorella Paraplenzer", - "name": "Undercover Sorella Brella" - }, - { - "text": "Paraplenzer Fräppant", - "name": "Patternz Undercover Brella" - }, - { - "text": "Protoplenzer 24-I", - "name": "Recycled Brella 24 Mk I" - }, - { - "text": "Protoplenzer 24-II", - "name": "Recycled Brella 24 Mk II" - }, - { - "text": "Plenzer", - "name": "Splat Brella" - }, - { - "text": "Sorella Plenzer", - "name": "Sorella Brella" - }, - { - "text": "Ordeplenzer (replica)", - "name": "Order Brella Replica" - }, - { - "text": "Kampeerplenzer", - "name": "Tenta Brella" - }, - { - "text": "Sorella Kampeerplenzer", - "name": "Tenta Sorella Brella" - }, - { - "text": "Kampeerplenzer CRE-M", - "name": "Tenta Brella CRE-M" - }, - { - "text": "Kladderwerper", - "name": "Aerospray MG" - }, - { - "text": "Kladderwerper Pro", - "name": "Aerospray RG" - }, - { - "text": "Kladderwerper Bönt", - "name": "Colorz Aerospray" - }, - { - "text": "Superspetter Pro", - "name": "Splattershot Pro" - }, - { - "text": "Forge Superspetter Pro", - "name": "Forge Splattershot Pro" - }, - { - "text": "Superspetter Pro BIB-R", - "name": "Splattershot Pro FRZ-N" - }, - { - "text": "Superspetter jr.", - "name": "Splattershot Jr." - }, - { - "text": "Gemodde Superspetter jr.", - "name": "Custom Splattershot Jr." - }, - { - "text": "Floeper", - "name": "Squeezer" - }, - { - "text": "Foil Floeper", - "name": "Foil Squeezer" - }, - { - "text": ".52 Kaliter", - "name": ".52 Gal" - }, - { - "text": ".52 Kaliter Deco", - "name": ".52 Gal Deco" - }, - { - "text": ".96 Kaliter", - "name": ".96 Gal" - }, - { - "text": ".96 Kaliter Deco", - "name": ".96 Gal Deco" - }, - { - "text": ".96 Kaliter Pänter", - "name": "Clawz .96 Gal" - }, - { - "text": "Straalplonzer", - "name": "Jet Squelcher" - }, - { - "text": "Gemodde Straalplonzer", - "name": "Custom Jet Squelcher" - }, - { - "text": "Straalplonzer COB-R", - "name": "Jet Squelcher COB-R" - }, - { - "text": "Superspetter", - "name": "Splattershot" - }, - { - "text": "Tentatek Superspetter", - "name": "Tentatek Splattershot" - }, - { - "text": "Superspetter Glänz", - "name": "Glamorz Splattershot" - }, - { - "text": "Heldenspetter (replica)", - "name": "Hero Shot Replica" - }, - { - "text": "Ordespetter (replica)", - "name": "Order Shot Replica" - }, - { - "text": "Octospetter (replica)", - "name": "Octo Shot Replica" - }, - { - "text": "Spetterspuit", - "name": "Splash-o-matic" - }, - { - "text": "Spetterspuit Neo", - "name": "Neo Splash-o-matic" - }, - { - "text": "Spetterspuit GKK-O", - "name": "Splash-o-matic GCK-O" - }, - { - "text": "Superspetter Nova", - "name": "Splattershot Nova" - }, - { - "text": "Annaki Superspetter Nova", - "name": "Annaki Splattershot Nova" - }, - { - "text": "Spettertuit", - "name": "Sploosh-o-matic" - }, - { - "text": "Spettertuit Neo", - "name": "Neo Sploosh-o-matic" - }, - { - "text": "H-3 Langsnuit", - "name": "H-3 Nozzlenose" - }, - { - "text": "H-3 Langsnuit D", - "name": "H-3 Nozzlenose D" - }, - { - "text": "H-3 Langsnuit ANC-D", - "name": "H-3 Nozzlenose VIP-R" - }, - { - "text": "L-3 Stompsnuit", - "name": "L-3 Nozzlenose" - }, - { - "text": "L-3 Stompsnuit D", - "name": "L-3 Nozzlenose D" - }, - { - "text": "L-3 Stompsnuit Spränkel", - "name": "Glitterz L-3 Nozzlenose" - }, - { - "text": "Spatkuip", - "name": "Bloblobber" - }, - { - "text": "Spatkuip Deco", - "name": "Bloblobber Deco" - }, - { - "text": "Triomorser", - "name": "Tri-Slosher" - }, - { - "text": "Triomorser Nouveau", - "name": "Tri-Slosher Nouveau" - }, - { - "text": "Triomorser GRW-G", - "name": "Tri-Slosher ASH-N" - }, - { - "text": "Wringmorser", - "name": "Dread Wringer" - }, - { - "text": "Wringmorser D", - "name": "Dread Wringer D" - }, - { - "text": "Wringmorser Hörnz", - "name": "Hornz Dread Wringer" - }, - { - "text": "Klotstrommel", - "name": "Sloshing Machine" - }, - { - "text": "Klotstrommel Neo", - "name": "Sloshing Machine Neo" - }, - { - "text": "Morser", - "name": "Slosher" - }, - { - "text": "Morser Deco", - "name": "Slosher Deco" - }, - { - "text": "Ordemorser (replica)", - "name": "Order Slosher Replica" - }, - { - "text": "Kliederkachel", - "name": "Explosher" - }, - { - "text": "Gemodde Kliederkachel", - "name": "Custom Explosher" - }, - { - "text": "Stylospetling", - "name": "Ballpoint Splatling" - }, - { - "text": "Stylospetling Nouveau", - "name": "Ballpoint Splatling Nouveau" - }, - { - "text": "Examinator", - "name": "Heavy Edit Splatling" - }, - { - "text": "Examinator Nouveau", - "name": "Heavy Edit Splatling Nouveau" - }, - { - "text": "Blusling", - "name": "Hydra Splatling" - }, - { - "text": "Gemodde Blusling", - "name": "Custom Hydra Splatling" - }, - { - "text": "Blusling Störtvlud", - "name": "Torrentz Hydra Splatling" - }, - { - "text": "Mini-Spetling", - "name": "Mini Splatling" - }, - { - "text": "Zink Mini-Spetling", - "name": "Zink Mini Splatling" - }, - { - "text": "Mini-Spetling PYT-N", - "name": "Mini Splatling RTL-R" - }, - { - "text": "Spetling", - "name": "Heavy Splatling" - }, - { - "text": "Spetling Deco", - "name": "Heavy Splatling Deco" - }, - { - "text": "Ordespetling (replica)", - "name": "Order Splatling Replica" - }, - { - "text": "Pentapomper", - "name": "Wellstring V" - }, - { - "text": "Gemodde Pentapomper", - "name": "Custom Wellstring V" - }, - { - "text": "Triospanner", - "name": "Tri-Stringer" - }, - { - "text": "Inkline Triospanner", - "name": "Inkline Tri-Stringer" - }, - { - "text": "Triospanner Lüminus", - "name": "Bulbz Tri-Stringer" - }, - { - "text": "Ordespanner (replica)", - "name": "Order Stringer Replica" - }, - { - "text": "RIF-LEX 450", - "name": "REEF-LUX 450" - }, - { - "text": "RIF-LEX 450 Deco", - "name": "REEF-LUX 450 Deco" - }, - { - "text": "RIF-LEX 450 MEL-K", - "name": "REEF-LUX 450 MIL-K" - }, - { - "text": "Springschotel", - "name": "Squid Beakon" - }, - { - "text": "Curlingbom", - "name": "Curling Bomb" - }, - { - "text": "Bomblikje", - "name": "Fizzy Bomb" - }, - { - "text": "Ballonbom", - "name": "Burst Bomb" - }, - { - "text": "Robobom", - "name": "Autobomb" - }, - { - "text": "Klodderbom", - "name": "Splat Bomb" - }, - { - "text": "Kleefbom", - "name": "Suction Bomb" - }, - { - "text": "Torpedobom", - "name": "Torpedo" - }, - { - "text": "Ketskogel", - "name": "Angle Shooter" - }, - { - "text": "Detector", - "name": "Point Sensor" - }, - { - "text": "Gifmist", - "name": "Toxic Mist" - }, - { - "text": "Inktgordijn", - "name": "Splash Wall" - }, - { - "text": "Inktsprinkler", - "name": "Sprinkler" - }, - { - "text": "Inktmijn", - "name": "Ink Mine" - }, - { - "text": "Inktdief", - "name": "Ink Vac" - }, - { - "text": "Monstermorfose", - "name": "Kraken Royale" - }, - { - "text": "Krabbentank", - "name": "Crab Tank" - }, - { - "text": "PLED-scherm", - "name": "Splattercolor Screen" - }, - { - "text": "Reddingsbar", - "name": "Tacticooler" - }, - { - "text": "Voorschutter", - "name": "Super Chump" - }, - { - "text": "Joekelschild", - "name": "Big Bubbler" - }, - { - "text": "Spetterbui", - "name": "Ink Storm" - }, - { - "text": "Inktjet", - "name": "Inkjet" - }, - { - "text": "Megalofoon 5.1", - "name": "Killer Wail 5.1" - }, - { - "text": "Spetterraketten", - "name": "Tenta Missiles" - }, - { - "text": "Coole bom", - "name": "Booyah Bomb" - }, - { - "text": "Ultralanding", - "name": "Triple Splashdown" - }, - { - "text": "Golfbeker", - "name": "Wave Breaker" - }, - { - "text": "Opblaashaai", - "name": "Reefslider" - }, - { - "text": "Tentakabel", - "name": "Zipcaster" - }, - { - "text": "Tri-tornado", - "name": "Triple Inkstrike" - }, - { - "text": "Ultrastempel", - "name": "Ultra Stamp" - } - ], - "EUru": [ - { - "text": "Бластермат ПРО", - "name": "Rapid Blaster Pro" - }, - { - "text": "Бластермат ПРО «Деко»", - "name": "Rapid Blaster Pro Deco" - }, - { - "text": "Бластермат ПРО «К-ЛОТУН»", - "name": "Rapid Blaster Pro WNT-R" - }, - { - "text": "Контрабластер", - "name": "Clash Blaster" - }, - { - "text": "Контрабластер «Нео»", - "name": "Clash Blaster Neo" - }, - { - "text": "Бластермат", - "name": "Rapid Blaster" - }, - { - "text": "Бластермат «Деко»", - "name": "Rapid Blaster Deco" - }, - { - "text": "Дальнобластер", - "name": "Range Blaster" - }, - { - "text": "Дальнобластер «Понт»", - "name": "Custom Range Blaster" - }, - { - "text": "Бластер", - "name": "Blaster" - }, - { - "text": "Бластер «Понт»", - "name": "Custom Blaster" - }, - { - "text": "Бластер «Лоск»", - "name": "Gleamz Blaster" - }, - { - "text": "S-BLAST 92", - "name": "S-BLAST '92" - }, - { - "text": "S-BLAST 91", - "name": "S-BLAST '91" - }, - { - "text": "Лунобластер", - "name": "Luna Blaster" - }, - { - "text": "Лунобластер «Нео»", - "name": "Luna Blaster Neo" - }, - { - "text": "Бластер Порядка (клон)", - "name": "Order Blaster Replica" - }, - { - "text": "Веерокисть «Плюхассо»", - "name": "Painbrush" - }, - { - "text": "Веерокисть «НуВо»", - "name": "Painbrush Nouveau" - }, - { - "text": "Веерокисть «БРОН-З»", - "name": "Painbrush BRN-Z" - }, - { - "text": "Кисть", - "name": "Inkbrush" - }, - { - "text": "Кисть «НуВо»", - "name": "Inkbrush Nouveau" - }, - { - "text": "Арт-кисть", - "name": "Octobrush" - }, - { - "text": "Арт-кисть «НуВо»", - "name": "Octobrush Nouveau" - }, - { - "text": "Арт-кисть «Комета»", - "name": "Cometz Octobrush" - }, - { - "text": "Кисть Порядка (клон)", - "name": "Orderbrush Replica" - }, - { - "text": "Трубастер", - "name": "Goo Tuber" - }, - { - "text": "Трубастер «Понт»", - "name": "Custom Goo Tuber" - }, - { - "text": "Бамбух 14-I", - "name": "Bamboozler 14 Mk I" - }, - { - "text": "Бамбух 14-II", - "name": "Bamboozler 14 Mk II" - }, - { - "text": "Снайп-э-литр 95", - "name": "E-liter 4K Scope" - }, - { - "text": "Снайп-э-литр 95 «Понт»", - "name": "Custom E-liter 4K Scope" - }, - { - "text": "Э-литр 95", - "name": "E-liter 4K" - }, - { - "text": "Э-литр 95 «Понт»", - "name": "Custom E-liter 4K" - }, - { - "text": "Снайпокрас", - "name": "Splatterscope" - }, - { - "text": "Снайпокрас «ЙожкоТюр»", - "name": "Z+F Splatterscope" - }, - { - "text": "Снайпокрас «К-ФЛЯЖ»", - "name": "Splatterscope CAM-O" - }, - { - "text": "Сплатган", - "name": "Splat Charger" - }, - { - "text": "Сплатган «ЙожкоТюр»", - "name": "Z+F Splat Charger" - }, - { - "text": "Сплатган «К-ФЛЯЖ»", - "name": "Splat Charger CAM-O" - }, - { - "text": "Сплатган Порядка (клон)", - "name": "Order Charger Replica" - }, - { - "text": "Патрондаш 5Т", - "name": "Snipewriter 5H" - }, - { - "text": "Патрондаш 5М", - "name": "Snipewriter 5B" - }, - { - "text": "Стиратель", - "name": "Classic Squiffer" - }, - { - "text": "Стиратель-II", - "name": "New Squiffer" - }, - { - "text": "Спурт-плескари", - "name": "Dualie Squelchers" - }, - { - "text": "Спурт-плескари «Понт»", - "name": "Custom Dualie Squelchers" - }, - { - "text": "Спурт-плескари «Копыта»", - "name": "Hoofz Dualie Squelchers" - }, - { - "text": "Термоплюхи 525", - "name": "Glooga Dualies" - }, - { - "text": "Термоплюхи 525 «Деко»", - "name": "Glooga Dualies Deco" - }, - { - "text": "Краскотушители 255", - "name": "Douser Dualies FF" - }, - { - "text": "Краскотушители «Понт»", - "name": "Custom Douser Dualies FF" - }, - { - "text": "Спуртометы", - "name": "Splat Dualies" - }, - { - "text": "Спуртометы «К.К.»", - "name": "Enperry Splat Dualies" - }, - { - "text": "Спуртометы «Искра»", - "name": "Twinklez Splat Dualies" - }, - { - "text": "Спуртометы Порядка (кл.)", - "name": "Order Dualie Replicas" - }, - { - "text": "Импрессиометы", - "name": "Dapple Dualies" - }, - { - "text": "Импрессиометы «НуВо»", - "name": "Dapple Dualies Nouveau" - }, - { - "text": "Импрессиометы «Ф-ЛИН»", - "name": "Dapple Dualies NOC-T" - }, - { - "text": "Черные спуртокроссы", - "name": "Dark Tetra Dualies" - }, - { - "text": "Белые спуртокроссы", - "name": "Light Tetra Dualies" - }, - { - "text": "Валик ЛАЙТ", - "name": "Carbon Roller" - }, - { - "text": "Валик ЛАЙТ «Деко»", - "name": "Carbon Roller Deco" - }, - { - "text": "Валик ЛАЙТ «У-ДИЛЬ»", - "name": "Carbon Roller ANG-L" - }, - { - "text": "Мотовалик", - "name": "Dynamo Roller" - }, - { - "text": "Мотовалик VIP", - "name": "Gold Dynamo Roller" - }, - { - "text": "Мотовалик «Пульсар»", - "name": "Starz Dynamo Roller" - }, - { - "text": "Омнивалик", - "name": "Flingza Roller" - }, - { - "text": "Омнивалик «Блеск»", - "name": "Foil Flingza Roller" - }, - { - "text": "Сплат-валик", - "name": "Splat Roller" - }, - { - "text": "Сплат-валик «Кра-Кен»", - "name": "Krak-On Splat Roller" - }, - { - "text": "Валик Порядка (клон)", - "name": "Order Roller Replica" - }, - { - "text": "Макси-валик", - "name": "Big Swig Roller" - }, - { - "text": "Макси-валик «Экспресс»", - "name": "Big Swig Roller Express" - }, - { - "text": "Макси-валик «Планета»", - "name": "Planetz Big Swig Roller" - }, - { - "text": "Мятная дентана", - "name": "Mint Decavitator" - }, - { - "text": "Угольная дентана", - "name": "Charcoal Decavitator" - }, - { - "text": "Cплат-дворник", - "name": "Splatana Wiper" - }, - { - "text": "Cплат-дворник «Деко»", - "name": "Splatana Wiper Deco" - }, - { - "text": "Сплат-дворник «РЖАВ-Ч»", - "name": "Splatana Wiper RUS-T" - }, - { - "text": "Сплат-печатель", - "name": "Splatana Stamper" - }, - { - "text": "Сплат-печатель «НуВо»", - "name": "Splatana Stamper Nouveau" - }, - { - "text": "Сплат-печатель «Уклейка»", - "name": "Stickerz Splatana Stamper" - }, - { - "text": "Сплат-катана Порядка (кл.)", - "name": "Order Splatana Replica" - }, - { - "text": "Шпионский зонт", - "name": "Undercover Brella" - }, - { - "text": "Шпионский зонт «Родиччи»", - "name": "Undercover Sorella Brella" - }, - { - "text": "Шпионский зонт «Коллаж»", - "name": "Patternz Undercover Brella" - }, - { - "text": "Ребут-зонтган М-1", - "name": "Recycled Brella 24 Mk I" - }, - { - "text": "Ребут-зонтган М-2", - "name": "Recycled Brella 24 Mk II" - }, - { - "text": "Сплат-зонт", - "name": "Splat Brella" - }, - { - "text": "Зонтган «Родиччи»", - "name": "Sorella Brella" - }, - { - "text": "Зонтган Порядка (клон)", - "name": "Order Brella Replica" - }, - { - "text": "Зонтент", - "name": "Tenta Brella" - }, - { - "text": "Зонтент «Родиччи»", - "name": "Tenta Sorella Brella" - }, - { - "text": "Зонтент «СЛИВ-К»", - "name": "Tenta Brella CRE-M" - }, - { - "text": "Аэроспрей", - "name": "Aerospray MG" - }, - { - "text": "Аэроспрей ДЕЛЮКС", - "name": "Aerospray RG" - }, - { - "text": "Аэроспрей «Перелив»", - "name": "Colorz Aerospray" - }, - { - "text": "Каплестрел ПРО", - "name": "Splattershot Pro" - }, - { - "text": "Каплестрел ПРО «Блиц»", - "name": "Forge Splattershot Pro" - }, - { - "text": "Каплестрел ПРО «СТУ-Ж»", - "name": "Splattershot Pro FRZ-N" - }, - { - "text": "Каплестрел-У", - "name": "Splattershot Jr." - }, - { - "text": "Каплестрел-У 2.0", - "name": "Custom Splattershot Jr." - }, - { - "text": "Бутылятор", - "name": "Squeezer" - }, - { - "text": "Бутылятор «Блеск»", - "name": "Foil Squeezer" - }, - { - "text": "Струевик .52", - "name": ".52 Gal" - }, - { - "text": "Струевик .52 «Деко»", - "name": ".52 Gal Deco" - }, - { - "text": "Струевик .96", - "name": ".96 Gal" - }, - { - "text": "Струевик .96 «Деко»", - "name": ".96 Gal Deco" - }, - { - "text": "Струевик .96 «Царап»", - "name": "Clawz .96 Gal" - }, - { - "text": "Плескарь", - "name": "Jet Squelcher" - }, - { - "text": "Плескарь «Понт»", - "name": "Custom Jet Squelcher" - }, - { - "text": "Плескарь «КОБ-Р»", - "name": "Jet Squelcher COB-R" - }, - { - "text": "Каплестрел", - "name": "Splattershot" - }, - { - "text": "Каплестрел «Щуччи»", - "name": "Tentatek Splattershot" - }, - { - "text": "Каплестрел «Блесна»", - "name": "Glamorz Splattershot" - }, - { - "text": "Каплестрел героя (клон)", - "name": "Hero Shot Replica" - }, - { - "text": "Каплестрел Порядка (клон)", - "name": "Order Shot Replica" - }, - { - "text": "Осьмострел (клон)", - "name": "Octo Shot Replica" - }, - { - "text": "Плюхомет", - "name": "Splash-o-matic" - }, - { - "text": "Плюхомет «Нео»", - "name": "Neo Splash-o-matic" - }, - { - "text": "Плюхомет «Г-КОН»", - "name": "Splash-o-matic GCK-O" - }, - { - "text": "Каплестрел НОВА", - "name": "Splattershot Nova" - }, - { - "text": "Каплестрел НОВА-ПАНК", - "name": "Annaki Splattershot Nova" - }, - { - "text": "N-ZAP 85", - "name": "N-ZAP '85" - }, - { - "text": "N-ZAP 89", - "name": "N-ZAP '89" - }, - { - "text": "Плюхотрон", - "name": "Sploosh-o-matic" - }, - { - "text": "Плюхотрон «Нео»", - "name": "Neo Sploosh-o-matic" - }, - { - "text": "Тяжелый каплетрон", - "name": "H-3 Nozzlenose" - }, - { - "text": "Тяжелый каплетрон 2.0", - "name": "H-3 Nozzlenose D" - }, - { - "text": "Т-каплетрон «МАМ-Б»", - "name": "H-3 Nozzlenose VIP-R" - }, - { - "text": "Каплетрон-компакт", - "name": "L-3 Nozzlenose" - }, - { - "text": "Каплетрон-компакт 2.0", - "name": "L-3 Nozzlenose D" - }, - { - "text": "Каплетрон «Страз»", - "name": "Glitterz L-3 Nozzlenose" - }, - { - "text": "Ваннаган", - "name": "Bloblobber" - }, - { - "text": "Ваннаган «Деко»", - "name": "Bloblobber Deco" - }, - { - "text": "Ведроган 3 в 1", - "name": "Tri-Slosher" - }, - { - "text": "Ведроган 3 в 1 «НуВо»", - "name": "Tri-Slosher Nouveau" - }, - { - "text": "Ведроган 3 в 1 «ГАЛЬ-Н»", - "name": "Tri-Slosher ASH-N" - }, - { - "text": "Отжиматель", - "name": "Dread Wringer" - }, - { - "text": "Отжиматель 2.0", - "name": "Dread Wringer D" - }, - { - "text": "Отжиматель «Рога»", - "name": "Hornz Dread Wringer" - }, - { - "text": "Центрифугер", - "name": "Sloshing Machine" - }, - { - "text": "Центрифугер «Нео»", - "name": "Sloshing Machine Neo" - }, - { - "text": "Ведроган", - "name": "Slosher" - }, - { - "text": "Ведроган «Деко»", - "name": "Slosher Deco" - }, - { - "text": "Ведроган Порядка (клон)", - "name": "Order Slosher Replica" - }, - { - "text": "Шпарган", - "name": "Explosher" - }, - { - "text": "Шпарган «Понт»", - "name": "Custom Explosher" - }, - { - "text": "Кугельшрайбер", - "name": "Ballpoint Splatling" - }, - { - "text": "Кугельшрайбер «НуВо»", - "name": "Ballpoint Splatling Nouveau" - }, - { - "text": "Брызгомет XL «Корректор»", - "name": "Heavy Edit Splatling" - }, - { - "text": "Корректор «НуВо»", - "name": "Heavy Edit Splatling Nouveau" - }, - { - "text": "Брызгомет «Горыныч»", - "name": "Hydra Splatling" - }, - { - "text": "«Понт-Горыныч»", - "name": "Custom Hydra Splatling" - }, - { - "text": "«Баллон-Горыныч»", - "name": "Torrentz Hydra Splatling" - }, - { - "text": "Брызгомет-М", - "name": "Mini Splatling" - }, - { - "text": "Брызгомет-М «Шпротус»", - "name": "Zink Mini Splatling" - }, - { - "text": "Брызгомет-М «Г-ДЮК»", - "name": "Mini Splatling RTL-R" - }, - { - "text": "Наутилус-47", - "name": "Nautilus 47" - }, - { - "text": "Наутилус-79", - "name": "Nautilus 79" - }, - { - "text": "Брызгомет XL", - "name": "Heavy Splatling" - }, - { - "text": "Брызгомет XL «Деко»", - "name": "Heavy Splatling Deco" - }, - { - "text": "Брызгомет Порядка (клон)", - "name": "Order Splatling Replica" - }, - { - "text": "Квинтиватор «Колодезь»", - "name": "Wellstring V" - }, - { - "text": "Квинтиватор «Понт»", - "name": "Custom Wellstring V" - }, - { - "text": "Тритиватор", - "name": "Tri-Stringer" - }, - { - "text": "Тритиватор «Коллаб»", - "name": "Inkline Tri-Stringer" - }, - { - "text": "Тритиватор «Диод»", - "name": "Bulbz Tri-Stringer" - }, - { - "text": "Тетиватор Порядка (клон)", - "name": "Order Stringer Replica" - }, - { - "text": "КО-РАЛЛ 450", - "name": "REEF-LUX 450" - }, - { - "text": "КО-РАЛЛ 450 «Деко»", - "name": "REEF-LUX 450 Deco" - }, - { - "text": "КО-РАЛЛ 450 «М-ЛОКО»", - "name": "REEF-LUX 450 MIL-K" - }, - { - "text": "Прыжковый маячок", - "name": "Squid Beakon" - }, - { - "text": "Керлинг-бомба", - "name": "Curling Bomb" - }, - { - "text": "Содовая бомба", - "name": "Fizzy Bomb" - }, - { - "text": "Разрывная бомба", - "name": "Burst Bomb" - }, - { - "text": "Робобомба", - "name": "Autobomb" - }, - { - "text": "Брызгающая бомба", - "name": "Splat Bomb" - }, - { - "text": "Бомба на присоске", - "name": "Suction Bomb" - }, - { - "text": "Торпедная бомба", - "name": "Torpedo" - }, - { - "text": "Углострел", - "name": "Angle Shooter" - }, - { - "text": "Маркер движения", - "name": "Point Sensor" - }, - { - "text": "Едкий туман", - "name": "Toxic Mist" - }, - { - "text": "Чернильный занавес", - "name": "Splash Wall" - }, - { - "text": "Распылятор", - "name": "Sprinkler" - }, - { - "text": "Мина", - "name": "Ink Mine" - }, - { - "text": "Краскосос", - "name": "Ink Vac" - }, - { - "text": "Тираннокракен", - "name": "Kraken Royale" - }, - { - "text": "Кработанк", - "name": "Crab Tank" - }, - { - "text": "Заливной барьер", - "name": "Splattercolor Screen" - }, - { - "text": "Тоникулер", - "name": "Tacticooler" - }, - { - "text": "Суперпрыжатор", - "name": "Super Chump" - }, - { - "text": "Шарощит", - "name": "Big Bubbler" - }, - { - "text": "Туча краски", - "name": "Ink Storm" - }, - { - "text": "Красколет", - "name": "Inkjet" - }, - { - "text": "Мегалофон 5.1", - "name": "Killer Wail 5.1" - }, - { - "text": "Каракатница", - "name": "Tenta Missiles" - }, - { - "text": "Йо-хо-плюхер", - "name": "Booyah Bomb" - }, - { - "text": "Тройной мегаплюх", - "name": "Triple Splashdown" - }, - { - "text": "Волноплюхер", - "name": "Wave Breaker" - }, - { - "text": "Мотокула", - "name": "Reefslider" - }, - { - "text": "Краскотрос", - "name": "Zipcaster" - }, - { - "text": "Тройной торнадо", - "name": "Triple Inkstrike" - }, - { - "text": "Гранатомет «Трезубец»", - "name": "Trizooka" - }, - { - "text": "Припечать", - "name": "Ultra Stamp" - } - ], - "JPja": [ - { - "text": "Rブラスターエリート", - "name": "Rapid Blaster Pro" - }, - { - "text": "Rブラスターエリートデコ", - "name": "Rapid Blaster Pro Deco" - }, - { - "text": "RブラスターエリートWNTR", - "name": "Rapid Blaster Pro WNT-R" - }, - { - "text": "クラッシュブラスター", - "name": "Clash Blaster" - }, - { - "text": "クラッシュブラスターネオ", - "name": "Clash Blaster Neo" - }, - { - "text": "ラピッドブラスター", - "name": "Rapid Blaster" - }, - { - "text": "ラピッドブラスターデコ", - "name": "Rapid Blaster Deco" - }, - { - "text": "ロングブラスター", - "name": "Range Blaster" - }, - { - "text": "ロングブラスターカスタム", - "name": "Custom Range Blaster" - }, - { - "text": "ホットブラスター", - "name": "Blaster" - }, - { - "text": "ホットブラスターカスタム", - "name": "Custom Blaster" - }, - { - "text": "ホットブラスター艶", - "name": "Gleamz Blaster" - }, - { - "text": "S-BLAST92", - "name": "S-BLAST '92" - }, - { - "text": "S-BLAST91", - "name": "S-BLAST '91" - }, - { - "text": "ノヴァブラスター", - "name": "Luna Blaster" - }, - { - "text": "ノヴァブラスターネオ", - "name": "Luna Blaster Neo" - }, - { - "text": "オーダーブラスター レプリカ", - "name": "Order Blaster Replica" - }, - { - "text": "フィンセント", - "name": "Painbrush" - }, - { - "text": "フィンセント・ヒュー", - "name": "Painbrush Nouveau" - }, - { - "text": "フィンセントBRNZ", - "name": "Painbrush BRN-Z" - }, - { - "text": "パブロ", - "name": "Inkbrush" - }, - { - "text": "パブロ・ヒュー", - "name": "Inkbrush Nouveau" - }, - { - "text": "ホクサイ", - "name": "Octobrush" - }, - { - "text": "ホクサイ・ヒュー", - "name": "Octobrush Nouveau" - }, - { - "text": "ホクサイ彗", - "name": "Cometz Octobrush" - }, - { - "text": "オーダーブラシ レプリカ", - "name": "Orderbrush Replica" - }, - { - "text": "ソイチューバー", - "name": "Goo Tuber" - }, - { - "text": "ソイチューバーカスタム", - "name": "Custom Goo Tuber" - }, - { - "text": "14式竹筒銃・甲", - "name": "Bamboozler 14 Mk I" - }, - { - "text": "14式竹筒銃・乙", - "name": "Bamboozler 14 Mk II" - }, - { - "text": "4Kスコープ", - "name": "E-liter 4K Scope" - }, - { - "text": "4Kスコープカスタム", - "name": "Custom E-liter 4K Scope" - }, - { - "text": "リッター4K", - "name": "E-liter 4K" - }, - { - "text": "リッター4Kカスタム", - "name": "Custom E-liter 4K" - }, - { - "text": "スプラスコープ", - "name": "Splatterscope" - }, - { - "text": "スプラスコープコラボ", - "name": "Z+F Splatterscope" - }, - { - "text": "スプラスコープFRST", - "name": "Splatterscope CAM-O" - }, - { - "text": "スプラチャージャー", - "name": "Splat Charger" - }, - { - "text": "スプラチャージャーコラボ", - "name": "Z+F Splat Charger" - }, - { - "text": "スプラチャージャーFRST", - "name": "Splat Charger CAM-O" - }, - { - "text": "オーダーチャージャー レプリカ", - "name": "Order Charger Replica" - }, - { - "text": "R-PEN/5H", - "name": "Snipewriter 5H" - }, - { - "text": "R-PEN/5B", - "name": "Snipewriter 5B" - }, - { - "text": "スクイックリンα", - "name": "Classic Squiffer" - }, - { - "text": "スクイックリンβ", - "name": "New Squiffer" - }, - { - "text": "デュアルスイーパー", - "name": "Dualie Squelchers" - }, - { - "text": "デュアルスイーパーカスタム", - "name": "Custom Dualie Squelchers" - }, - { - "text": "デュアルスイーパー蹄", - "name": "Hoofz Dualie Squelchers" - }, - { - "text": "ケルビン525", - "name": "Glooga Dualies" - }, - { - "text": "ケルビン525デコ", - "name": "Glooga Dualies Deco" - }, - { - "text": "ガエンFF", - "name": "Douser Dualies FF" - }, - { - "text": "ガエンFFカスタム", - "name": "Custom Douser Dualies FF" - }, - { - "text": "スプラマニューバー", - "name": "Splat Dualies" - }, - { - "text": "スプラマニューバーコラボ", - "name": "Enperry Splat Dualies" - }, - { - "text": "スプラマニューバー耀", - "name": "Twinklez Splat Dualies" - }, - { - "text": "オーダーマニューバー レプリカ", - "name": "Order Dualie Replicas" - }, - { - "text": "スパッタリー", - "name": "Dapple Dualies" - }, - { - "text": "スパッタリー・ヒュー", - "name": "Dapple Dualies Nouveau" - }, - { - "text": "スパッタリーOWL", - "name": "Dapple Dualies NOC-T" - }, - { - "text": "クアッドホッパーブラック", - "name": "Dark Tetra Dualies" - }, - { - "text": "クアッドホッパーホワイト", - "name": "Light Tetra Dualies" - }, - { - "text": "カーボンローラー", - "name": "Carbon Roller" - }, - { - "text": "カーボンローラーデコ", - "name": "Carbon Roller Deco" - }, - { - "text": "カーボンローラーANGL", - "name": "Carbon Roller ANG-L" - }, - { - "text": "ダイナモローラー", - "name": "Dynamo Roller" - }, - { - "text": "ダイナモローラーテスラ", - "name": "Gold Dynamo Roller" - }, - { - "text": "ダイナモローラー冥", - "name": "Starz Dynamo Roller" - }, - { - "text": "ヴァリアブルローラー", - "name": "Flingza Roller" - }, - { - "text": "ヴァリアブルローラーフォイル", - "name": "Foil Flingza Roller" - }, - { - "text": "スプラローラー", - "name": "Splat Roller" - }, - { - "text": "スプラローラーコラボ", - "name": "Krak-On Splat Roller" - }, - { - "text": "オーダーローラー レプリカ", - "name": "Order Roller Replica" - }, - { - "text": "ワイドローラー", - "name": "Big Swig Roller" - }, - { - "text": "ワイドローラーコラボ", - "name": "Big Swig Roller Express" - }, - { - "text": "ワイドローラー惑", - "name": "Planetz Big Swig Roller" - }, - { - "text": "デンタルワイパーミント", - "name": "Mint Decavitator" - }, - { - "text": "デンタルワイパースミ", - "name": "Charcoal Decavitator" - }, - { - "text": "ドライブワイパー", - "name": "Splatana Wiper" - }, - { - "text": "ドライブワイパーデコ", - "name": "Splatana Wiper Deco" - }, - { - "text": "ドライブワイパーRUST", - "name": "Splatana Wiper RUS-T" - }, - { - "text": "ジムワイパー", - "name": "Splatana Stamper" - }, - { - "text": "ジムワイパー・ヒュー", - "name": "Splatana Stamper Nouveau" - }, - { - "text": "ジムワイパー封", - "name": "Stickerz Splatana Stamper" - }, - { - "text": "オーダーワイパー レプリカ", - "name": "Order Splatana Replica" - }, - { - "text": "スパイガジェット", - "name": "Undercover Brella" - }, - { - "text": "スパイガジェットソレーラ", - "name": "Undercover Sorella Brella" - }, - { - "text": "スパイガジェット繚", - "name": "Patternz Undercover Brella" - }, - { - "text": "24式張替傘・甲", - "name": "Recycled Brella 24 Mk I" - }, - { - "text": "24式張替傘・乙", - "name": "Recycled Brella 24 Mk II" - }, - { - "text": "パラシェルター", - "name": "Splat Brella" - }, - { - "text": "パラシェルターソレーラ", - "name": "Sorella Brella" - }, - { - "text": "オーダーシェルター レプリカ", - "name": "Order Brella Replica" - }, - { - "text": "キャンピングシェルター", - "name": "Tenta Brella" - }, - { - "text": "キャンピングシェルターソレーラ", - "name": "Tenta Sorella Brella" - }, - { - "text": "キャンピングシェルターCREM", - "name": "Tenta Brella CRE-M" - }, - { - "text": "プロモデラーMG", - "name": "Aerospray MG" - }, - { - "text": "プロモデラーRG", - "name": "Aerospray RG" - }, - { - "text": "プロモデラー彩", - "name": "Colorz Aerospray" - }, - { - "text": "プライムシューター", - "name": "Splattershot Pro" - }, - { - "text": "プライムシューターコラボ", - "name": "Forge Splattershot Pro" - }, - { - "text": "プライムシューターFRZN", - "name": "Splattershot Pro FRZ-N" - }, - { - "text": "わかばシューター", - "name": "Splattershot Jr." - }, - { - "text": "もみじシューター", - "name": "Custom Splattershot Jr." - }, - { - "text": "ボトルガイザー", - "name": "Squeezer" - }, - { - "text": "ボトルガイザーフォイル", - "name": "Foil Squeezer" - }, - { - "text": ".52ガロン", - "name": ".52 Gal" - }, - { - "text": ".52ガロンデコ", - "name": ".52 Gal Deco" - }, - { - "text": ".96ガロン", - "name": ".96 Gal" - }, - { - "text": ".96ガロンデコ", - "name": ".96 Gal Deco" - }, - { - "text": ".96ガロン爪", - "name": "Clawz .96 Gal" - }, - { - "text": "ジェットスイーパー", - "name": "Jet Squelcher" - }, - { - "text": "ジェットスイーパーカスタム", - "name": "Custom Jet Squelcher" - }, - { - "text": "ジェットスイーパーCOBR", - "name": "Jet Squelcher COB-R" - }, - { - "text": "スプラシューター", - "name": "Splattershot" - }, - { - "text": "スプラシューターコラボ", - "name": "Tentatek Splattershot" - }, - { - "text": "スプラシューター煌", - "name": "Glamorz Splattershot" - }, - { - "text": "ヒーローシューター レプリカ", - "name": "Hero Shot Replica" - }, - { - "text": "オーダーシューター レプリカ", - "name": "Order Shot Replica" - }, - { - "text": "オクタシューター レプリカ", - "name": "Octo Shot Replica" - }, - { - "text": "シャープマーカー", - "name": "Splash-o-matic" - }, - { - "text": "シャープマーカーネオ", - "name": "Neo Splash-o-matic" - }, - { - "text": "シャープマーカーGECK", - "name": "Splash-o-matic GCK-O" - }, - { - "text": "スペースシューター", - "name": "Splattershot Nova" - }, - { - "text": "スペースシューターコラボ", - "name": "Annaki Splattershot Nova" - }, - { - "text": "N-ZAP85", - "name": "N-ZAP '85" - }, - { - "text": "N-ZAP89", - "name": "N-ZAP '89" - }, - { - "text": "ボールドマーカー", - "name": "Sploosh-o-matic" - }, - { - "text": "ボールドマーカーネオ", - "name": "Neo Sploosh-o-matic" - }, - { - "text": "H3リールガン", - "name": "H-3 Nozzlenose" - }, - { - "text": "H3リールガンD", - "name": "H-3 Nozzlenose D" - }, - { - "text": "H3リールガンSNAK", - "name": "H-3 Nozzlenose VIP-R" - }, - { - "text": "L3リールガン", - "name": "L-3 Nozzlenose" - }, - { - "text": "L3リールガンD", - "name": "L-3 Nozzlenose D" - }, - { - "text": "L3リールガン箔", - "name": "Glitterz L-3 Nozzlenose" - }, - { - "text": "オーバーフロッシャー", - "name": "Bloblobber" - }, - { - "text": "オーバーフロッシャーデコ", - "name": "Bloblobber Deco" - }, - { - "text": "ヒッセン", - "name": "Tri-Slosher" - }, - { - "text": "ヒッセン・ヒュー", - "name": "Tri-Slosher Nouveau" - }, - { - "text": "ヒッセンASH", - "name": "Tri-Slosher ASH-N" - }, - { - "text": "モップリン", - "name": "Dread Wringer" - }, - { - "text": "モップリンD", - "name": "Dread Wringer D" - }, - { - "text": "モップリン角", - "name": "Hornz Dread Wringer" - }, - { - "text": "スクリュースロッシャー", - "name": "Sloshing Machine" - }, - { - "text": "スクリュースロッシャーネオ", - "name": "Sloshing Machine Neo" - }, - { - "text": "バケットスロッシャー", - "name": "Slosher" - }, - { - "text": "バケットスロッシャーデコ", - "name": "Slosher Deco" - }, - { - "text": "オーダースロッシャー レプリカ", - "name": "Order Slosher Replica" - }, - { - "text": "エクスプロッシャー", - "name": "Explosher" - }, - { - "text": "エクスプロッシャーカスタム", - "name": "Custom Explosher" - }, - { - "text": "クーゲルシュライバー", - "name": "Ballpoint Splatling" - }, - { - "text": "クーゲルシュライバー・ヒュー", - "name": "Ballpoint Splatling Nouveau" - }, - { - "text": "イグザミナー", - "name": "Heavy Edit Splatling" - }, - { - "text": "イグザミナー・ヒュー", - "name": "Heavy Edit Splatling Nouveau" - }, - { - "text": "ハイドラント", - "name": "Hydra Splatling" - }, - { - "text": "ハイドラントカスタム", - "name": "Custom Hydra Splatling" - }, - { - "text": "ハイドラント圧", - "name": "Torrentz Hydra Splatling" - }, - { - "text": "スプラスピナー", - "name": "Mini Splatling" - }, - { - "text": "スプラスピナーコラボ", - "name": "Zink Mini Splatling" - }, - { - "text": "スプラスピナーPYTN", - "name": "Mini Splatling RTL-R" - }, - { - "text": "ノーチラス47", - "name": "Nautilus 47" - }, - { - "text": "ノーチラス79", - "name": "Nautilus 79" - }, - { - "text": "バレルスピナー", - "name": "Heavy Splatling" - }, - { - "text": "バレルスピナーデコ", - "name": "Heavy Splatling Deco" - }, - { - "text": "オーダースピナー レプリカ", - "name": "Order Splatling Replica" - }, - { - "text": "フルイドV", - "name": "Wellstring V" - }, - { - "text": "フルイドVカスタム", - "name": "Custom Wellstring V" - }, - { - "text": "トライストリンガー", - "name": "Tri-Stringer" - }, - { - "text": "トライストリンガーコラボ", - "name": "Inkline Tri-Stringer" - }, - { - "text": "トライストリンガー燈", - "name": "Bulbz Tri-Stringer" - }, - { - "text": "オーダーストリンガー レプリカ", - "name": "Order Stringer Replica" - }, - { - "text": "LACT-450", - "name": "REEF-LUX 450" - }, - { - "text": "LACT-450デコ", - "name": "REEF-LUX 450 Deco" - }, - { - "text": "LACT-450MILK", - "name": "REEF-LUX 450 MIL-K" - }, - { - "text": "ジャンプビーコン", - "name": "Squid Beakon" - }, - { - "text": "カーリングボム", - "name": "Curling Bomb" - }, - { - "text": "タンサンボム", - "name": "Fizzy Bomb" - }, - { - "text": "クイックボム", - "name": "Burst Bomb" - }, - { - "text": "ロボットボム", - "name": "Autobomb" - }, - { - "text": "スプラッシュボム", - "name": "Splat Bomb" - }, - { - "text": "キューバンボム", - "name": "Suction Bomb" - }, - { - "text": "トーピード", - "name": "Torpedo" - }, - { - "text": "ラインマーカー", - "name": "Angle Shooter" - }, - { - "text": "ポイントセンサー", - "name": "Point Sensor" - }, - { - "text": "ポイズンミスト", - "name": "Toxic Mist" - }, - { - "text": "スプラッシュシールド", - "name": "Splash Wall" - }, - { - "text": "スプリンクラー", - "name": "Sprinkler" - }, - { - "text": "トラップ", - "name": "Ink Mine" - }, - { - "text": "キューインキ", - "name": "Ink Vac" - }, - { - "text": "テイオウイカ", - "name": "Kraken Royale" - }, - { - "text": "カニタンク", - "name": "Crab Tank" - }, - { - "text": "スミナガシート", - "name": "Splattercolor Screen" - }, - { - "text": "エナジースタンド", - "name": "Tacticooler" - }, - { - "text": "デコイチラシ", - "name": "Super Chump" - }, - { - "text": "グレートバリア", - "name": "Big Bubbler" - }, - { - "text": "アメフラシ", - "name": "Ink Storm" - }, - { - "text": "ジェットパック", - "name": "Inkjet" - }, - { - "text": "メガホンレーザー5.1ch", - "name": "Killer Wail 5.1" - }, - { - "text": "マルチミサイル", - "name": "Tenta Missiles" - }, - { - "text": "ナイスダマ", - "name": "Booyah Bomb" - }, - { - "text": "ウルトラチャクチ", - "name": "Triple Splashdown" - }, - { - "text": "ホップソナー", - "name": "Wave Breaker" - }, - { - "text": "サメライド", - "name": "Reefslider" - }, - { - "text": "ショクワンダー", - "name": "Zipcaster" - }, - { - "text": "トリプルトルネード", - "name": "Triple Inkstrike" - }, - { - "text": "ウルトラショット", - "name": "Trizooka" - }, - { - "text": "ウルトラハンコ", - "name": "Ultra Stamp" - } - ], - "KRko": [ - { - "text": "R 블래스터 엘리트", - "name": "Rapid Blaster Pro" - }, - { - "text": "R 블래스터 엘리트 데코", - "name": "Rapid Blaster Pro Deco" - }, - { - "text": "R 블래스터 엘리트 WNTR", - "name": "Rapid Blaster Pro WNT-R" - }, - { - "text": "크래시 블래스터", - "name": "Clash Blaster" - }, - { - "text": "네오 크래시 블래스터", - "name": "Clash Blaster Neo" - }, - { - "text": "래피드 블래스터", - "name": "Rapid Blaster" - }, - { - "text": "래피드 블래스터 데코", - "name": "Rapid Blaster Deco" - }, - { - "text": "롱 블래스터", - "name": "Range Blaster" - }, - { - "text": "커스텀 롱 블래스터", - "name": "Custom Range Blaster" - }, - { - "text": "핫 블래스터", - "name": "Blaster" - }, - { - "text": "커스텀 핫 블래스터", - "name": "Custom Blaster" - }, - { - "text": "글림 핫 블래스터", - "name": "Gleamz Blaster" - }, - { - "text": "S-BLAST92", - "name": "S-BLAST '92" - }, - { - "text": "S-BLAST91", - "name": "S-BLAST '91" - }, - { - "text": "노바 블래스터", - "name": "Luna Blaster" - }, - { - "text": "네오 노바 블래스터", - "name": "Luna Blaster Neo" - }, - { - "text": "오더 블래스터 레플리카", - "name": "Order Blaster Replica" - }, - { - "text": "빈센트", - "name": "Painbrush" - }, - { - "text": "빈센트 휴", - "name": "Painbrush Nouveau" - }, - { - "text": "빈센트 BRNZ", - "name": "Painbrush BRN-Z" - }, - { - "text": "파블로", - "name": "Inkbrush" - }, - { - "text": "파블로 휴", - "name": "Inkbrush Nouveau" - }, - { - "text": "호쿠사이", - "name": "Octobrush" - }, - { - "text": "호쿠사이 휴", - "name": "Octobrush Nouveau" - }, - { - "text": "코메트 호쿠사이", - "name": "Cometz Octobrush" - }, - { - "text": "오더 브러시 레플리카", - "name": "Orderbrush Replica" - }, - { - "text": "소이 튜버", - "name": "Goo Tuber" - }, - { - "text": "커스텀 소이 튜버", - "name": "Custom Goo Tuber" - }, - { - "text": "14식 대나무 총 갑", - "name": "Bamboozler 14 Mk I" - }, - { - "text": "14식 대나무 총 을", - "name": "Bamboozler 14 Mk II" - }, - { - "text": "4K 스코프", - "name": "E-liter 4K Scope" - }, - { - "text": "커스텀 4K 스코프", - "name": "Custom E-liter 4K Scope" - }, - { - "text": "리터 4K", - "name": "E-liter 4K" - }, - { - "text": "커스텀 리터 4K", - "name": "Custom E-liter 4K" - }, - { - "text": "스플랫 스코프", - "name": "Splatterscope" - }, - { - "text": "스플랫 스코프 컬래버", - "name": "Z+F Splatterscope" - }, - { - "text": "스플랫 스코프 FRST", - "name": "Splatterscope CAM-O" - }, - { - "text": "스플랫 차저", - "name": "Splat Charger" - }, - { - "text": "스플랫 차저 컬래버", - "name": "Z+F Splat Charger" - }, - { - "text": "스플랫 차저 FRST", - "name": "Splat Charger CAM-O" - }, - { - "text": "오더 차저 레플리카", - "name": "Order Charger Replica" - }, - { - "text": "R-PEN/5H", - "name": "Snipewriter 5H" - }, - { - "text": "R-PEN/5B", - "name": "Snipewriter 5B" - }, - { - "text": "스퀵 클린 α", - "name": "Classic Squiffer" - }, - { - "text": "스퀵 클린 β", - "name": "New Squiffer" - }, - { - "text": "듀얼 스위퍼", - "name": "Dualie Squelchers" - }, - { - "text": "커스텀 듀얼 스위퍼", - "name": "Custom Dualie Squelchers" - }, - { - "text": "후프 듀얼 스위퍼", - "name": "Hoofz Dualie Squelchers" - }, - { - "text": "켈빈 525", - "name": "Glooga Dualies" - }, - { - "text": "켈빈 525 데코", - "name": "Glooga Dualies Deco" - }, - { - "text": "소방 FF", - "name": "Douser Dualies FF" - }, - { - "text": "커스텀 소방 FF", - "name": "Custom Douser Dualies FF" - }, - { - "text": "스플랫 머누버", - "name": "Splat Dualies" - }, - { - "text": "스플랫 머누버 컬래버", - "name": "Enperry Splat Dualies" - }, - { - "text": "트윙클 스플랫 머누버", - "name": "Twinklez Splat Dualies" - }, - { - "text": "오더 머누버 레플리카", - "name": "Order Dualie Replicas" - }, - { - "text": "스퍼터리", - "name": "Dapple Dualies" - }, - { - "text": "스퍼터리 휴", - "name": "Dapple Dualies Nouveau" - }, - { - "text": "스퍼터리 OWL", - "name": "Dapple Dualies NOC-T" - }, - { - "text": "블랙 쿼드 호퍼", - "name": "Dark Tetra Dualies" - }, - { - "text": "화이트 쿼드 호퍼", - "name": "Light Tetra Dualies" - }, - { - "text": "카본 롤러", - "name": "Carbon Roller" - }, - { - "text": "카본 롤러 데코", - "name": "Carbon Roller Deco" - }, - { - "text": "카본 롤러 ANGL", - "name": "Carbon Roller ANG-L" - }, - { - "text": "다이너모 롤러", - "name": "Dynamo Roller" - }, - { - "text": "골드 다이너모 롤러", - "name": "Gold Dynamo Roller" - }, - { - "text": "스타 다이너모 롤러", - "name": "Starz Dynamo Roller" - }, - { - "text": "베리어블 롤러", - "name": "Flingza Roller" - }, - { - "text": "포일 베리어블 롤러", - "name": "Foil Flingza Roller" - }, - { - "text": "스플랫 롤러", - "name": "Splat Roller" - }, - { - "text": "스플랫 롤러 컬래버", - "name": "Krak-On Splat Roller" - }, - { - "text": "오더 롤러 레플리카", - "name": "Order Roller Replica" - }, - { - "text": "와이드 롤러", - "name": "Big Swig Roller" - }, - { - "text": "와이드 롤러 컬래버", - "name": "Big Swig Roller Express" - }, - { - "text": "플래닛 와이드 롤러", - "name": "Planetz Big Swig Roller" - }, - { - "text": "민트 덴탈 와이퍼", - "name": "Mint Decavitator" - }, - { - "text": "잉크 덴탈 와이퍼", - "name": "Charcoal Decavitator" - }, - { - "text": "드라이브 와이퍼", - "name": "Splatana Wiper" - }, - { - "text": "드라이브 와이퍼 데코", - "name": "Splatana Wiper Deco" - }, - { - "text": "드라이브 와이퍼 RUST", - "name": "Splatana Wiper RUS-T" - }, - { - "text": "사무 와이퍼", - "name": "Splatana Stamper" - }, - { - "text": "사무 와이퍼 휴", - "name": "Splatana Stamper Nouveau" - }, - { - "text": "스티커 사무 와이퍼", - "name": "Stickerz Splatana Stamper" - }, - { - "text": "오더 와이퍼 레플리카", - "name": "Order Splatana Replica" - }, - { - "text": "스파이 가젯", - "name": "Undercover Brella" - }, - { - "text": "스파이 가젯 소렐라", - "name": "Undercover Sorella Brella" - }, - { - "text": "패턴 스파이 가젯", - "name": "Patternz Undercover Brella" - }, - { - "text": "24식 도돌이 우산 갑", - "name": "Recycled Brella 24 Mk I" - }, - { - "text": "24식 도돌이 우산 을", - "name": "Recycled Brella 24 Mk II" - }, - { - "text": "파라 셸터", - "name": "Splat Brella" - }, - { - "text": "파라 셸터 소렐라", - "name": "Sorella Brella" - }, - { - "text": "오더 셸터 레플리카", - "name": "Order Brella Replica" - }, - { - "text": "캠핑 셸터", - "name": "Tenta Brella" - }, - { - "text": "캠핑 셸터 소렐라", - "name": "Tenta Sorella Brella" - }, - { - "text": "캠핑 셸터 CREM", - "name": "Tenta Brella CRE-M" - }, - { - "text": "프로모델러 MG", - "name": "Aerospray MG" - }, - { - "text": "프로모델러 RG", - "name": "Aerospray RG" - }, - { - "text": "컬러 프로모델러", - "name": "Colorz Aerospray" - }, - { - "text": "프라임 슈터", - "name": "Splattershot Pro" - }, - { - "text": "프라임 슈터 컬래버", - "name": "Forge Splattershot Pro" - }, - { - "text": "프라임 슈터 FRZN", - "name": "Splattershot Pro FRZ-N" - }, - { - "text": "새싹 슈터", - "name": "Splattershot Jr." - }, - { - "text": "단풍 슈터", - "name": "Custom Splattershot Jr." - }, - { - "text": "보틀 가이저", - "name": "Squeezer" - }, - { - "text": "포일 보틀 가이저", - "name": "Foil Squeezer" - }, - { - "text": ".52 갤런", - "name": ".52 Gal" - }, - { - "text": ".52 갤런 데코", - "name": ".52 Gal Deco" - }, - { - "text": ".96 갤런", - "name": ".96 Gal" - }, - { - "text": ".96 갤런 데코", - "name": ".96 Gal Deco" - }, - { - "text": "클로 .96 갤런", - "name": "Clawz .96 Gal" - }, - { - "text": "제트 스위퍼", - "name": "Jet Squelcher" - }, - { - "text": "커스텀 제트 스위퍼", - "name": "Custom Jet Squelcher" - }, - { - "text": "제트 스위퍼 COBR", - "name": "Jet Squelcher COB-R" - }, - { - "text": "스플랫 슈터", - "name": "Splattershot" - }, - { - "text": "스플랫 슈터 컬래버", - "name": "Tentatek Splattershot" - }, - { - "text": "글램 스플랫 슈터", - "name": "Glamorz Splattershot" - }, - { - "text": "히어로 슈터 레플리카", - "name": "Hero Shot Replica" - }, - { - "text": "오더 슈터 레플리카", - "name": "Order Shot Replica" - }, - { - "text": "옥타 슈터 레플리카", - "name": "Octo Shot Replica" - }, - { - "text": "샤프 마커", - "name": "Splash-o-matic" - }, - { - "text": "네오 샤프 마커", - "name": "Neo Splash-o-matic" - }, - { - "text": "샤프 마커 GECK", - "name": "Splash-o-matic GCK-O" - }, - { - "text": "스페이스 슈터", - "name": "Splattershot Nova" - }, - { - "text": "스페이스 슈터 컬래버", - "name": "Annaki Splattershot Nova" - }, - { - "text": "N-ZAP85", - "name": "N-ZAP '85" - }, - { - "text": "N-ZAP89", - "name": "N-ZAP '89" - }, - { - "text": "볼드 마커", - "name": "Sploosh-o-matic" - }, - { - "text": "네오 볼드 마커", - "name": "Neo Sploosh-o-matic" - }, - { - "text": "H3 릴 건", - "name": "H-3 Nozzlenose" - }, - { - "text": "H3 릴 건 D", - "name": "H-3 Nozzlenose D" - }, - { - "text": "H3 릴 건 SNAK", - "name": "H-3 Nozzlenose VIP-R" - }, - { - "text": "L3 릴 건", - "name": "L-3 Nozzlenose" - }, - { - "text": "L3 릴 건 D", - "name": "L-3 Nozzlenose D" - }, - { - "text": "글리터 L3 릴 건", - "name": "Glitterz L-3 Nozzlenose" - }, - { - "text": "오버플로셔", - "name": "Bloblobber" - }, - { - "text": "오버플로셔 데코", - "name": "Bloblobber Deco" - }, - { - "text": "물통", - "name": "Tri-Slosher" - }, - { - "text": "물통 휴", - "name": "Tri-Slosher Nouveau" - }, - { - "text": "물통 ASH", - "name": "Tri-Slosher ASH-N" - }, - { - "text": "몹 링", - "name": "Dread Wringer" - }, - { - "text": "몹 링 D", - "name": "Dread Wringer D" - }, - { - "text": "혼 몹 링", - "name": "Hornz Dread Wringer" - }, - { - "text": "스크루 슬로셔", - "name": "Sloshing Machine" - }, - { - "text": "네오 스크루 슬로셔", - "name": "Sloshing Machine Neo" - }, - { - "text": "버킷 슬로셔", - "name": "Slosher" - }, - { - "text": "버킷 슬로셔 데코", - "name": "Slosher Deco" - }, - { - "text": "오더 슬로셔 레플리카", - "name": "Order Slosher Replica" - }, - { - "text": "익스플로셔", - "name": "Explosher" - }, - { - "text": "커스텀 익스플로셔", - "name": "Custom Explosher" - }, - { - "text": "쿠겔 슈라이버", - "name": "Ballpoint Splatling" - }, - { - "text": "쿠겔 슈라이버 휴", - "name": "Ballpoint Splatling Nouveau" - }, - { - "text": "이그재미너", - "name": "Heavy Edit Splatling" - }, - { - "text": "이그재미너 휴", - "name": "Heavy Edit Splatling Nouveau" - }, - { - "text": "하이드런트", - "name": "Hydra Splatling" - }, - { - "text": "커스텀 하이드런트", - "name": "Custom Hydra Splatling" - }, - { - "text": "토렌트 하이드런트", - "name": "Torrentz Hydra Splatling" - }, - { - "text": "스플랫 스피너", - "name": "Mini Splatling" - }, - { - "text": "스플랫 스피너 컬래버", - "name": "Zink Mini Splatling" - }, - { - "text": "스플랫 스피너 PYTN", - "name": "Mini Splatling RTL-R" - }, - { - "text": "노틸러스 47", - "name": "Nautilus 47" - }, - { - "text": "노틸러스 79", - "name": "Nautilus 79" - }, - { - "text": "배럴 스피너", - "name": "Heavy Splatling" - }, - { - "text": "배럴 스피너 데코", - "name": "Heavy Splatling Deco" - }, - { - "text": "오더 스피너 레플리카", - "name": "Order Splatling Replica" - }, - { - "text": "플루이드 V", - "name": "Wellstring V" - }, - { - "text": "커스텀 플루이드 V", - "name": "Custom Wellstring V" - }, - { - "text": "트라이 스트링거", - "name": "Tri-Stringer" - }, - { - "text": "트라이 스트링거 컬래버", - "name": "Inkline Tri-Stringer" - }, - { - "text": "벌브 트라이 스트링거", - "name": "Bulbz Tri-Stringer" - }, - { - "text": "오더 스트링거 레플리카", - "name": "Order Stringer Replica" - }, - { - "text": "LACT-450", - "name": "REEF-LUX 450" - }, - { - "text": "LACT-450 데코", - "name": "REEF-LUX 450 Deco" - }, - { - "text": "LACT-450 MILK", - "name": "REEF-LUX 450 MIL-K" - }, - { - "text": "점프 비컨", - "name": "Squid Beakon" - }, - { - "text": "컬링 밤", - "name": "Curling Bomb" - }, - { - "text": "탄산 밤", - "name": "Fizzy Bomb" - }, - { - "text": "퀵 밤", - "name": "Burst Bomb" - }, - { - "text": "로봇 밤", - "name": "Autobomb" - }, - { - "text": "스플래시 밤", - "name": "Splat Bomb" - }, - { - "text": "빨판 밤", - "name": "Suction Bomb" - }, - { - "text": "토피도", - "name": "Torpedo" - }, - { - "text": "라인 마커", - "name": "Angle Shooter" - }, - { - "text": "포인트 센서", - "name": "Point Sensor" - }, - { - "text": "포이즌 미스트", - "name": "Toxic Mist" - }, - { - "text": "스플래시 실드", - "name": "Splash Wall" - }, - { - "text": "스프링클러", - "name": "Sprinkler" - }, - { - "text": "트랩", - "name": "Ink Mine" - }, - { - "text": "흡입기", - "name": "Ink Vac" - }, - { - "text": "로열 크라켄", - "name": "Kraken Royale" - }, - { - "text": "크랩 탱크", - "name": "Crab Tank" - }, - { - "text": "스플래터컬러 스크린", - "name": "Splattercolor Screen" - }, - { - "text": "에너지 스탠드", - "name": "Tacticooler" - }, - { - "text": "디코이 캐넌", - "name": "Super Chump" - }, - { - "text": "그레이트 배리어", - "name": "Big Bubbler" - }, - { - "text": "먹구름", - "name": "Ink Storm" - }, - { - "text": "제트 팩", - "name": "Inkjet" - }, - { - "text": "메가폰 레이저 5.1ch", - "name": "Killer Wail 5.1" - }, - { - "text": "멀티 미사일", - "name": "Tenta Missiles" - }, - { - "text": "나이스옥", - "name": "Booyah Bomb" - }, - { - "text": "울트라 착지", - "name": "Triple Splashdown" - }, - { - "text": "홉 소나", - "name": "Wave Breaker" - }, - { - "text": "샤크 라이드", - "name": "Reefslider" - }, - { - "text": "쇼크 원더", - "name": "Zipcaster" - }, - { - "text": "트리플 토네이도", - "name": "Triple Inkstrike" - }, - { - "text": "울트라 샷", - "name": "Trizooka" - }, - { - "text": "울트라 스탬프", - "name": "Ultra Stamp" - } - ], - "TWzh": [ - { - "text": "快速爆破槍 精英", - "name": "Rapid Blaster Pro" - }, - { - "text": "快速爆破槍 精英裝飾", - "name": "Rapid Blaster Pro Deco" - }, - { - "text": "快速爆破槍 精英WNTR", - "name": "Rapid Blaster Pro WNT-R" - }, - { - "text": "衝塗爆破槍", - "name": "Clash Blaster" - }, - { - "text": "衝塗爆破槍 新型", - "name": "Clash Blaster Neo" - }, - { - "text": "快速爆破槍", - "name": "Rapid Blaster" - }, - { - "text": "快速爆破槍 裝飾", - "name": "Rapid Blaster Deco" - }, - { - "text": "遠距爆破槍", - "name": "Range Blaster" - }, - { - "text": "遠距爆破槍 改裝", - "name": "Custom Range Blaster" - }, - { - "text": "火熱爆破槍", - "name": "Blaster" - }, - { - "text": "火熱爆破槍 改裝", - "name": "Custom Blaster" - }, - { - "text": "火熱爆破槍 艷", - "name": "Gleamz Blaster" - }, - { - "text": "S-BLAST92", - "name": "S-BLAST '92" - }, - { - "text": "S-BLAST91", - "name": "S-BLAST '91" - }, - { - "text": "新星爆破槍", - "name": "Luna Blaster" - }, - { - "text": "新星爆破槍 新型", - "name": "Luna Blaster Neo" - }, - { - "text": "秩序爆破槍 複製", - "name": "Order Blaster Replica" - }, - { - "text": "文森", - "name": "Painbrush" - }, - { - "text": "文森‧新藝術", - "name": "Painbrush Nouveau" - }, - { - "text": "文森BRNZ", - "name": "Painbrush BRN-Z" - }, - { - "text": "巴勃羅", - "name": "Inkbrush" - }, - { - "text": "巴勃羅‧新藝術", - "name": "Inkbrush Nouveau" - }, - { - "text": "北齋", - "name": "Octobrush" - }, - { - "text": "北齋‧新藝術", - "name": "Octobrush Nouveau" - }, - { - "text": "北齋 彗", - "name": "Cometz Octobrush" - }, - { - "text": "秩序排刷 複製", - "name": "Orderbrush Replica" - }, - { - "text": "高壓油管槍", - "name": "Goo Tuber" - }, - { - "text": "高壓油管槍 改裝", - "name": "Custom Goo Tuber" - }, - { - "text": "14式竹筒槍‧甲", - "name": "Bamboozler 14 Mk I" - }, - { - "text": "14式竹筒槍‧乙", - "name": "Bamboozler 14 Mk II" - }, - { - "text": "4K準星槍", - "name": "E-liter 4K Scope" - }, - { - "text": "4K準星槍 改裝", - "name": "Custom E-liter 4K Scope" - }, - { - "text": "公升4K", - "name": "E-liter 4K" - }, - { - "text": "公升4K 改裝", - "name": "Custom E-liter 4K" - }, - { - "text": "斯普拉準星槍", - "name": "Splatterscope" - }, - { - "text": "斯普拉準星槍 聯名", - "name": "Z+F Splatterscope" - }, - { - "text": "斯普拉準星槍FRST", - "name": "Splatterscope CAM-O" - }, - { - "text": "斯普拉蓄力狙擊槍", - "name": "Splat Charger" - }, - { - "text": "斯普拉蓄力狙擊槍 聯名", - "name": "Z+F Splat Charger" - }, - { - "text": "斯普拉蓄力狙擊槍FRST", - "name": "Splat Charger CAM-O" - }, - { - "text": "秩序蓄力狙擊槍 複製", - "name": "Order Charger Replica" - }, - { - "text": "R-PEN/5H", - "name": "Snipewriter 5H" - }, - { - "text": "R-PEN/5B", - "name": "Snipewriter 5B" - }, - { - "text": "魷快潔α", - "name": "Classic Squiffer" - }, - { - "text": "魷快潔β", - "name": "New Squiffer" - }, - { - "text": "雙重清潔槍", - "name": "Dualie Squelchers" - }, - { - "text": "雙重清潔槍 改裝", - "name": "Custom Dualie Squelchers" - }, - { - "text": "雙重清潔槍 蹄", - "name": "Hoofz Dualie Squelchers" - }, - { - "text": "開爾文525", - "name": "Glooga Dualies" - }, - { - "text": "開爾文525 裝飾", - "name": "Glooga Dualies Deco" - }, - { - "text": "滅熄FF", - "name": "Douser Dualies FF" - }, - { - "text": "滅熄FF 改裝", - "name": "Custom Douser Dualies FF" - }, - { - "text": "斯普拉機動槍", - "name": "Splat Dualies" - }, - { - "text": "斯普拉機動槍 聯名", - "name": "Enperry Splat Dualies" - }, - { - "text": "斯普拉機動槍 耀", - "name": "Twinklez Splat Dualies" - }, - { - "text": "秩序機動槍 複製", - "name": "Order Dualie Replicas" - }, - { - "text": "濺鍍槍", - "name": "Dapple Dualies" - }, - { - "text": "濺鍍槍‧新藝術", - "name": "Dapple Dualies Nouveau" - }, - { - "text": "濺鍍槍OWL", - "name": "Dapple Dualies NOC-T" - }, - { - "text": "四重彈跳手槍 黑", - "name": "Dark Tetra Dualies" - }, - { - "text": "四重彈跳手槍 白", - "name": "Light Tetra Dualies" - }, - { - "text": "碳纖維滾筒", - "name": "Carbon Roller" - }, - { - "text": "碳纖維滾筒 裝飾", - "name": "Carbon Roller Deco" - }, - { - "text": "碳纖維滾筒ANGL", - "name": "Carbon Roller ANG-L" - }, - { - "text": "電動馬達滾筒", - "name": "Dynamo Roller" - }, - { - "text": "電動馬達滾筒 高磁波", - "name": "Gold Dynamo Roller" - }, - { - "text": "電動馬達滾筒 冥", - "name": "Starz Dynamo Roller" - }, - { - "text": "可變式滾筒", - "name": "Flingza Roller" - }, - { - "text": "可變式滾筒 金屬箔", - "name": "Foil Flingza Roller" - }, - { - "text": "斯普拉滾筒", - "name": "Splat Roller" - }, - { - "text": "斯普拉滾筒 聯名", - "name": "Krak-On Splat Roller" - }, - { - "text": "秩序滾筒 複製", - "name": "Order Roller Replica" - }, - { - "text": "寬滾筒", - "name": "Big Swig Roller" - }, - { - "text": "寬滾筒 聯名", - "name": "Big Swig Roller Express" - }, - { - "text": "寬滾筒 惑", - "name": "Planetz Big Swig Roller" - }, - { - "text": "巨齒刮水刀 薄荷", - "name": "Mint Decavitator" - }, - { - "text": "巨齒刮水刀 墨黑", - "name": "Charcoal Decavitator" - }, - { - "text": "雨刷刮水刀", - "name": "Splatana Wiper" - }, - { - "text": "雨刷刮水刀 裝飾", - "name": "Splatana Wiper Deco" - }, - { - "text": "雨刷刮水刀RUST", - "name": "Splatana Wiper RUS-T" - }, - { - "text": "工作刮水刀", - "name": "Splatana Stamper" - }, - { - "text": "工作刮水刀‧新藝術", - "name": "Splatana Stamper Nouveau" - }, - { - "text": "工作刮水刀 封", - "name": "Stickerz Splatana Stamper" - }, - { - "text": "秩序刮水刀 複製", - "name": "Order Splatana Replica" - }, - { - "text": "特務配件", - "name": "Undercover Brella" - }, - { - "text": "特務配件 姐妹", - "name": "Undercover Sorella Brella" - }, - { - "text": "特務配件 繚", - "name": "Patternz Undercover Brella" - }, - { - "text": "24式可替換傘‧甲", - "name": "Recycled Brella 24 Mk I" - }, - { - "text": "24式可替換傘‧乙", - "name": "Recycled Brella 24 Mk II" - }, - { - "text": "遮陽防空傘", - "name": "Splat Brella" - }, - { - "text": "遮陽防空傘 姐妹", - "name": "Sorella Brella" - }, - { - "text": "秩序防空傘 複製", - "name": "Order Brella Replica" - }, - { - "text": "露營防空傘", - "name": "Tenta Brella" - }, - { - "text": "露營防空傘 姐妹", - "name": "Tenta Sorella Brella" - }, - { - "text": "露營防空傘CREM", - "name": "Tenta Brella CRE-M" - }, - { - "text": "專業模型槍MG", - "name": "Aerospray MG" - }, - { - "text": "專業模型槍RG", - "name": "Aerospray RG" - }, - { - "text": "專業模型槍 彩", - "name": "Colorz Aerospray" - }, - { - "text": "頂尖射擊槍", - "name": "Splattershot Pro" - }, - { - "text": "頂尖射擊槍 聯名", - "name": "Forge Splattershot Pro" - }, - { - "text": "頂尖射擊槍FRZN", - "name": "Splattershot Pro FRZ-N" - }, - { - "text": "新葉射擊槍", - "name": "Splattershot Jr." - }, - { - "text": "楓葉射擊槍", - "name": "Custom Splattershot Jr." - }, - { - "text": "開瓶噴泉槍", - "name": "Squeezer" - }, - { - "text": "開瓶噴泉槍 金屬箔", - "name": "Foil Squeezer" - }, - { - "text": ".52加侖", - "name": ".52 Gal" - }, - { - "text": ".52加侖 裝飾", - "name": ".52 Gal Deco" - }, - { - "text": ".96加侖", - "name": ".96 Gal" - }, - { - "text": ".96加侖 裝飾", - "name": ".96 Gal Deco" - }, - { - "text": ".96加侖 爪", - "name": "Clawz .96 Gal" - }, - { - "text": "噴射清潔槍", - "name": "Jet Squelcher" - }, - { - "text": "噴射清潔槍 改裝", - "name": "Custom Jet Squelcher" - }, - { - "text": "噴射清潔槍COBR", - "name": "Jet Squelcher COB-R" - }, - { - "text": "斯普拉射擊槍", - "name": "Splattershot" - }, - { - "text": "斯普拉射擊槍 聯名", - "name": "Tentatek Splattershot" - }, - { - "text": "斯普拉射擊槍 煌", - "name": "Glamorz Splattershot" - }, - { - "text": "英雄射擊槍 複製", - "name": "Hero Shot Replica" - }, - { - "text": "秩序射擊槍 複製", - "name": "Order Shot Replica" - }, - { - "text": "章魚射擊槍 複製", - "name": "Octo Shot Replica" - }, - { - "text": "窄域標記槍", - "name": "Splash-o-matic" - }, - { - "text": "窄域標記槍 新型", - "name": "Neo Splash-o-matic" - }, - { - "text": "窄域標記槍GECK", - "name": "Splash-o-matic GCK-O" - }, - { - "text": "太空射擊槍", - "name": "Splattershot Nova" - }, - { - "text": "太空射擊槍 聯名", - "name": "Annaki Splattershot Nova" - }, - { - "text": "N-ZAP85", - "name": "N-ZAP '85" - }, - { - "text": "N-ZAP89", - "name": "N-ZAP '89" - }, - { - "text": "廣域標記槍", - "name": "Sploosh-o-matic" - }, - { - "text": "廣域標記槍 新型", - "name": "Neo Sploosh-o-matic" - }, - { - "text": "H3捲管槍", - "name": "H-3 Nozzlenose" - }, - { - "text": "H3捲管槍D", - "name": "H-3 Nozzlenose D" - }, - { - "text": "H3捲管槍SNAK", - "name": "H-3 Nozzlenose VIP-R" - }, - { - "text": "L3捲管槍", - "name": "L-3 Nozzlenose" - }, - { - "text": "L3捲管槍D", - "name": "L-3 Nozzlenose D" - }, - { - "text": "L3捲管槍 箔", - "name": "Glitterz L-3 Nozzlenose" - }, - { - "text": "滿溢泡澡潑桶", - "name": "Bloblobber" - }, - { - "text": "滿溢泡澡潑桶 裝飾", - "name": "Bloblobber Deco" - }, - { - "text": "洗筆桶", - "name": "Tri-Slosher" - }, - { - "text": "洗筆桶‧新藝術", - "name": "Tri-Slosher Nouveau" - }, - { - "text": "洗筆桶ASH", - "name": "Tri-Slosher ASH-N" - }, - { - "text": "墨瀑淋", - "name": "Dread Wringer" - }, - { - "text": "墨瀑淋D", - "name": "Dread Wringer D" - }, - { - "text": "墨瀑淋 角", - "name": "Hornz Dread Wringer" - }, - { - "text": "迴旋潑桶", - "name": "Sloshing Machine" - }, - { - "text": "迴旋潑桶 新型", - "name": "Sloshing Machine Neo" - }, - { - "text": "飛濺潑桶", - "name": "Slosher" - }, - { - "text": "飛濺潑桶 裝飾", - "name": "Slosher Deco" - }, - { - "text": "秩序潑桶 複製", - "name": "Order Slosher Replica" - }, - { - "text": "爆炸潑桶", - "name": "Explosher" - }, - { - "text": "爆炸潑桶 改裝", - "name": "Custom Explosher" - }, - { - "text": "圓珠筆", - "name": "Ballpoint Splatling" - }, - { - "text": "圓珠筆‧新藝術", - "name": "Ballpoint Splatling Nouveau" - }, - { - "text": "審查者", - "name": "Heavy Edit Splatling" - }, - { - "text": "審查者‧新藝術", - "name": "Heavy Edit Splatling Nouveau" - }, - { - "text": "消防栓旋轉槍", - "name": "Hydra Splatling" - }, - { - "text": "消防栓旋轉槍 改裝", - "name": "Custom Hydra Splatling" - }, - { - "text": "消防栓旋轉槍 壓", - "name": "Torrentz Hydra Splatling" - }, - { - "text": "斯普拉旋轉槍", - "name": "Mini Splatling" - }, - { - "text": "斯普拉旋轉槍 聯名", - "name": "Zink Mini Splatling" - }, - { - "text": "斯普拉旋轉槍PYTN", - "name": "Mini Splatling RTL-R" - }, - { - "text": "鸚鵡螺號47", - "name": "Nautilus 47" - }, - { - "text": "鸚鵡螺號79", - "name": "Nautilus 79" - }, - { - "text": "桶裝旋轉槍", - "name": "Heavy Splatling" - }, - { - "text": "桶裝旋轉槍 裝飾", - "name": "Heavy Splatling Deco" - }, - { - "text": "秩序旋轉槍 複製", - "name": "Order Splatling Replica" - }, - { - "text": "邦普V", - "name": "Wellstring V" - }, - { - "text": "邦普V 改裝", - "name": "Custom Wellstring V" - }, - { - "text": "三發獵魚弓", - "name": "Tri-Stringer" - }, - { - "text": "三發獵魚弓 聯名", - "name": "Inkline Tri-Stringer" - }, - { - "text": "三發獵魚弓 燈", - "name": "Bulbz Tri-Stringer" - }, - { - "text": "秩序獵魚弓 複製", - "name": "Order Stringer Replica" - }, - { - "text": "LACT-450", - "name": "REEF-LUX 450" - }, - { - "text": "LACT-450 裝飾", - "name": "REEF-LUX 450 Deco" - }, - { - "text": "LACT-450MILK", - "name": "REEF-LUX 450 MIL-K" - }, - { - "text": "跳躍信標", - "name": "Squid Beakon" - }, - { - "text": "冰壺炸彈", - "name": "Curling Bomb" - }, - { - "text": "碳酸炸彈", - "name": "Fizzy Bomb" - }, - { - "text": "快速炸彈", - "name": "Burst Bomb" - }, - { - "text": "機器人炸彈", - "name": "Autobomb" - }, - { - "text": "斯普拉炸彈", - "name": "Splat Bomb" - }, - { - "text": "吸盤炸彈", - "name": "Suction Bomb" - }, - { - "text": "魚雷", - "name": "Torpedo" - }, - { - "text": "標線器", - "name": "Angle Shooter" - }, - { - "text": "定點偵測器", - "name": "Point Sensor" - }, - { - "text": "毒霧", - "name": "Toxic Mist" - }, - { - "text": "斯普拉防護牆", - "name": "Splash Wall" - }, - { - "text": "灑墨器", - "name": "Sprinkler" - }, - { - "text": "墨汁陷阱", - "name": "Ink Mine" - }, - { - "text": "吸墨機", - "name": "Ink Vac" - }, - { - "text": "帝王魷魚", - "name": "Kraken Royale" - }, - { - "text": "螃蟹坦克", - "name": "Crab Tank" - }, - { - "text": "浮墨幕牆", - "name": "Splattercolor Screen" - }, - { - "text": "能量站", - "name": "Tacticooler" - }, - { - "text": "誘餌煙火", - "name": "Super Chump" - }, - { - "text": "巨大防護罩", - "name": "Big Bubbler" - }, - { - "text": "墨雨雲", - "name": "Ink Storm" - }, - { - "text": "噴射背包", - "name": "Inkjet" - }, - { - "text": "喇叭雷射5.1ch", - "name": "Killer Wail 5.1" - }, - { - "text": "多重導彈", - "name": "Tenta Missiles" - }, - { - "text": "讚氣彈", - "name": "Booyah Bomb" - }, - { - "text": "終極著陸", - "name": "Triple Splashdown" - }, - { - "text": "彈跳聲納", - "name": "Wave Breaker" - }, - { - "text": "鯊魚坐騎", - "name": "Reefslider" - }, - { - "text": "觸手噴射", - "name": "Zipcaster" - }, - { - "text": "三重龍捲風", - "name": "Triple Inkstrike" - }, - { - "text": "終極發射", - "name": "Trizooka" - }, - { - "text": "終極印章", - "name": "Ultra Stamp" - } - ], - "USes": [ - { - "text": "Turbolanzamotas Pro", - "name": "Rapid Blaster Pro" - }, - { - "text": "Turbolanzamotas Pro chic", - "name": "Rapid Blaster Pro Deco" - }, - { - "text": "Turbolanzamotas INV", - "name": "Rapid Blaster Pro WNT-R" - }, - { - "text": "Hiperlanzamotas", - "name": "Clash Blaster" - }, - { - "text": "Hiperlanzamotas neo", - "name": "Clash Blaster Neo" - }, - { - "text": "Turbolanzamotas", - "name": "Rapid Blaster" - }, - { - "text": "Turbolanzamotas chic", - "name": "Rapid Blaster Deco" - }, - { - "text": "Telelanzamotas", - "name": "Range Blaster" - }, - { - "text": "Telelanzamotas adaptado", - "name": "Custom Range Blaster" - }, - { - "text": "Lanzamotas", - "name": "Blaster" - }, - { - "text": "Lanzamotas adaptado", - "name": "Custom Blaster" - }, - { - "text": "Lanzamotas DSX", - "name": "Gleamz Blaster" - }, - { - "text": "L-MOT 92", - "name": "S-BLAST '92" - }, - { - "text": "L-MOT 91", - "name": "S-BLAST '91" - }, - { - "text": "Lanzamotas Nova", - "name": "Luna Blaster" - }, - { - "text": "Lanzamotas Nova neo", - "name": "Luna Blaster Neo" - }, - { - "text": "Lanzamotas orden réplica", - "name": "Order Blaster Replica" - }, - { - "text": "Pincel abanico", - "name": "Painbrush" - }, - { - "text": "Pincel abanico maestro", - "name": "Painbrush Nouveau" - }, - { - "text": "Pincel abanico BNC", - "name": "Painbrush BRN-Z" - }, - { - "text": "Pincel", - "name": "Inkbrush" - }, - { - "text": "Pincel maestro", - "name": "Inkbrush Nouveau" - }, - { - "text": "Pincel caligrafía", - "name": "Octobrush" - }, - { - "text": "Pincel caligrafía maestro", - "name": "Octobrush Nouveau" - }, - { - "text": "Pincel caligrafía CMX", - "name": "Cometz Octobrush" - }, - { - "text": "Pincel caligrafía orden réplica", - "name": "Orderbrush Replica" - }, - { - "text": "Entintubo", - "name": "Goo Tuber" - }, - { - "text": "Entintubo adaptado", - "name": "Custom Goo Tuber" - }, - { - "text": "Bamzuka 14-I", - "name": "Bamboozler 14 Mk I" - }, - { - "text": "Bamzuka 14-II", - "name": "Bamboozler 14 Mk II" - }, - { - "text": "Telentintador 4K", - "name": "E-liter 4K Scope" - }, - { - "text": "Telentintador 4K adaptado", - "name": "Custom E-liter 4K Scope" - }, - { - "text": "Entintador 4K", - "name": "E-liter 4K" - }, - { - "text": "Entintador 4K adaptado", - "name": "Custom E-liter 4K" - }, - { - "text": "Telecargatintas", - "name": "Splatterscope" - }, - { - "text": "Telecargatintas ZyS", - "name": "Z+F Splatterscope" - }, - { - "text": "Telecargatintas BSQ", - "name": "Splatterscope CAM-O" - }, - { - "text": "Cargatintas", - "name": "Splat Charger" - }, - { - "text": "Cargatintas ZyS", - "name": "Z+F Splat Charger" - }, - { - "text": "Cargatintas BSQ", - "name": "Splat Charger CAM-O" - }, - { - "text": "Cargatintas orden réplica", - "name": "Order Charger Replica" - }, - { - "text": "Cargaminas 5H", - "name": "Snipewriter 5H" - }, - { - "text": "Cargaminas 5B", - "name": "Snipewriter 5B" - }, - { - "text": "Delineador X", - "name": "Classic Squiffer" - }, - { - "text": "Delineador Y", - "name": "New Squiffer" - }, - { - "text": "Soplador dual", - "name": "Dualie Squelchers" - }, - { - "text": "Soplador dual adaptado", - "name": "Custom Dualie Squelchers" - }, - { - "text": "Soplador dual RYX", - "name": "Hoofz Dualie Squelchers" - }, - { - "text": "Fundidora 525 dual", - "name": "Glooga Dualies" - }, - { - "text": "Fundidora 525 dual chic", - "name": "Glooga Dualies Deco" - }, - { - "text": "Extintor dual FF", - "name": "Douser Dualies FF" - }, - { - "text": "Extintor dual FF adaptado", - "name": "Custom Douser Dualies FF" - }, - { - "text": "Difusor dual", - "name": "Splat Dualies" - }, - { - "text": "Difusor dual Emperi", - "name": "Enperry Splat Dualies" - }, - { - "text": "Difusor dual CHX", - "name": "Twinklez Splat Dualies" - }, - { - "text": "Difusor dual orden réplica", - "name": "Order Dualie Replicas" - }, - { - "text": "Atomizador dual", - "name": "Dapple Dualies" - }, - { - "text": "Atomizador dual maestro", - "name": "Dapple Dualies Nouveau" - }, - { - "text": "Atomizador dual NCT", - "name": "Dapple Dualies NOC-T" - }, - { - "text": "Tetra dual negro", - "name": "Dark Tetra Dualies" - }, - { - "text": "Tetra dual blanco", - "name": "Light Tetra Dualies" - }, - { - "text": "Rodillo de carbono", - "name": "Carbon Roller" - }, - { - "text": "Rodillo de carbono chic", - "name": "Carbon Roller Deco" - }, - { - "text": "Rodillo de carbono ABL", - "name": "Carbon Roller ANG-L" - }, - { - "text": "Rodillo Pro", - "name": "Dynamo Roller" - }, - { - "text": "Rodillo Pro dorado", - "name": "Gold Dynamo Roller" - }, - { - "text": "Rodillo Pro CSX", - "name": "Starz Dynamo Roller" - }, - { - "text": "Rodillo versátil", - "name": "Flingza Roller" - }, - { - "text": "Rodillo versátil Foil", - "name": "Foil Flingza Roller" - }, - { - "text": "Rodillo básico", - "name": "Splat Roller" - }, - { - "text": "Rodillo básico Meddux", - "name": "Krak-On Splat Roller" - }, - { - "text": "Rodillo orden réplica", - "name": "Order Roller Replica" - }, - { - "text": "Rodillo mágnum", - "name": "Big Swig Roller" - }, - { - "text": "Rodillo mágnum exprés", - "name": "Big Swig Roller Express" - }, - { - "text": "Rodillo mágnum PLX", - "name": "Planetz Big Swig Roller" - }, - { - "text": "Odontintador menta", - "name": "Mint Decavitator" - }, - { - "text": "Odontintador carbón", - "name": "Charcoal Decavitator" - }, - { - "text": "Azotintador brisa", - "name": "Splatana Wiper" - }, - { - "text": "Azotintador brisa chic", - "name": "Splatana Wiper Deco" - }, - { - "text": "Azotintador brisa OXD", - "name": "Splatana Wiper RUS-T" - }, - { - "text": "Azotintador Pro", - "name": "Splatana Stamper" - }, - { - "text": "Azotintador Pro maestro", - "name": "Splatana Stamper Nouveau" - }, - { - "text": "Azotintador Pro CLX", - "name": "Stickerz Splatana Stamper" - }, - { - "text": "Azotintador orden réplica", - "name": "Order Splatana Replica" - }, - { - "text": "Paratintas discreto", - "name": "Undercover Brella" - }, - { - "text": "Paratintas discreto Sorella", - "name": "Undercover Sorella Brella" - }, - { - "text": "Paratintas discreto PTX", - "name": "Patternz Undercover Brella" - }, - { - "text": "Prototintas 24-I", - "name": "Recycled Brella 24 Mk I" - }, - { - "text": "Prototintas 24-II", - "name": "Recycled Brella 24 Mk II" - }, - { - "text": "Paratintas", - "name": "Splat Brella" - }, - { - "text": "Paratintas Sorella", - "name": "Sorella Brella" - }, - { - "text": "Paratintas orden réplica", - "name": "Order Brella Replica" - }, - { - "text": "Paratintas forestal", - "name": "Tenta Brella" - }, - { - "text": "Paratintas forestal Sorella", - "name": "Tenta Sorella Brella" - }, - { - "text": "Paratintas forestal CRM", - "name": "Tenta Brella CRE-M" - }, - { - "text": "Aerógrafo GP", - "name": "Aerospray MG" - }, - { - "text": "Aerógrafo GN", - "name": "Aerospray RG" - }, - { - "text": "Aerógrafo ARX", - "name": "Colorz Aerospray" - }, - { - "text": "Rociador Pro", - "name": "Splattershot Pro" - }, - { - "text": "Rociador Forima", - "name": "Forge Splattershot Pro" - }, - { - "text": "Rociador Pro GLD", - "name": "Splattershot Pro FRZ-N" - }, - { - "text": "Rociador básico", - "name": "Splattershot Jr." - }, - { - "text": "Rociador básico adaptado", - "name": "Custom Splattershot Jr." - }, - { - "text": "Tintopresor", - "name": "Squeezer" - }, - { - "text": "Tintopresor Foil", - "name": "Foil Squeezer" - }, - { - "text": "Salpicadora 2000", - "name": ".52 Gal" - }, - { - "text": "Salpicadora 2000 chic", - "name": ".52 Gal Deco" - }, - { - "text": "Salpicadora 3000", - "name": ".96 Gal" - }, - { - "text": "Salpicadora 3000 chic", - "name": ".96 Gal Deco" - }, - { - "text": "Salpicadora 3000 GRX", - "name": "Clawz .96 Gal" - }, - { - "text": "Megasoplador", - "name": "Jet Squelcher" - }, - { - "text": "Megasoplador adaptado", - "name": "Custom Jet Squelcher" - }, - { - "text": "Megasoplador CBR", - "name": "Jet Squelcher COB-R" - }, - { - "text": "Rociador", - "name": "Splattershot" - }, - { - "text": "Rociador Tentáctica", - "name": "Tentatek Splattershot" - }, - { - "text": "Rociador GLX", - "name": "Glamorz Splattershot" - }, - { - "text": "Rociador de élite réplica", - "name": "Hero Shot Replica" - }, - { - "text": "Rociador orden réplica", - "name": "Order Shot Replica" - }, - { - "text": "Rociador octariano réplica", - "name": "Octo Shot Replica" - }, - { - "text": "Plumón", - "name": "Splash-o-matic" - }, - { - "text": "Plumón neo", - "name": "Neo Splash-o-matic" - }, - { - "text": "Plumón GCO", - "name": "Splash-o-matic GCK-O" - }, - { - "text": "Rociador espacial", - "name": "Splattershot Nova" - }, - { - "text": "Rociador espacial Annak", - "name": "Annaki Splattershot Nova" - }, - { - "text": "N-ZAP 85", - "name": "N-ZAP '85" - }, - { - "text": "N-ZAP 89", - "name": "N-ZAP '89" - }, - { - "text": "Marcador grueso", - "name": "Sploosh-o-matic" - }, - { - "text": "Marcador grueso neo", - "name": "Neo Sploosh-o-matic" - }, - { - "text": "Manguera pesada", - "name": "H-3 Nozzlenose" - }, - { - "text": "Manguera pesada D", - "name": "H-3 Nozzlenose D" - }, - { - "text": "Manguera pesada PTN", - "name": "H-3 Nozzlenose VIP-R" - }, - { - "text": "Manguera ligera", - "name": "L-3 Nozzlenose" - }, - { - "text": "Manguera ligera D", - "name": "L-3 Nozzlenose D" - }, - { - "text": "Manguera ligera BRX", - "name": "Glitterz L-3 Nozzlenose" - }, - { - "text": "Derramatic baño", - "name": "Bloblobber" - }, - { - "text": "Derramatic baño chic", - "name": "Bloblobber Deco" - }, - { - "text": "Derramatic triple", - "name": "Tri-Slosher" - }, - { - "text": "Derramatic triple maestro", - "name": "Tri-Slosher Nouveau" - }, - { - "text": "Derramatic triple CNZ", - "name": "Tri-Slosher ASH-N" - }, - { - "text": "Friegamatic", - "name": "Dread Wringer" - }, - { - "text": "Friegamatic D", - "name": "Dread Wringer D" - }, - { - "text": "Friegamatic ATX", - "name": "Hornz Dread Wringer" - }, - { - "text": "Derramatic centrífugo", - "name": "Sloshing Machine" - }, - { - "text": "Derramatic centrífugo neo", - "name": "Sloshing Machine Neo" - }, - { - "text": "Derramatic", - "name": "Slosher" - }, - { - "text": "Derramatic chic", - "name": "Slosher Deco" - }, - { - "text": "Derramatic orden réplica", - "name": "Order Slosher Replica" - }, - { - "text": "Derramatic XP", - "name": "Explosher" - }, - { - "text": "Derramatic XP adaptado", - "name": "Custom Explosher" - }, - { - "text": "Garabateador PLM", - "name": "Ballpoint Splatling" - }, - { - "text": "Garabateador PLM maestro", - "name": "Ballpoint Splatling Nouveau" - }, - { - "text": "Corrigetintas", - "name": "Heavy Edit Splatling" - }, - { - "text": "Corrigetintas maestro", - "name": "Heavy Edit Splatling Nouveau" - }, - { - "text": "Garabateador HD", - "name": "Hydra Splatling" - }, - { - "text": "Garabateador HD adaptado", - "name": "Custom Hydra Splatling" - }, - { - "text": "Garabateador HD TRX", - "name": "Torrentz Hydra Splatling" - }, - { - "text": "Garabateador", - "name": "Mini Splatling" - }, - { - "text": "Garabateador Tintaz", - "name": "Zink Mini Splatling" - }, - { - "text": "Garabateador CSB", - "name": "Mini Splatling RTL-R" - }, - { - "text": "Nautilo 47", - "name": "Nautilus 47" - }, - { - "text": "Nautilo 79", - "name": "Nautilus 79" - }, - { - "text": "Garabateador Pro", - "name": "Heavy Splatling" - }, - { - "text": "Garabateador Pro chic", - "name": "Heavy Splatling Deco" - }, - { - "text": "Garabateador orden réplica", - "name": "Order Splatling Replica" - }, - { - "text": "Arcrotensor V", - "name": "Wellstring V" - }, - { - "text": "Arcrotensor V adaptado", - "name": "Custom Wellstring V" - }, - { - "text": "Arcromatizador triple", - "name": "Tri-Stringer" - }, - { - "text": "Arcromatizador triple Moluskia", - "name": "Inkline Tri-Stringer" - }, - { - "text": "Arcromatizador triple FCX", - "name": "Bulbz Tri-Stringer" - }, - { - "text": "Arcromatizador orden réplica", - "name": "Order Stringer Replica" - }, - { - "text": "AR-CR0 450", - "name": "REEF-LUX 450" - }, - { - "text": "AR-CR0 450 chic", - "name": "REEF-LUX 450 Deco" - }, - { - "text": "AR-CR0 450 LCT", - "name": "REEF-LUX 450 MIL-K" - }, - { - "text": "Baliza", - "name": "Squid Beakon" - }, - { - "text": "Bomba deslizante", - "name": "Curling Bomb" - }, - { - "text": "Bomba carbónica", - "name": "Fizzy Bomb" - }, - { - "text": "Globo entintado", - "name": "Burst Bomb" - }, - { - "text": "Robobomba", - "name": "Autobomb" - }, - { - "text": "Plasbomba", - "name": "Splat Bomb" - }, - { - "text": "Bomba pegajosa", - "name": "Suction Bomb" - }, - { - "text": "Dardo resaltador", - "name": "Angle Shooter" - }, - { - "text": "Rastreador", - "name": "Point Sensor" - }, - { - "text": "Nebulizador", - "name": "Toxic Mist" - }, - { - "text": "Barricada", - "name": "Splash Wall" - }, - { - "text": "Aspersor", - "name": "Sprinkler" - }, - { - "text": "Mina de tinta", - "name": "Ink Mine" - }, - { - "text": "Aspiratinta", - "name": "Ink Vac" - }, - { - "text": "Calamar imperial", - "name": "Kraken Royale" - }, - { - "text": "Cangrejobot", - "name": "Crab Tank" - }, - { - "text": "Muro marmoleado", - "name": "Splattercolor Screen" - }, - { - "text": "Dispensabebidas", - "name": "Tacticooler" - }, - { - "text": "Multiseñuelos", - "name": "Super Chump" - }, - { - "text": "Megaburbuja", - "name": "Big Bubbler" - }, - { - "text": "Atormentador", - "name": "Ink Storm" - }, - { - "text": "Propulsor", - "name": "Inkjet" - }, - { - "text": "Berreón 5.1", - "name": "Killer Wail 5.1" - }, - { - "text": "Lanzamisiles", - "name": "Tenta Missiles" - }, - { - "text": "Bola genializante", - "name": "Booyah Bomb" - }, - { - "text": "Clavado triple", - "name": "Triple Splashdown" - }, - { - "text": "Emiteondas", - "name": "Wave Breaker" - }, - { - "text": "Flotiburón", - "name": "Reefslider" - }, - { - "text": "Gancho tentacular", - "name": "Zipcaster" - }, - { - "text": "Tornado triple", - "name": "Triple Inkstrike" - }, - { - "text": "Cañón triple", - "name": "Trizooka" - }, - { - "text": "Ultraselladora", - "name": "Ultra Stamp" - } - ], - "USfr": [ - { - "text": "Turboblasteur pro", - "name": "Rapid Blaster Pro" - }, - { - "text": "Turboblasteur pro chic", - "name": "Rapid Blaster Pro Deco" - }, - { - "text": "Turboblasteur pro POL-R", - "name": "Rapid Blaster Pro WNT-R" - }, - { - "text": "Rafalblasteur", - "name": "Clash Blaster" - }, - { - "text": "Rafalblasteur néo", - "name": "Clash Blaster Neo" - }, - { - "text": "Turboblasteur", - "name": "Rapid Blaster" - }, - { - "text": "Turboblasteur chic", - "name": "Rapid Blaster Deco" - }, - { - "text": "Éclablasteur LP", - "name": "Range Blaster" - }, - { - "text": "Éclablasteur LP modifié", - "name": "Custom Range Blaster" - }, - { - "text": "Éclablasteur", - "name": "Blaster" - }, - { - "text": "Éclablasteur modifié", - "name": "Custom Blaster" - }, - { - "text": "Éclablasteur miragius", - "name": "Gleamz Blaster" - }, - { - "text": "S-Blasteur '92", - "name": "S-BLAST '92" - }, - { - "text": "S-Blasteur '91", - "name": "S-BLAST '91" - }, - { - "text": "Proxiblasteur", - "name": "Luna Blaster" - }, - { - "text": "Proxiblasteur néo", - "name": "Luna Blaster Neo" - }, - { - "text": "Blasteur d'Ordre (réplique)", - "name": "Order Blaster Replica" - }, - { - "text": "Épinceau écaïe", - "name": "Painbrush" - }, - { - "text": "Épinceau écaïe nuancé", - "name": "Painbrush Nouveau" - }, - { - "text": "Épinceau écaïe BRN-Z", - "name": "Painbrush BRN-Z" - }, - { - "text": "Épinceau", - "name": "Inkbrush" - }, - { - "text": "Épinceau nuancé", - "name": "Inkbrush Nouveau" - }, - { - "text": "Épinceau brosse", - "name": "Octobrush" - }, - { - "text": "Épinceau brosse nuancé", - "name": "Octobrush Nouveau" - }, - { - "text": "Épinceau brosse stellius", - "name": "Cometz Octobrush" - }, - { - "text": "Pinceau d'Ordre (réplique)", - "name": "Orderbrush Replica" - }, - { - "text": "Détubeur", - "name": "Goo Tuber" - }, - { - "text": "Détubeur modifié", - "name": "Custom Goo Tuber" - }, - { - "text": "Bimbamboum Mk I", - "name": "Bamboozler 14 Mk I" - }, - { - "text": "Bimbamboum Mk II", - "name": "Bamboozler 14 Mk II" - }, - { - "text": "X-traceur + zoom", - "name": "E-liter 4K Scope" - }, - { - "text": "X-traceur + zoom modifié", - "name": "Custom E-liter 4K Scope" - }, - { - "text": "X-traceur +", - "name": "E-liter 4K" - }, - { - "text": "X-traceur + modifié", - "name": "Custom E-liter 4K" - }, - { - "text": "Concentraceur zoom", - "name": "Splatterscope" - }, - { - "text": "Concentraceur zoom E+A", - "name": "Z+F Splatterscope" - }, - { - "text": "Concentraceur zoom CAM-O", - "name": "Splatterscope CAM-O" - }, - { - "text": "Concentraceur", - "name": "Splat Charger" - }, - { - "text": "Concentraceur E+A", - "name": "Z+F Splat Charger" - }, - { - "text": "Concentraceur CAM-O", - "name": "Splat Charger CAM-O" - }, - { - "text": "Fusil d'Ordre (réplique)", - "name": "Order Charger Replica" - }, - { - "text": "Crayon X - 5H", - "name": "Snipewriter 5H" - }, - { - "text": "Crayon X - 5B", - "name": "Snipewriter 5B" - }, - { - "text": "Lignizateur alpha", - "name": "Classic Squiffer" - }, - { - "text": "Lignizateur bêta", - "name": "New Squiffer" - }, - { - "text": "Double nettoyeur", - "name": "Dualie Squelchers" - }, - { - "text": "Double nettoyeur modifié", - "name": "Custom Dualie Squelchers" - }, - { - "text": "Double nettoyeur zébranius", - "name": "Hoofz Dualie Squelchers" - }, - { - "text": "Double Kelvin 525", - "name": "Glooga Dualies" - }, - { - "text": "Double Kelvin 525 chic", - "name": "Glooga Dualies Deco" - }, - { - "text": "Double ignifugeur", - "name": "Douser Dualies FF" - }, - { - "text": "Double ignifugeur modifié", - "name": "Custom Douser Dualies FF" - }, - { - "text": "Double encreur", - "name": "Splat Dualies" - }, - { - "text": "Double encreur DUX", - "name": "Enperry Splat Dualies" - }, - { - "text": "Double encreur flambius", - "name": "Twinklez Splat Dualies" - }, - { - "text": "Arme double d'Ordre (réplique)", - "name": "Order Dualie Replicas" - }, - { - "text": "Double moucheteur", - "name": "Dapple Dualies" - }, - { - "text": "Double moucheteur nuancé", - "name": "Dapple Dualies Nouveau" - }, - { - "text": "Double moucheteur NOC-T", - "name": "Dapple Dualies NOC-T" - }, - { - "text": "Double voltigeur noir", - "name": "Dark Tetra Dualies" - }, - { - "text": "Double voltigeur blanc", - "name": "Light Tetra Dualies" - }, - { - "text": "Rouleau carbone", - "name": "Carbon Roller" - }, - { - "text": "Rouleau carbone chic", - "name": "Carbon Roller Deco" - }, - { - "text": "Rouleau carbone BOD-RWA", - "name": "Carbon Roller ANG-L" - }, - { - "text": "Dynamo-rouleau", - "name": "Dynamo Roller" - }, - { - "text": "Dynamo-rouleau pro", - "name": "Gold Dynamo Roller" - }, - { - "text": "Dynamo-rouleau galaxius", - "name": "Starz Dynamo Roller" - }, - { - "text": "Flexi-rouleau", - "name": "Flingza Roller" - }, - { - "text": "Flexi-rouleau aluminium", - "name": "Foil Flingza Roller" - }, - { - "text": "Rouleau", - "name": "Splat Roller" - }, - { - "text": "Rouleau Krakeurs", - "name": "Krak-On Splat Roller" - }, - { - "text": "Rouleau d'Ordre (réplique)", - "name": "Order Roller Replica" - }, - { - "text": "Rouleau large", - "name": "Big Swig Roller" - }, - { - "text": "Rouleau large Xpress", - "name": "Big Swig Roller Express" - }, - { - "text": "Rouleau large astralius", - "name": "Planetz Big Swig Roller" - }, - { - "text": "Sabrifrice menthe", - "name": "Mint Decavitator" - }, - { - "text": "Sabrifrice charbon", - "name": "Charcoal Decavitator" - }, - { - "text": "Éclatana Doto", - "name": "Splatana Wiper" - }, - { - "text": "Éclatana Doto chic", - "name": "Splatana Wiper Deco" - }, - { - "text": "Éclatana Doto OXY-D", - "name": "Splatana Wiper RUS-T" - }, - { - "text": "Éclatana d'estampe", - "name": "Splatana Stamper" - }, - { - "text": "Éclatana d'estampe nuancé", - "name": "Splatana Stamper Nouveau" - }, - { - "text": "Éclatana d'estampe décolius", - "name": "Stickerz Splatana Stamper" - }, - { - "text": "Éclatana d'Ordre (réplique)", - "name": "Order Splatana Replica" - }, - { - "text": "Para-encre espion", - "name": "Undercover Brella" - }, - { - "text": "Para-encre espion Sorella", - "name": "Undercover Sorella Brella" - }, - { - "text": "Para-encre espion mélangius", - "name": "Patternz Undercover Brella" - }, - { - "text": "Para-encre Ré-gen 24 Mk I", - "name": "Recycled Brella 24 Mk I" - }, - { - "text": "Para-encre Ré-gen 24 Mk II", - "name": "Recycled Brella 24 Mk II" - }, - { - "text": "Para-encre", - "name": "Splat Brella" - }, - { - "text": "Para-encre Sorella", - "name": "Sorella Brella" - }, - { - "text": "Para-encre d'Ordre (réplique)", - "name": "Order Brella Replica" - }, - { - "text": "Méga para-encre", - "name": "Tenta Brella" - }, - { - "text": "Méga para-encre Sorella", - "name": "Tenta Sorella Brella" - }, - { - "text": "Méga para-encre CRM-A", - "name": "Tenta Brella CRE-M" - }, - { - "text": "Pouche-pouche", - "name": "Aerospray MG" - }, - { - "text": "Pouche-pouche premium", - "name": "Aerospray RG" - }, - { - "text": "Pouche-pouche chromius", - "name": "Colorz Aerospray" - }, - { - "text": "Liquéficateur pro", - "name": "Splattershot Pro" - }, - { - "text": "Liquéficateur pro Fosia", - "name": "Forge Splattershot Pro" - }, - { - "text": "Liquéficateur pro GLA-C", - "name": "Splattershot Pro FRZ-N" - }, - { - "text": "Liquéficateur ultra", - "name": "Splattershot Jr." - }, - { - "text": "Liquéficateur ultra modifié", - "name": "Custom Splattershot Jr." - }, - { - "text": "Compresseur", - "name": "Squeezer" - }, - { - "text": "Compresseur aluminium", - "name": "Foil Squeezer" - }, - { - "text": "Calibre 2000", - "name": ".52 Gal" - }, - { - "text": "Calibre 2000 chic", - "name": ".52 Gal Deco" - }, - { - "text": "Calibre 3000", - "name": ".96 Gal" - }, - { - "text": "Calibre 3000 chic", - "name": ".96 Gal Deco" - }, - { - "text": "Calibre 3000 guépius", - "name": "Clawz .96 Gal" - }, - { - "text": "Méganettoyeur", - "name": "Jet Squelcher" - }, - { - "text": "Méganettoyeur modifié", - "name": "Custom Jet Squelcher" - }, - { - "text": "Méganettoyeur COB-R", - "name": "Jet Squelcher COB-R" - }, - { - "text": "Liquéficateur", - "name": "Splattershot" - }, - { - "text": "Liquéficateur Tentatek", - "name": "Tentatek Splattershot" - }, - { - "text": "Liquéficateur glamius", - "name": "Glamorz Splattershot" - }, - { - "text": "Liquéfi. héroïque (rép.)", - "name": "Hero Shot Replica" - }, - { - "text": "Lanceur d'Ordre (réplique)", - "name": "Order Shot Replica" - }, - { - "text": "Lanceur octaling (réplique)", - "name": "Octo Shot Replica" - }, - { - "text": "Marqueur léger", - "name": "Splash-o-matic" - }, - { - "text": "Marqueur léger néo", - "name": "Neo Splash-o-matic" - }, - { - "text": "Marqueur léger G-KO", - "name": "Splash-o-matic GCK-O" - }, - { - "text": "Lanceur spatial", - "name": "Splattershot Nova" - }, - { - "text": "Lanceur spatial Aroz", - "name": "Annaki Splattershot Nova" - }, - { - "text": "N-ZAP 85", - "name": "N-ZAP '85" - }, - { - "text": "N-ZAP 89", - "name": "N-ZAP '89" - }, - { - "text": "Marqueur lourd", - "name": "Sploosh-o-matic" - }, - { - "text": "Marqueur lourd néo", - "name": "Neo Sploosh-o-matic" - }, - { - "text": "Arroseur lourd", - "name": "H-3 Nozzlenose" - }, - { - "text": "Arroseur lourd requin", - "name": "H-3 Nozzlenose D" - }, - { - "text": "Arroseur lourd VIP-R", - "name": "H-3 Nozzlenose VIP-R" - }, - { - "text": "Arroseur léger", - "name": "L-3 Nozzlenose" - }, - { - "text": "Arroseur léger requin", - "name": "L-3 Nozzlenose D" - }, - { - "text": "Arroseur léger éclatius", - "name": "Glitterz L-3 Nozzlenose" - }, - { - "text": "Bassineur", - "name": "Bloblobber" - }, - { - "text": "Bassineur chic", - "name": "Bloblobber Deco" - }, - { - "text": "Seauceur trio", - "name": "Tri-Slosher" - }, - { - "text": "Seauceur nuancé", - "name": "Tri-Slosher Nouveau" - }, - { - "text": "Seauceur trio 100-DRÉ", - "name": "Tri-Slosher ASH-N" - }, - { - "text": "Seau S-Horreur", - "name": "Dread Wringer" - }, - { - "text": "Seau S-Horreur requin", - "name": "Dread Wringer D" - }, - { - "text": "Seau S-Horreur kératius", - "name": "Hornz Dread Wringer" - }, - { - "text": "Encrifugeur", - "name": "Sloshing Machine" - }, - { - "text": "Encrifugeur néo", - "name": "Sloshing Machine Neo" - }, - { - "text": "Seauceur", - "name": "Slosher" - }, - { - "text": "Seauceur chic", - "name": "Slosher Deco" - }, - { - "text": "Seau d'Ordre (réplique)", - "name": "Order Slosher Replica" - }, - { - "text": "Détoneur", - "name": "Explosher" - }, - { - "text": "Détoneur modifié", - "name": "Custom Explosher" - }, - { - "text": "Badigeonneur stylo", - "name": "Ballpoint Splatling" - }, - { - "text": "Badigeonneur stylo nuancé", - "name": "Ballpoint Splatling Nouveau" - }, - { - "text": "Correcteur", - "name": "Heavy Edit Splatling" - }, - { - "text": "Correcteur nuancé", - "name": "Heavy Edit Splatling Nouveau" - }, - { - "text": "Exteinteur", - "name": "Hydra Splatling" - }, - { - "text": "Exteinteur modifié", - "name": "Custom Hydra Splatling" - }, - { - "text": "Exteinteur cascadius", - "name": "Torrentz Hydra Splatling" - }, - { - "text": "Badigeonneur XP", - "name": "Mini Splatling" - }, - { - "text": "Badigeonneur XP Leviathus", - "name": "Zink Mini Splatling" - }, - { - "text": "Badigeonneur XP VN-1", - "name": "Mini Splatling RTL-R" - }, - { - "text": "Badigeonneur", - "name": "Heavy Splatling" - }, - { - "text": "Badigeonneur chic", - "name": "Heavy Splatling Deco" - }, - { - "text": "Badigeonneur d'Ordre (réplique)", - "name": "Order Splatling Replica" - }, - { - "text": "Multisperceur V", - "name": "Wellstring V" - }, - { - "text": "Multisperceur V modifié", - "name": "Custom Wellstring V" - }, - { - "text": "Trisperceur", - "name": "Tri-Stringer" - }, - { - "text": "Trisperceur Abysma", - "name": "Inkline Tri-Stringer" - }, - { - "text": "Trisperceur prismalius", - "name": "Bulbz Tri-Stringer" - }, - { - "text": "Transperceur d'Ordre (réplique)", - "name": "Order Stringer Replica" - }, - { - "text": "Coralux 450", - "name": "REEF-LUX 450" - }, - { - "text": "Coralux 450 chic", - "name": "REEF-LUX 450 Deco" - }, - { - "text": "Coralux 450 LACTO", - "name": "REEF-LUX 450 MIL-K" - }, - { - "text": "Balise de saut", - "name": "Squid Beakon" - }, - { - "text": "Bombe curling", - "name": "Curling Bomb" - }, - { - "text": "Bombe soda", - "name": "Fizzy Bomb" - }, - { - "text": "Bombe ballon", - "name": "Burst Bomb" - }, - { - "text": "Bombe robot", - "name": "Autobomb" - }, - { - "text": "Bombe splash", - "name": "Splat Bomb" - }, - { - "text": "Bombe gluante", - "name": "Suction Bomb" - }, - { - "text": "Bentorpille", - "name": "Torpedo" - }, - { - "text": "Ricocheur", - "name": "Angle Shooter" - }, - { - "text": "Détecteur", - "name": "Point Sensor" - }, - { - "text": "Brume toxique", - "name": "Toxic Mist" - }, - { - "text": "Mur d'encre", - "name": "Splash Wall" - }, - { - "text": "Gicleur", - "name": "Sprinkler" - }, - { - "text": "Mine d'encre", - "name": "Ink Mine" - }, - { - "text": "Aspirencre", - "name": "Ink Vac" - }, - { - "text": "Kraken royal", - "name": "Kraken Royale" - }, - { - "text": "Crabe d'assaut", - "name": "Crab Tank" - }, - { - "text": "Barrière barbouillée", - "name": "Splattercolor Screen" - }, - { - "text": "Districool", - "name": "Tacticooler" - }, - { - "text": "Multi-leurres", - "name": "Super Chump" - }, - { - "text": "Super bouclier", - "name": "Big Bubbler" - }, - { - "text": "Pluie d'encre", - "name": "Ink Storm" - }, - { - "text": "Chromo-jet", - "name": "Inkjet" - }, - { - "text": "Laser perçant 5.1", - "name": "Killer Wail 5.1" - }, - { - "text": "Multi-missile", - "name": "Tenta Missiles" - }, - { - "text": "Excellinator", - "name": "Booyah Bomb" - }, - { - "text": "Triple choc chromatique", - "name": "Triple Splashdown" - }, - { - "text": "Sonar paf", - "name": "Wave Breaker" - }, - { - "text": "Cavalsquale", - "name": "Reefslider" - }, - { - "text": "Super Mollusque", - "name": "Zipcaster" - }, - { - "text": "Trimissile tornade", - "name": "Triple Inkstrike" - }, - { - "text": "Lance-tornades", - "name": "Trizooka" - }, - { - "text": "Ultra-étampeur", - "name": "Ultra Stamp" - } - ] + CNzh: [ + { + text: "快速爆破枪 精英", + name: "Rapid Blaster Pro", + }, + { + text: "快速爆破枪 精英装饰", + name: "Rapid Blaster Pro Deco", + }, + { + text: "快速爆破枪 精英WNTR", + name: "Rapid Blaster Pro WNT-R", + }, + { + text: "冲涂爆破枪", + name: "Clash Blaster", + }, + { + text: "冲涂爆破枪 新型", + name: "Clash Blaster Neo", + }, + { + text: "快速爆破枪", + name: "Rapid Blaster", + }, + { + text: "快速爆破枪 装饰", + name: "Rapid Blaster Deco", + }, + { + text: "远距爆破枪", + name: "Range Blaster", + }, + { + text: "远距爆破枪 改装", + name: "Custom Range Blaster", + }, + { + text: "火热爆破枪", + name: "Blaster", + }, + { + text: "火热爆破枪 改装", + name: "Custom Blaster", + }, + { + text: "火热爆破枪 艳", + name: "Gleamz Blaster", + }, + { + text: "S-BLAST92", + name: "S-BLAST '92", + }, + { + text: "S-BLAST91", + name: "S-BLAST '91", + }, + { + text: "新星爆破枪", + name: "Luna Blaster", + }, + { + text: "新星爆破枪 新型", + name: "Luna Blaster Neo", + }, + { + text: "秩序爆破枪 复制", + name: "Order Blaster Replica", + }, + { + text: "文森", + name: "Painbrush", + }, + { + text: "文森·新艺术", + name: "Painbrush Nouveau", + }, + { + text: "文森BRNZ", + name: "Painbrush BRN-Z", + }, + { + text: "巴勃罗", + name: "Inkbrush", + }, + { + text: "巴勃罗·新艺术", + name: "Inkbrush Nouveau", + }, + { + text: "北斋", + name: "Octobrush", + }, + { + text: "北斋·新艺术", + name: "Octobrush Nouveau", + }, + { + text: "北斋 彗", + name: "Cometz Octobrush", + }, + { + text: "秩序排刷 复制", + name: "Orderbrush Replica", + }, + { + text: "高压油管枪", + name: "Goo Tuber", + }, + { + text: "高压油管枪 改装", + name: "Custom Goo Tuber", + }, + { + text: "14式竹筒枪·甲", + name: "Bamboozler 14 Mk I", + }, + { + text: "14式竹筒枪·乙", + name: "Bamboozler 14 Mk II", + }, + { + text: "4K准星枪", + name: "E-liter 4K Scope", + }, + { + text: "4K准星枪 改装", + name: "Custom E-liter 4K Scope", + }, + { + text: "公升4K", + name: "E-liter 4K", + }, + { + text: "公升4K 改装", + name: "Custom E-liter 4K", + }, + { + text: "斯普拉准星枪", + name: "Splatterscope", + }, + { + text: "斯普拉准星枪 联名", + name: "Z+F Splatterscope", + }, + { + text: "斯普拉准星枪FRST", + name: "Splatterscope CAM-O", + }, + { + text: "斯普拉蓄力狙击枪", + name: "Splat Charger", + }, + { + text: "斯普拉蓄力狙击枪 联名", + name: "Z+F Splat Charger", + }, + { + text: "斯普拉蓄力狙击枪FRST", + name: "Splat Charger CAM-O", + }, + { + text: "秩序蓄力狙击枪 复制", + name: "Order Charger Replica", + }, + { + text: "R-PEN/5H", + name: "Snipewriter 5H", + }, + { + text: "R-PEN/5B", + name: "Snipewriter 5B", + }, + { + text: "鱿快洁α", + name: "Classic Squiffer", + }, + { + text: "鱿快洁β", + name: "New Squiffer", + }, + { + text: "双重清洁枪", + name: "Dualie Squelchers", + }, + { + text: "双重清洁枪 改装", + name: "Custom Dualie Squelchers", + }, + { + text: "双重清洁枪 蹄", + name: "Hoofz Dualie Squelchers", + }, + { + text: "开尔文525", + name: "Glooga Dualies", + }, + { + text: "开尔文525 装饰", + name: "Glooga Dualies Deco", + }, + { + text: "灭熄FF", + name: "Douser Dualies FF", + }, + { + text: "灭熄FF 改装", + name: "Custom Douser Dualies FF", + }, + { + text: "斯普拉机动枪", + name: "Splat Dualies", + }, + { + text: "斯普拉机动枪 联名", + name: "Enperry Splat Dualies", + }, + { + text: "斯普拉机动枪 耀", + name: "Twinklez Splat Dualies", + }, + { + text: "秩序机动枪 复制", + name: "Order Dualie Replicas", + }, + { + text: "溅镀枪", + name: "Dapple Dualies", + }, + { + text: "溅镀枪·新艺术", + name: "Dapple Dualies Nouveau", + }, + { + text: "溅镀枪OWL", + name: "Dapple Dualies NOC-T", + }, + { + text: "四重弹跳手枪 黑", + name: "Dark Tetra Dualies", + }, + { + text: "四重弹跳手枪 白", + name: "Light Tetra Dualies", + }, + { + text: "碳纤维滚筒", + name: "Carbon Roller", + }, + { + text: "碳纤维滚筒 装饰", + name: "Carbon Roller Deco", + }, + { + text: "碳纤维滚筒ANGL", + name: "Carbon Roller ANG-L", + }, + { + text: "电动马达滚筒", + name: "Dynamo Roller", + }, + { + text: "电动马达滚筒 高磁波", + name: "Gold Dynamo Roller", + }, + { + text: "电动马达滚筒 冥", + name: "Starz Dynamo Roller", + }, + { + text: "可变式滚筒", + name: "Flingza Roller", + }, + { + text: "可变式滚筒 金属箔", + name: "Foil Flingza Roller", + }, + { + text: "斯普拉滚筒", + name: "Splat Roller", + }, + { + text: "斯普拉滚筒 联名", + name: "Krak-On Splat Roller", + }, + { + text: "秩序滚筒 复制", + name: "Order Roller Replica", + }, + { + text: "宽滚筒", + name: "Big Swig Roller", + }, + { + text: "宽滚筒 联名", + name: "Big Swig Roller Express", + }, + { + text: "宽滚筒 惑", + name: "Planetz Big Swig Roller", + }, + { + text: "巨齿刮水刀 薄荷", + name: "Mint Decavitator", + }, + { + text: "巨齿刮水刀 墨黑", + name: "Charcoal Decavitator", + }, + { + text: "雨刷刮水刀", + name: "Splatana Wiper", + }, + { + text: "雨刷刮水刀 装饰", + name: "Splatana Wiper Deco", + }, + { + text: "雨刷刮水刀RUST", + name: "Splatana Wiper RUS-T", + }, + { + text: "工作刮水刀", + name: "Splatana Stamper", + }, + { + text: "工作刮水刀·新艺术", + name: "Splatana Stamper Nouveau", + }, + { + text: "工作刮水刀 封", + name: "Stickerz Splatana Stamper", + }, + { + text: "秩序刮水刀 复制", + name: "Order Splatana Replica", + }, + { + text: "特工配件", + name: "Undercover Brella", + }, + { + text: "特工配件 姐妹", + name: "Undercover Sorella Brella", + }, + { + text: "特工配件 缭", + name: "Patternz Undercover Brella", + }, + { + text: "24式可替换伞·甲", + name: "Recycled Brella 24 Mk I", + }, + { + text: "24式可替换伞·乙", + name: "Recycled Brella 24 Mk II", + }, + { + text: "遮阳防空伞", + name: "Splat Brella", + }, + { + text: "遮阳防空伞 姐妹", + name: "Sorella Brella", + }, + { + text: "秩序防空伞 复制", + name: "Order Brella Replica", + }, + { + text: "露营防空伞", + name: "Tenta Brella", + }, + { + text: "露营防空伞 姐妹", + name: "Tenta Sorella Brella", + }, + { + text: "露营防空伞CREM", + name: "Tenta Brella CRE-M", + }, + { + text: "专业模型枪MG", + name: "Aerospray MG", + }, + { + text: "专业模型枪RG", + name: "Aerospray RG", + }, + { + text: "专业模型枪 彩", + name: "Colorz Aerospray", + }, + { + text: "顶尖射击枪", + name: "Splattershot Pro", + }, + { + text: "顶尖射击枪 联名", + name: "Forge Splattershot Pro", + }, + { + text: "顶尖射击枪FRZN", + name: "Splattershot Pro FRZ-N", + }, + { + text: "新叶射击枪", + name: "Splattershot Jr.", + }, + { + text: "枫叶射击枪", + name: "Custom Splattershot Jr.", + }, + { + text: "开瓶喷泉枪", + name: "Squeezer", + }, + { + text: "开瓶喷泉枪 金属箔", + name: "Foil Squeezer", + }, + { + text: ".52加仑", + name: ".52 Gal", + }, + { + text: ".52加仑 装饰", + name: ".52 Gal Deco", + }, + { + text: ".96加仑", + name: ".96 Gal", + }, + { + text: ".96加仑 装饰", + name: ".96 Gal Deco", + }, + { + text: ".96加仑 爪", + name: "Clawz .96 Gal", + }, + { + text: "喷射清洁枪", + name: "Jet Squelcher", + }, + { + text: "喷射清洁枪 改装", + name: "Custom Jet Squelcher", + }, + { + text: "喷射清洁枪COBR", + name: "Jet Squelcher COB-R", + }, + { + text: "斯普拉射击枪", + name: "Splattershot", + }, + { + text: "斯普拉射击枪 联名", + name: "Tentatek Splattershot", + }, + { + text: "斯普拉射击枪 煌", + name: "Glamorz Splattershot", + }, + { + text: "英雄射击枪 复制", + name: "Hero Shot Replica", + }, + { + text: "秩序射击枪 复制", + name: "Order Shot Replica", + }, + { + text: "章鱼射击枪 复制", + name: "Octo Shot Replica", + }, + { + text: "窄域标记枪", + name: "Splash-o-matic", + }, + { + text: "窄域标记枪 新型", + name: "Neo Splash-o-matic", + }, + { + text: "窄域标记枪GECK", + name: "Splash-o-matic GCK-O", + }, + { + text: "太空射击枪", + name: "Splattershot Nova", + }, + { + text: "太空射击枪 联名", + name: "Annaki Splattershot Nova", + }, + { + text: "N-ZAP85", + name: "N-ZAP '85", + }, + { + text: "N-ZAP89", + name: "N-ZAP '89", + }, + { + text: "广域标记枪", + name: "Sploosh-o-matic", + }, + { + text: "广域标记枪 新型", + name: "Neo Sploosh-o-matic", + }, + { + text: "H3卷管枪", + name: "H-3 Nozzlenose", + }, + { + text: "H3卷管枪D", + name: "H-3 Nozzlenose D", + }, + { + text: "H3卷管枪SNAK", + name: "H-3 Nozzlenose VIP-R", + }, + { + text: "L3卷管枪", + name: "L-3 Nozzlenose", + }, + { + text: "L3卷管枪D", + name: "L-3 Nozzlenose D", + }, + { + text: "L3卷管枪 箔", + name: "Glitterz L-3 Nozzlenose", + }, + { + text: "满溢泡澡泼桶", + name: "Bloblobber", + }, + { + text: "满溢泡澡泼桶 装饰", + name: "Bloblobber Deco", + }, + { + text: "洗笔桶", + name: "Tri-Slosher", + }, + { + text: "洗笔桶·新艺术", + name: "Tri-Slosher Nouveau", + }, + { + text: "洗笔桶ASH", + name: "Tri-Slosher ASH-N", + }, + { + text: "墨瀑淋", + name: "Dread Wringer", + }, + { + text: "墨瀑淋D", + name: "Dread Wringer D", + }, + { + text: "墨瀑淋 角", + name: "Hornz Dread Wringer", + }, + { + text: "回旋泼桶", + name: "Sloshing Machine", + }, + { + text: "回旋泼桶 新型", + name: "Sloshing Machine Neo", + }, + { + text: "飞溅泼桶", + name: "Slosher", + }, + { + text: "飞溅泼桶 装饰", + name: "Slosher Deco", + }, + { + text: "秩序泼桶 复制", + name: "Order Slosher Replica", + }, + { + text: "爆炸泼桶", + name: "Explosher", + }, + { + text: "爆炸泼桶 改装", + name: "Custom Explosher", + }, + { + text: "圆珠笔", + name: "Ballpoint Splatling", + }, + { + text: "圆珠笔·新艺术", + name: "Ballpoint Splatling Nouveau", + }, + { + text: "审查者", + name: "Heavy Edit Splatling", + }, + { + text: "审查者·新艺术", + name: "Heavy Edit Splatling Nouveau", + }, + { + text: "消防栓旋转枪", + name: "Hydra Splatling", + }, + { + text: "消防栓旋转枪 改装", + name: "Custom Hydra Splatling", + }, + { + text: "消防栓旋转枪 压", + name: "Torrentz Hydra Splatling", + }, + { + text: "斯普拉旋转枪", + name: "Mini Splatling", + }, + { + text: "斯普拉旋转枪 联名", + name: "Zink Mini Splatling", + }, + { + text: "斯普拉旋转枪PYTN", + name: "Mini Splatling RTL-R", + }, + { + text: "鹦鹉螺号47", + name: "Nautilus 47", + }, + { + text: "鹦鹉螺号79", + name: "Nautilus 79", + }, + { + text: "桶装旋转枪", + name: "Heavy Splatling", + }, + { + text: "桶装旋转枪 装饰", + name: "Heavy Splatling Deco", + }, + { + text: "秩序旋转枪 复制", + name: "Order Splatling Replica", + }, + { + text: "邦普V", + name: "Wellstring V", + }, + { + text: "邦普V 改装", + name: "Custom Wellstring V", + }, + { + text: "三发猎鱼弓", + name: "Tri-Stringer", + }, + { + text: "三发猎鱼弓 联名", + name: "Inkline Tri-Stringer", + }, + { + text: "三发猎鱼弓 灯", + name: "Bulbz Tri-Stringer", + }, + { + text: "秩序猎鱼弓 复制", + name: "Order Stringer Replica", + }, + { + text: "LACT-450", + name: "REEF-LUX 450", + }, + { + text: "LACT-450 装饰", + name: "REEF-LUX 450 Deco", + }, + { + text: "LACT-450MILK", + name: "REEF-LUX 450 MIL-K", + }, + { + text: "跳跃信标", + name: "Squid Beakon", + }, + { + text: "冰壶炸弹", + name: "Curling Bomb", + }, + { + text: "碳酸炸弹", + name: "Fizzy Bomb", + }, + { + text: "快速炸弹", + name: "Burst Bomb", + }, + { + text: "机器人炸弹", + name: "Autobomb", + }, + { + text: "斯普拉炸弹", + name: "Splat Bomb", + }, + { + text: "吸盘炸弹", + name: "Suction Bomb", + }, + { + text: "鱼雷", + name: "Torpedo", + }, + { + text: "标线器", + name: "Angle Shooter", + }, + { + text: "定点侦测器", + name: "Point Sensor", + }, + { + text: "毒雾", + name: "Toxic Mist", + }, + { + text: "斯普拉防护墙", + name: "Splash Wall", + }, + { + text: "洒墨器", + name: "Sprinkler", + }, + { + text: "墨汁陷阱", + name: "Ink Mine", + }, + { + text: "吸墨机", + name: "Ink Vac", + }, + { + text: "帝王鱿鱼", + name: "Kraken Royale", + }, + { + text: "螃蟹坦克", + name: "Crab Tank", + }, + { + text: "浮墨幕墙", + name: "Splattercolor Screen", + }, + { + text: "能量站", + name: "Tacticooler", + }, + { + text: "诱饵烟花", + name: "Super Chump", + }, + { + text: "巨大防护罩", + name: "Big Bubbler", + }, + { + text: "墨雨云", + name: "Ink Storm", + }, + { + text: "喷射背包", + name: "Inkjet", + }, + { + text: "喇叭镭射5.1ch", + name: "Killer Wail 5.1", + }, + { + text: "多重导弹", + name: "Tenta Missiles", + }, + { + text: "赞气弹", + name: "Booyah Bomb", + }, + { + text: "终极着陆", + name: "Triple Splashdown", + }, + { + text: "弹跳声呐", + name: "Wave Breaker", + }, + { + text: "鲨鱼坐骑", + name: "Reefslider", + }, + { + text: "触手喷射", + name: "Zipcaster", + }, + { + text: "三重龙卷风", + name: "Triple Inkstrike", + }, + { + text: "终极发射", + name: "Trizooka", + }, + { + text: "终极印章", + name: "Ultra Stamp", + }, + ], + EUde: [ + { + text: "Turbo-Blaster Plus", + name: "Rapid Blaster Pro", + }, + { + text: "Turbo-Blaster Plus Deko", + name: "Rapid Blaster Pro Deco", + }, + { + text: "Turbo-Blaster Plus WNT-R", + name: "Rapid Blaster Pro WNT-R", + }, + { + text: "Kontra-Blaster", + name: "Clash Blaster", + }, + { + text: "Kontra-Blaster Neo", + name: "Clash Blaster Neo", + }, + { + text: "Turbo-Blaster", + name: "Rapid Blaster", + }, + { + text: "Turbo-Blaster Deko", + name: "Rapid Blaster Deco", + }, + { + text: "Fern-Blaster", + name: "Range Blaster", + }, + { + text: "Fern-Blaster SE", + name: "Custom Range Blaster", + }, + { + text: "Blaster SE", + name: "Custom Blaster", + }, + { + text: "Blaster GLAM", + name: "Gleamz Blaster", + }, + { + text: "S-BLAST92", + name: "S-BLAST '92", + }, + { + text: "S-BLAST91", + name: "S-BLAST '91", + }, + { + text: "Luna-Blaster", + name: "Luna Blaster", + }, + { + text: "Luna-Blaster Neo", + name: "Luna Blaster Neo", + }, + { + text: "Ordnung-Blaster Replik", + name: "Order Blaster Replica", + }, + { + text: "Fächerfärber", + name: "Painbrush", + }, + { + text: "Fächerfärber Fresco", + name: "Painbrush Nouveau", + }, + { + text: "Fächerfärber BRN-Z", + name: "Painbrush BRN-Z", + }, + { + text: "Quasto", + name: "Inkbrush", + }, + { + text: "Quasto Fresco", + name: "Inkbrush Nouveau", + }, + { + text: "Kalligraf", + name: "Octobrush", + }, + { + text: "Kalligraf Fresco", + name: "Octobrush Nouveau", + }, + { + text: "Kalligraf Astro", + name: "Cometz Octobrush", + }, + { + text: "Ordnung-Kalligraf Replik", + name: "Orderbrush Replica", + }, + { + text: "T-Tuber", + name: "Goo Tuber", + }, + { + text: "T-Tuber SE", + name: "Custom Goo Tuber", + }, + { + text: "Klotzer 14-A", + name: "Bamboozler 14 Mk I", + }, + { + text: "Klotzer 14-B", + name: "Bamboozler 14 Mk II", + }, + { + text: "Ziel-E-liter 4K", + name: "E-liter 4K Scope", + }, + { + text: "Ziel-E-liter 4K SE", + name: "Custom E-liter 4K Scope", + }, + { + text: "E-liter 4K SE", + name: "Custom E-liter 4K", + }, + { + text: "Ziel-Konzentrator", + name: "Splatterscope", + }, + { + text: "Zekkori-Ziel-Konzentrator", + name: "Z+F Splatterscope", + }, + { + text: "Ziel-Konzentrator TAR-N", + name: "Splatterscope CAM-O", + }, + { + text: "Klecks-Konzentrator", + name: "Splat Charger", + }, + { + text: "Zekkori-Klecks-Konzentrator", + name: "Z+F Splat Charger", + }, + { + text: "Klecks-Konzentrator TAR-N", + name: "Splat Charger CAM-O", + }, + { + text: "Ordnung-Konzentrator Replik", + name: "Order Charger Replica", + }, + { + text: "R-BLR/5H", + name: "Snipewriter 5H", + }, + { + text: "R-BLR/5B", + name: "Snipewriter 5B", + }, + { + text: "Sepiator α", + name: "Classic Squiffer", + }, + { + text: "Sepiator β", + name: "New Squiffer", + }, + { + text: "Dual-Platscher", + name: "Dualie Squelchers", + }, + { + text: "Dual-Platscher SE", + name: "Custom Dualie Squelchers", + }, + { + text: "Dual-Platscher ZBR", + name: "Hoofz Dualie Squelchers", + }, + { + text: "Kelvin 525", + name: "Glooga Dualies", + }, + { + text: "Kelvin 525 Deko", + name: "Glooga Dualies Deco", + }, + { + text: "Lösch-Doppler FW", + name: "Douser Dualies FF", + }, + { + text: "Lösch-Doppler FW SE", + name: "Custom Douser Dualies FF", + }, + { + text: "Klecks-Doppler", + name: "Splat Dualies", + }, + { + text: "Enperry-Klecks-Doppler", + name: "Enperry Splat Dualies", + }, + { + text: "Glitz-Klecks-Doppler", + name: "Twinklez Splat Dualies", + }, + { + text: "Ordnung-Doppler Replik", + name: "Order Dualie Replicas", + }, + { + text: "Sprenkler", + name: "Dapple Dualies", + }, + { + text: "Sprenkler Fresco", + name: "Dapple Dualies Nouveau", + }, + { + text: "Sprenkler U-HU", + name: "Dapple Dualies NOC-T", + }, + { + text: "Quadhopper Noir", + name: "Dark Tetra Dualies", + }, + { + text: "Quadhopper Blanc", + name: "Light Tetra Dualies", + }, + { + text: "Karbonroller", + name: "Carbon Roller", + }, + { + text: "Karbonroller Deko", + name: "Carbon Roller Deco", + }, + { + text: "Karbonroller ANG-LR", + name: "Carbon Roller ANG-L", + }, + { + text: "Dynaroller", + name: "Dynamo Roller", + }, + { + text: "Dynaroller Tesla", + name: "Gold Dynamo Roller", + }, + { + text: "Dynaroller Galaxa", + name: "Starz Dynamo Roller", + }, + { + text: "Flex-Roller", + name: "Flingza Roller", + }, + { + text: "Flex-Roller Fol", + name: "Foil Flingza Roller", + }, + { + text: "Klecksroller", + name: "Splat Roller", + }, + { + text: "Medusa-Klecksroller", + name: "Krak-On Splat Roller", + }, + { + text: "Ordnung-Roller Replik", + name: "Order Roller Replica", + }, + { + text: "Breitroller", + name: "Big Swig Roller", + }, + { + text: "Breitroller Express", + name: "Big Swig Roller Express", + }, + { + text: "Breitroller Exzell", + name: "Planetz Big Swig Roller", + }, + { + text: "Minz-Prophylator", + name: "Mint Decavitator", + }, + { + text: "Carbo-Prophylator", + name: "Charcoal Decavitator", + }, + { + text: "Wischer-Splatana", + name: "Splatana Wiper", + }, + { + text: "Wischer-Splatana Deko", + name: "Splatana Wiper Deco", + }, + { + text: "Wischer-Splatana RST-G", + name: "Splatana Wiper RUS-T", + }, + { + text: "Stempel-Splatana", + name: "Splatana Stamper", + }, + { + text: "Stempel-Splatana Fresco", + name: "Splatana Stamper Nouveau", + }, + { + text: "Stempel-Splatana Sticker", + name: "Stickerz Splatana Stamper", + }, + { + text: "Ordnung-Splatana Replik", + name: "Order Splatana Replica", + }, + { + text: "UnderCover", + name: "Undercover Brella", + }, + { + text: "Sorella-UnderCover", + name: "Undercover Sorella Brella", + }, + { + text: "Flair-UnderCover", + name: "Patternz Undercover Brella", + }, + { + text: "Bast-Pluviator 24-A", + name: "Recycled Brella 24 Mk I", + }, + { + text: "Bast-Pluviator 24-B", + name: "Recycled Brella 24 Mk II", + }, + { + text: "Parapluviator", + name: "Splat Brella", + }, + { + text: "Sorella-Parapluviator", + name: "Sorella Brella", + }, + { + text: "Ordnung-Pluviator Replik", + name: "Order Brella Replica", + }, + { + text: "Camp-Pluviator", + name: "Tenta Brella", + }, + { + text: "Sorella-Camp-Pluviator", + name: "Tenta Sorella Brella", + }, + { + text: "Camp-Pluviator CR-MA", + name: "Tenta Brella CRE-M", + }, + { + text: "Airbrush MG", + name: "Aerospray MG", + }, + { + text: "Airbrush RG", + name: "Aerospray RG", + }, + { + text: "Airbrush RGB", + name: "Colorz Aerospray", + }, + { + text: "Profi-Kleckser", + name: "Splattershot Pro", + }, + { + text: "Focus-Profi-Kleckser", + name: "Forge Splattershot Pro", + }, + { + text: "Profi-Kleckser CH-LL", + name: "Splattershot Pro FRZ-N", + }, + { + text: "Junior-Kleckser", + name: "Splattershot Jr.", + }, + { + text: "Junior-Kleckser Plus", + name: "Custom Splattershot Jr.", + }, + { + text: "Quetscher", + name: "Squeezer", + }, + { + text: "Quetscher Fol", + name: "Foil Squeezer", + }, + { + text: ".52 Gallon", + name: ".52 Gal", + }, + { + text: ".52 Gallon Deko", + name: ".52 Gal Deco", + }, + { + text: ".96 Gallon", + name: ".96 Gal", + }, + { + text: ".96 Gallon Deko", + name: ".96 Gal Deco", + }, + { + text: ".96 Gallon Leo", + name: "Clawz .96 Gal", + }, + { + text: "Platscher", + name: "Jet Squelcher", + }, + { + text: "Platscher SE", + name: "Custom Jet Squelcher", + }, + { + text: "Platscher KB-RA", + name: "Jet Squelcher COB-R", + }, + { + text: "Kleckser", + name: "Splattershot", + }, + { + text: "Tentatek-Kleckser", + name: "Tentatek Splattershot", + }, + { + text: "Chroma-Kleckser", + name: "Glamorz Splattershot", + }, + { + text: "Heldenwaffe Replik", + name: "Hero Shot Replica", + }, + { + text: "Ordnung-Kleckser Replik", + name: "Order Shot Replica", + }, + { + text: "Okto-Kleckser Replik", + name: "Octo Shot Replica", + }, + { + text: "Fein-Disperser", + name: "Splash-o-matic", + }, + { + text: "Fein-Disperser Neo", + name: "Neo Splash-o-matic", + }, + { + text: "Fein-Disperser GK-KO", + name: "Splash-o-matic GCK-O", + }, + { + text: "Kosmo-Kleckser", + name: "Splattershot Nova", + }, + { + text: "Annaki-Kosmo-Kleckser", + name: "Annaki Splattershot Nova", + }, + { + text: "N-ZAP85", + name: "N-ZAP '85", + }, + { + text: "N-ZAP89", + name: "N-ZAP '89", + }, + { + text: "Disperser", + name: "Sploosh-o-matic", + }, + { + text: "Disperser Neo", + name: "Neo Sploosh-o-matic", + }, + { + text: "S3 Tintenwerfer", + name: "H-3 Nozzlenose", + }, + { + text: "S3 Tintenwerfer D", + name: "H-3 Nozzlenose D", + }, + { + text: "S3 Tintenwerfer VIP-R", + name: "H-3 Nozzlenose VIP-R", + }, + { + text: "L3 Tintenwerfer", + name: "L-3 Nozzlenose", + }, + { + text: "L3 Tintenwerfer D", + name: "L-3 Nozzlenose D", + }, + { + text: "L3 Tintenwerfer Oro", + name: "Glitterz L-3 Nozzlenose", + }, + { + text: "Wannen-Schwapper", + name: "Bloblobber", + }, + { + text: "Wannen-Schwapper Deko", + name: "Bloblobber Deco", + }, + { + text: "3R-Schwapper", + name: "Tri-Slosher", + }, + { + text: "3R-Schwapper Fresco", + name: "Tri-Slosher Nouveau", + }, + { + text: "3R-Schwapper OX-D", + name: "Tri-Slosher ASH-N", + }, + { + text: "Schwappwascher", + name: "Dread Wringer", + }, + { + text: "Schwappwascher D", + name: "Dread Wringer D", + }, + { + text: "Schwappwascher Horn", + name: "Hornz Dread Wringer", + }, + { + text: "Trommel-Schwapper", + name: "Sloshing Machine", + }, + { + text: "Trommel-Schwapper Neo", + name: "Sloshing Machine Neo", + }, + { + text: "Schwapper", + name: "Slosher", + }, + { + text: "Schwapper Deko", + name: "Slosher Deco", + }, + { + text: "Ordnung-Schwapper Replik", + name: "Order Slosher Replica", + }, + { + text: "Knall-Schwapper", + name: "Explosher", + }, + { + text: "Knall-Schwapper SE", + name: "Custom Explosher", + }, + { + text: "Kuli-Splatling", + name: "Ballpoint Splatling", + }, + { + text: "Kuli-Splatling Fresco", + name: "Ballpoint Splatling Nouveau", + }, + { + text: "Kartuschierer", + name: "Heavy Edit Splatling", + }, + { + text: "Kartuschierer Fresco", + name: "Heavy Edit Splatling Nouveau", + }, + { + text: "Hydrant", + name: "Hydra Splatling", + }, + { + text: "Hydrant SE", + name: "Custom Hydra Splatling", + }, + { + text: "Hydrant ATÜ", + name: "Torrentz Hydra Splatling", + }, + { + text: "Klecks-Splatling", + name: "Mini Splatling", + }, + { + text: "Sagitron-Klecks-Splatling", + name: "Zink Mini Splatling", + }, + { + text: "Klecks-Splatling KLP-R", + name: "Mini Splatling RTL-R", + }, + { + text: "Splatling", + name: "Heavy Splatling", + }, + { + text: "Splatling Deko", + name: "Heavy Splatling Deco", + }, + { + text: "Ordnung-Splatling Replik", + name: "Order Splatling Replica", + }, + { + text: "Penta-Pumper", + name: "Wellstring V", + }, + { + text: "Penta-Pumper SE", + name: "Custom Wellstring V", + }, + { + text: "Alpomar-Tri-Stringer", + name: "Inkline Tri-Stringer", + }, + { + text: "Prisma-Tri-Stringer", + name: "Bulbz Tri-Stringer", + }, + { + text: "Ordnung-Stringer Replik", + name: "Order Stringer Replica", + }, + { + text: "LACT-450", + name: "REEF-LUX 450", + }, + { + text: "LACT-450 Deko", + name: "REEF-LUX 450 Deco", + }, + { + text: "LACT-450 Q-MLCH", + name: "REEF-LUX 450 MIL-K", + }, + { + text: "Sprungboje", + name: "Squid Beakon", + }, + { + text: "Curling-Bombe", + name: "Curling Bomb", + }, + { + text: "Sprudel-Bombe", + name: "Fizzy Bomb", + }, + { + text: "Insta-Bombe", + name: "Burst Bomb", + }, + { + text: "Robo-Bombe", + name: "Autobomb", + }, + { + text: "Klecks-Bombe", + name: "Splat Bomb", + }, + { + text: "Haftbombe", + name: "Suction Bomb", + }, + { + text: "Winkelmarker", + name: "Angle Shooter", + }, + { + text: "Detektor", + name: "Point Sensor", + }, + { + text: "Sepitox-Nebel", + name: "Toxic Mist", + }, + { + text: "Tintenwall", + name: "Splash Wall", + }, + { + text: "Tintenmine", + name: "Ink Mine", + }, + { + text: "Tintegrator", + name: "Ink Vac", + }, + { + text: "Tintentyrann", + name: "Kraken Royale", + }, + { + text: "Krabbenpanzer", + name: "Crab Tank", + }, + { + text: "Unsichtbarriere", + name: "Splattercolor Screen", + }, + { + text: "Tranktank", + name: "Tacticooler", + }, + { + text: "Bluffbomber", + name: "Super Chump", + }, + { + text: "Kugelschild Pro", + name: "Big Bubbler", + }, + { + text: "Tintenschauer", + name: "Ink Storm", + }, + { + text: "Tintendüser", + name: "Inkjet", + }, + { + text: "Heulboje 5.1", + name: "Killer Wail 5.1", + }, + { + text: "Schwarmraketen", + name: "Tenta Missiles", + }, + { + text: "Cool-Kugel", + name: "Booyah Bomb", + }, + { + text: "Tri-Tintenschock", + name: "Triple Splashdown", + }, + { + text: "Schauerwelle", + name: "Wave Breaker", + }, + { + text: "Haihammer", + name: "Reefslider", + }, + { + text: "Haftsprung", + name: "Zipcaster", + }, + { + text: "Tri-Tintferno", + name: "Triple Inkstrike", + }, + { + text: "Ultra-Stempel", + name: "Ultra Stamp", + }, + ], + EUes: [ + { + text: "Superdevastador", + name: "Rapid Blaster Pro", + }, + { + text: "Superdevastador DX", + name: "Rapid Blaster Pro Deco", + }, + { + text: "Superdevastador INV", + name: "Rapid Blaster Pro WNT-R", + }, + { + text: "Turbodevastador", + name: "Clash Blaster", + }, + { + text: "Turbodevastador neo", + name: "Clash Blaster Neo", + }, + { + text: "Devastador exprés", + name: "Rapid Blaster", + }, + { + text: "Devastador exprés DX", + name: "Rapid Blaster Deco", + }, + { + text: "Teledevastador", + name: "Range Blaster", + }, + { + text: "Teledevastador SP", + name: "Custom Range Blaster", + }, + { + text: "Devastador", + name: "Blaster", + }, + { + text: "Devastador SP", + name: "Custom Blaster", + }, + { + text: "Devastador LUX", + name: "Gleamz Blaster", + }, + { + text: "D-VAST 92", + name: "S-BLAST '92", + }, + { + text: "D-VAST 91", + name: "S-BLAST '91", + }, + { + text: "Ultradevastador", + name: "Luna Blaster", + }, + { + text: "Ultradevastador neo", + name: "Luna Blaster Neo", + }, + { + text: "Devastador orden (réplica)", + name: "Order Blaster Replica", + }, + { + text: "Brocha abanico", + name: "Painbrush", + }, + { + text: "Brocha abanico maestra", + name: "Painbrush Nouveau", + }, + { + text: "Brocha abanico BNC", + name: "Painbrush BRN-Z", + }, + { + text: "Pincel", + name: "Inkbrush", + }, + { + text: "Pincel maestro", + name: "Inkbrush Nouveau", + }, + { + text: "Brocha", + name: "Octobrush", + }, + { + text: "Brocha maestra", + name: "Octobrush Nouveau", + }, + { + text: "Brocha cometa", + name: "Cometz Octobrush", + }, + { + text: "Brocha orden (réplica)", + name: "Orderbrush Replica", + }, + { + text: "Tubofusil", + name: "Goo Tuber", + }, + { + text: "Tubofusil SP", + name: "Custom Goo Tuber", + }, + { + text: "Bambufusil 14-I", + name: "Bamboozler 14 Mk I", + }, + { + text: "Bambufusil 14-II", + name: "Bamboozler 14 Mk II", + }, + { + text: "Telentintador 4K", + name: "E-liter 4K Scope", + }, + { + text: "Telentintador 4K SP", + name: "Custom E-liter 4K Scope", + }, + { + text: "Entintador 4K", + name: "E-liter 4K", + }, + { + text: "Entintador 4K SP", + name: "Custom E-liter 4K", + }, + { + text: "Cargatintas con mira", + name: "Splatterscope", + }, + { + text: "Cargatintas con mira Ezkkor", + name: "Z+F Splatterscope", + }, + { + text: "Cargatintas con mira BSQ", + name: "Splatterscope CAM-O", + }, + { + text: "Cargatintas", + name: "Splat Charger", + }, + { + text: "Cargatintas Ezkkor", + name: "Z+F Splat Charger", + }, + { + text: "Cargatintas BSQ", + name: "Splat Charger CAM-O", + }, + { + text: "Cargatintas orden (réplica)", + name: "Order Charger Replica", + }, + { + text: "Cargaminas 5H", + name: "Snipewriter 5H", + }, + { + text: "Cargaminas 5B", + name: "Snipewriter 5B", + }, + { + text: "Kalarrapid α", + name: "Classic Squiffer", + }, + { + text: "Kalarrapid β", + name: "New Squiffer", + }, + { + text: "Barredora dual", + name: "Dualie Squelchers", + }, + { + text: "Barredora dual SP", + name: "Custom Dualie Squelchers", + }, + { + text: "Barredora dual rayas", + name: "Hoofz Dualie Squelchers", + }, + { + text: "Fundidora 525 dual", + name: "Glooga Dualies", + }, + { + text: "Fundidora 525 dual DX", + name: "Glooga Dualies Deco", + }, + { + text: "Extintor Dual FF", + name: "Douser Dualies FF", + }, + { + text: "Extintor Dual FF SP", + name: "Custom Douser Dualies FF", + }, + { + text: "Difusor dual", + name: "Splat Dualies", + }, + { + text: "Difusor dual B", + name: "Enperry Splat Dualies", + }, + { + text: "Difusor dual chispa", + name: "Twinklez Splat Dualies", + }, + { + text: "Difusor dual orden (réplica)", + name: "Order Dualie Replicas", + }, + { + text: "Atomizador dual", + name: "Dapple Dualies", + }, + { + text: "Atomizador dual maestro", + name: "Dapple Dualies Nouveau", + }, + { + text: "Atomizador dual búho", + name: "Dapple Dualies NOC-T", + }, + { + text: "Motatrónic dual negro", + name: "Dark Tetra Dualies", + }, + { + text: "Motatrónic dual blanco", + name: "Light Tetra Dualies", + }, + { + text: "Rodillo de carbono", + name: "Carbon Roller", + }, + { + text: "Rodillo de carbono DX", + name: "Carbon Roller Deco", + }, + { + text: "Rodillo de carbono ABSL", + name: "Carbon Roller ANG-L", + }, + { + text: "Dinamorrodillo", + name: "Dynamo Roller", + }, + { + text: "Dinamorrodillo T", + name: "Gold Dynamo Roller", + }, + { + text: "Dinamorrodillo cosmos", + name: "Starz Dynamo Roller", + }, + { + text: "Rodillo versátil", + name: "Flingza Roller", + }, + { + text: "Rodillo versátil B", + name: "Foil Flingza Roller", + }, + { + text: "Rodillo básico", + name: "Splat Roller", + }, + { + text: "Rodillo básico B", + name: "Krak-On Splat Roller", + }, + { + text: "Rodillo orden (réplica)", + name: "Order Roller Replica", + }, + { + text: "Rodillo mágnum", + name: "Big Swig Roller", + }, + { + text: "Rodillo mágnum B", + name: "Big Swig Roller Express", + }, + { + text: "Rodillo mágnum SAT", + name: "Planetz Big Swig Roller", + }, + { + text: "Odontintador menta", + name: "Mint Decavitator", + }, + { + text: "Odontintador carbón", + name: "Charcoal Decavitator", + }, + { + text: "Azotintador brisa", + name: "Splatana Wiper", + }, + { + text: "Azotintador brisa DX", + name: "Splatana Wiper Deco", + }, + { + text: "Azotintador brisa óxido", + name: "Splatana Wiper RUS-T", + }, + { + text: "Azotintador pro", + name: "Splatana Stamper", + }, + { + text: "Azotintador pro maestro", + name: "Splatana Stamper Nouveau", + }, + { + text: "Azotintador pro pegatas", + name: "Stickerz Splatana Stamper", + }, + { + text: "Azotintador orden (réplica)", + name: "Order Splatana Replica", + }, + { + text: "Paratintas clásico", + name: "Undercover Brella", + }, + { + text: "Paratintas clásico B", + name: "Undercover Sorella Brella", + }, + { + text: "Paratintas clásico MIX", + name: "Patternz Undercover Brella", + }, + { + text: "Prototintas 24-I", + name: "Recycled Brella 24 Mk I", + }, + { + text: "Prototintas 24-II", + name: "Recycled Brella 24 Mk II", + }, + { + text: "Paratintas", + name: "Splat Brella", + }, + { + text: "Paratintas B", + name: "Sorella Brella", + }, + { + text: "Paratintas orden (réplica)", + name: "Order Brella Replica", + }, + { + text: "Paratintas maxi", + name: "Tenta Brella", + }, + { + text: "Paratintas maxi B", + name: "Tenta Sorella Brella", + }, + { + text: "Paratintas maxi crema", + name: "Tenta Brella CRE-M", + }, + { + text: "Aerógrafo pro", + name: "Aerospray MG", + }, + { + text: "Aerógrafo plus", + name: "Aerospray RG", + }, + { + text: "Aerógrafo arcoíris", + name: "Colorz Aerospray", + }, + { + text: "Lanzatintas plus", + name: "Splattershot Pro", + }, + { + text: "Lanzatintas plus B", + name: "Forge Splattershot Pro", + }, + { + text: "Lanzatintas plus gélido", + name: "Splattershot Pro FRZ-N", + }, + { + text: "Lanzatintas novato", + name: "Splattershot Jr.", + }, + { + text: "Lanzatintas novato B", + name: "Custom Splattershot Jr.", + }, + { + text: "Tintopresor", + name: "Squeezer", + }, + { + text: "Tintopresor B", + name: "Foil Squeezer", + }, + { + text: "Salpicadora 2000", + name: ".52 Gal", + }, + { + text: "Salpicadora 2000 DX", + name: ".52 Gal Deco", + }, + { + text: "Salpicadora 3000", + name: ".96 Gal", + }, + { + text: "Salpicadora 3000 DX", + name: ".96 Gal Deco", + }, + { + text: "Salpicadora 3000 garra", + name: "Clawz .96 Gal", + }, + { + text: "Megabarredora", + name: "Jet Squelcher", + }, + { + text: "Megabarredora SP", + name: "Custom Jet Squelcher", + }, + { + text: "Megabarredora cobra", + name: "Jet Squelcher COB-R", + }, + { + text: "Lanzatintas", + name: "Splattershot", + }, + { + text: "Lanzatintas B", + name: "Tentatek Splattershot", + }, + { + text: "Lanzatintas ZAS", + name: "Glamorz Splattershot", + }, + { + text: "Pistola de élite (réplica)", + name: "Hero Shot Replica", + }, + { + text: "Lanzatintas orden (réplica)", + name: "Order Shot Replica", + }, + { + text: "Pistola octariana (réplica)", + name: "Octo Shot Replica", + }, + { + text: "Marcador fino", + name: "Splash-o-matic", + }, + { + text: "Marcador fino neo", + name: "Neo Splash-o-matic", + }, + { + text: "Marcador fino geco", + name: "Splash-o-matic GCK-O", + }, + { + text: "Lanzatintas espacial", + name: "Splattershot Nova", + }, + { + text: "Lanzatintas espacial B", + name: "Annaki Splattershot Nova", + }, + { + text: "N-ZAP 85", + name: "N-ZAP '85", + }, + { + text: "N-ZAP 89", + name: "N-ZAP '89", + }, + { + text: "Marcador", + name: "Sploosh-o-matic", + }, + { + text: "Marcador neo", + name: "Neo Sploosh-o-matic", + }, + { + text: "Tintambor pesado", + name: "H-3 Nozzlenose", + }, + { + text: "Tintambor pesado D", + name: "H-3 Nozzlenose D", + }, + { + text: "Tintambor pesado áspid", + name: "H-3 Nozzlenose VIP-R", + }, + { + text: "Tintambor ligero", + name: "L-3 Nozzlenose", + }, + { + text: "Tintambor ligero D", + name: "L-3 Nozzlenose D", + }, + { + text: "Tintambor ligero chapa", + name: "Glitterz L-3 Nozzlenose", + }, + { + text: "Derramatic baño", + name: "Bloblobber", + }, + { + text: "Derramatic baño DX", + name: "Bloblobber Deco", + }, + { + text: "Derramatic triple", + name: "Tri-Slosher", + }, + { + text: "Derramatic triple maestro", + name: "Tri-Slosher Nouveau", + }, + { + text: "Derramatic triple ceniza", + name: "Tri-Slosher ASH-N", + }, + { + text: "Friegamatic", + name: "Dread Wringer", + }, + { + text: "Friegamatic D", + name: "Dread Wringer D", + }, + { + text: "Friegamatic asta", + name: "Hornz Dread Wringer", + }, + { + text: "Derramatic centrífugo", + name: "Sloshing Machine", + }, + { + text: "Derramatic centrífugo neo", + name: "Sloshing Machine Neo", + }, + { + text: "Derramatic", + name: "Slosher", + }, + { + text: "Derramatic DX", + name: "Slosher Deco", + }, + { + text: "Derramatic orden (réplica)", + name: "Order Slosher Replica", + }, + { + text: "Derramatic turbo", + name: "Explosher", + }, + { + text: "Derramatic turbo SP", + name: "Custom Explosher", + }, + { + text: "Ametrallógrafo", + name: "Ballpoint Splatling", + }, + { + text: "Ametrallógrafo maestro", + name: "Ballpoint Splatling Nouveau", + }, + { + text: "Corrigetintas", + name: "Heavy Edit Splatling", + }, + { + text: "Corrigetintas maestro", + name: "Heavy Edit Splatling Nouveau", + }, + { + text: "Extintador", + name: "Hydra Splatling", + }, + { + text: "Extintador SP", + name: "Custom Hydra Splatling", + }, + { + text: "Extintador presión", + name: "Torrentz Hydra Splatling", + }, + { + text: "Tintralladora ligera", + name: "Mini Splatling", + }, + { + text: "Tintralladora ligera B", + name: "Zink Mini Splatling", + }, + { + text: "Tintralladora ligera pitón", + name: "Mini Splatling RTL-R", + }, + { + text: "Subtralladora 47", + name: "Nautilus 47", + }, + { + text: "Subtralladora 79", + name: "Nautilus 79", + }, + { + text: "Tintralladora", + name: "Heavy Splatling", + }, + { + text: "Tintralladora DX", + name: "Heavy Splatling Deco", + }, + { + text: "Tintralladora orden (réplica)", + name: "Order Splatling Replica", + }, + { + text: "Arcrotensor V", + name: "Wellstring V", + }, + { + text: "Arcrotensor V SP", + name: "Custom Wellstring V", + }, + { + text: "Arcromatizador triple", + name: "Tri-Stringer", + }, + { + text: "Arcromatizador triple B", + name: "Inkline Tri-Stringer", + }, + { + text: "Arcromatizador triple LUZ", + name: "Bulbz Tri-Stringer", + }, + { + text: "Arcromatizador orden (réplica)", + name: "Order Stringer Replica", + }, + { + text: "AR-CR0 450", + name: "REEF-LUX 450", + }, + { + text: "AR-CR0 450 DX", + name: "REEF-LUX 450 Deco", + }, + { + text: "AR-CR0 450 lácteo", + name: "REEF-LUX 450 MIL-K", + }, + { + text: "Baliza transportadora", + name: "Squid Beakon", + }, + { + text: "Bomba deslizante", + name: "Curling Bomb", + }, + { + text: "Bomba carbónica", + name: "Fizzy Bomb", + }, + { + text: "Bomba rápida", + name: "Burst Bomb", + }, + { + text: "Robobomba", + name: "Autobomb", + }, + { + text: "Bomba básica", + name: "Splat Bomb", + }, + { + text: "Bomba ventosa", + name: "Suction Bomb", + }, + { + text: "Tiralíneas", + name: "Angle Shooter", + }, + { + text: "Rastreador", + name: "Point Sensor", + }, + { + text: "Nebulizador", + name: "Toxic Mist", + }, + { + text: "Telón de tinta", + name: "Splash Wall", + }, + { + text: "Aspersor", + name: "Sprinkler", + }, + { + text: "Bomba trampa", + name: "Ink Mine", + }, + { + text: "Aspiratinta", + name: "Ink Vac", + }, + { + text: "Calamar imperial", + name: "Kraken Royale", + }, + { + text: "Cangrejobot", + name: "Crab Tank", + }, + { + text: "Pantintalla", + name: "Splattercolor Screen", + }, + { + text: "Dispensabebidas", + name: "Tacticooler", + }, + { + text: "Multiseñuelos", + name: "Super Chump", + }, + { + text: "Megaburbuja", + name: "Big Bubbler", + }, + { + text: "Atormentador", + name: "Ink Storm", + }, + { + text: "Propulsor", + name: "Inkjet", + }, + { + text: "Tintófono 5.1", + name: "Killer Wail 5.1", + }, + { + text: "Lanzamisiles", + name: "Tenta Missiles", + }, + { + text: "Bola genializante", + name: "Booyah Bomb", + }, + { + text: "Puñetazos explosivos", + name: "Triple Splashdown", + }, + { + text: "Emiteondas", + name: "Wave Breaker", + }, + { + text: "Flotiburón", + name: "Reefslider", + }, + { + text: "Gancho tentacular", + name: "Zipcaster", + }, + { + text: "Tornado triple", + name: "Triple Inkstrike", + }, + { + text: "Tintazuca triple", + name: "Trizooka", + }, + { + text: "Ultraselladora", + name: "Ultra Stamp", + }, + ], + EUfr: [ + { + text: "Turboblaster pro", + name: "Rapid Blaster Pro", + }, + { + text: "Turboblaster pro chic", + name: "Rapid Blaster Pro Deco", + }, + { + text: "Turboblaster pro POL-R", + name: "Rapid Blaster Pro WNT-R", + }, + { + text: "Rafablaster", + name: "Clash Blaster", + }, + { + text: "Rafablaster Néo", + name: "Clash Blaster Neo", + }, + { + text: "Turboblaster", + name: "Rapid Blaster", + }, + { + text: "Turboblaster chic", + name: "Rapid Blaster Deco", + }, + { + text: "Éclablaster XL", + name: "Range Blaster", + }, + { + text: "Éclablaster XL griffé", + name: "Custom Range Blaster", + }, + { + text: "Éclablaster", + name: "Blaster", + }, + { + text: "Éclablaster modifié", + name: "Custom Blaster", + }, + { + text: "Éclablaster mirapex", + name: "Gleamz Blaster", + }, + { + text: "S-Blaster '92", + name: "S-BLAST '92", + }, + { + text: "S-Blaster '91", + name: "S-BLAST '91", + }, + { + text: "Proxiblaster", + name: "Luna Blaster", + }, + { + text: "Proxiblaster Néo", + name: "Luna Blaster Neo", + }, + { + text: "Blaster d'Ordre (réplique)", + name: "Order Blaster Replica", + }, + { + text: "Épinceau écaïe", + name: "Painbrush", + }, + { + text: "Épinceau écaïe nuancé", + name: "Painbrush Nouveau", + }, + { + text: "Épinceau écaïe BRN-Z", + name: "Painbrush BRN-Z", + }, + { + text: "Épinceau", + name: "Inkbrush", + }, + { + text: "Épinceau nuancé", + name: "Inkbrush Nouveau", + }, + { + text: "Épinceau brosse", + name: "Octobrush", + }, + { + text: "Épinceau brosse nuancé", + name: "Octobrush Nouveau", + }, + { + text: "Épinceau brosse stellapex", + name: "Cometz Octobrush", + }, + { + text: "Pinceau d'Ordre (réplique)", + name: "Orderbrush Replica", + }, + { + text: "Détubeur", + name: "Goo Tuber", + }, + { + text: "Détubeur modifié", + name: "Custom Goo Tuber", + }, + { + text: "Bimbamboum Mk I", + name: "Bamboozler 14 Mk I", + }, + { + text: "Bimbamboum Mk II", + name: "Bamboozler 14 Mk II", + }, + { + text: "Extraceur + zoom", + name: "E-liter 4K Scope", + }, + { + text: "Extraceur + zoom modifié", + name: "Custom E-liter 4K Scope", + }, + { + text: "Extraceur +", + name: "E-liter 4K", + }, + { + text: "Extraceur + modifié", + name: "Custom E-liter 4K", + }, + { + text: "Concentraceur zoom", + name: "Splatterscope", + }, + { + text: "Concentraceur zoom E+F", + name: "Z+F Splatterscope", + }, + { + text: "Concentraceur zoom CAM-O", + name: "Splatterscope CAM-O", + }, + { + text: "Concentraceur", + name: "Splat Charger", + }, + { + text: "Concentraceur E+F", + name: "Z+F Splat Charger", + }, + { + text: "Concentraceur CAM-O", + name: "Splat Charger CAM-O", + }, + { + text: "Fusil d'Ordre (réplique)", + name: "Order Charger Replica", + }, + { + text: "Crayon X - 5H", + name: "Snipewriter 5H", + }, + { + text: "Crayon X - 5B", + name: "Snipewriter 5B", + }, + { + text: "Décap'express Alpha", + name: "Classic Squiffer", + }, + { + text: "Décap'express Bêta", + name: "New Squiffer", + }, + { + text: "Double nettoyeur", + name: "Dualie Squelchers", + }, + { + text: "Double nettoyeur modifié", + name: "Custom Dualie Squelchers", + }, + { + text: "Double nettoyeur zébrapex", + name: "Hoofz Dualie Squelchers", + }, + { + text: "Double Kelvin 525", + name: "Glooga Dualies", + }, + { + text: "Double Kelvin 525 chic", + name: "Glooga Dualies Deco", + }, + { + text: "Double ignifugeur", + name: "Douser Dualies FF", + }, + { + text: "Double ignifugeur modifié", + name: "Custom Douser Dualies FF", + }, + { + text: "Double encreur", + name: "Splat Dualies", + }, + { + text: "Double encreur griffé", + name: "Enperry Splat Dualies", + }, + { + text: "Double encreur flambapex", + name: "Twinklez Splat Dualies", + }, + { + text: "Arme double d'Ordre (réplique)", + name: "Order Dualie Replicas", + }, + { + text: "Double moucheteur", + name: "Dapple Dualies", + }, + { + text: "Double moucheteur nuancé", + name: "Dapple Dualies Nouveau", + }, + { + text: "Double moucheteur NOC-T", + name: "Dapple Dualies NOC-T", + }, + { + text: "Double voltigeur noir", + name: "Dark Tetra Dualies", + }, + { + text: "Double voltigeur blanc", + name: "Light Tetra Dualies", + }, + { + text: "Rouleau carbone", + name: "Carbon Roller", + }, + { + text: "Rouleau carbone chic", + name: "Carbon Roller Deco", + }, + { + text: "Rouleau carbone BOD-RWA", + name: "Carbon Roller ANG-L", + }, + { + text: "Dynamo-rouleau", + name: "Dynamo Roller", + }, + { + text: "Dynamo-rouleau tesla", + name: "Gold Dynamo Roller", + }, + { + text: "Dynamo-rouleau galapex", + name: "Starz Dynamo Roller", + }, + { + text: "Flexi-rouleau", + name: "Flingza Roller", + }, + { + text: "Flexi-rouleau alu", + name: "Foil Flingza Roller", + }, + { + text: "Rouleau", + name: "Splat Roller", + }, + { + text: "Rouleau griffé", + name: "Krak-On Splat Roller", + }, + { + text: "Rouleau d'Ordre (réplique)", + name: "Order Roller Replica", + }, + { + text: "Rouleau large", + name: "Big Swig Roller", + }, + { + text: "Rouleau large Xpress", + name: "Big Swig Roller Express", + }, + { + text: "Rouleau large astrapex", + name: "Planetz Big Swig Roller", + }, + { + text: "Sabrifrice menthe", + name: "Mint Decavitator", + }, + { + text: "Sabrifrice charbon", + name: "Charcoal Decavitator", + }, + { + text: "Éclatana Doto", + name: "Splatana Wiper", + }, + { + text: "Éclatana Doto chic", + name: "Splatana Wiper Deco", + }, + { + text: "Éclatana Doto OXY-D", + name: "Splatana Wiper RUS-T", + }, + { + text: "Éclatana d'estampe", + name: "Splatana Stamper", + }, + { + text: "Éclatana d'estampe nuancé", + name: "Splatana Stamper Nouveau", + }, + { + text: "Éclatana d'estampe décapex", + name: "Stickerz Splatana Stamper", + }, + { + text: "Éclatana d'Ordre (réplique)", + name: "Order Splatana Replica", + }, + { + text: "Para-encre espion", + name: "Undercover Brella", + }, + { + text: "Para-encre espion Sorella", + name: "Undercover Sorella Brella", + }, + { + text: "Para-encre espion amalgapex", + name: "Patternz Undercover Brella", + }, + { + text: "Para-encre Ré-gen 24 Mk I", + name: "Recycled Brella 24 Mk I", + }, + { + text: "Para-encre Ré-gen 24 Mk II", + name: "Recycled Brella 24 Mk II", + }, + { + text: "Para-encre", + name: "Splat Brella", + }, + { + text: "Para-encre Sorella", + name: "Sorella Brella", + }, + { + text: "Para-encre d'Ordre (réplique)", + name: "Order Brella Replica", + }, + { + text: "Para-encre XL", + name: "Tenta Brella", + }, + { + text: "Para-encre XL Sorella", + name: "Tenta Sorella Brella", + }, + { + text: "Para-encre XL CRM-A", + name: "Tenta Brella CRE-M", + }, + { + text: "Aérogun", + name: "Aerospray MG", + }, + { + text: "Aérogun premium", + name: "Aerospray RG", + }, + { + text: "Aérogun chromapex", + name: "Colorz Aerospray", + }, + { + text: "Liquidateur pro", + name: "Splattershot Pro", + }, + { + text: "Liquidateur pro griffé", + name: "Forge Splattershot Pro", + }, + { + text: "Liquidateur pro GLA-C", + name: "Splattershot Pro FRZ-N", + }, + { + text: "Liquidateur Jr.", + name: "Splattershot Jr.", + }, + { + text: "Liquidateur Sr.", + name: "Custom Splattershot Jr.", + }, + { + text: "Compresseur", + name: "Squeezer", + }, + { + text: "Compresseur alu", + name: "Foil Squeezer", + }, + { + text: "Calibre 2000", + name: ".52 Gal", + }, + { + text: "Calibre 2000 chic", + name: ".52 Gal Deco", + }, + { + text: "Calibre 3000", + name: ".96 Gal", + }, + { + text: "Calibre 3000 chic", + name: ".96 Gal Deco", + }, + { + text: "Calibre 3000 guépapex", + name: "Clawz .96 Gal", + }, + { + text: "Nettoyeur XL", + name: "Jet Squelcher", + }, + { + text: "Nettoyeur XL modifié", + name: "Custom Jet Squelcher", + }, + { + text: "Nettoyeur XL COB-R", + name: "Jet Squelcher COB-R", + }, + { + text: "Liquidateur", + name: "Splattershot", + }, + { + text: "Liquidateur griffé", + name: "Tentatek Splattershot", + }, + { + text: "Liquidateur glamapex", + name: "Glamorz Splattershot", + }, + { + text: "Lanceur héroïque (réplique)", + name: "Hero Shot Replica", + }, + { + text: "Lanceur d'Ordre (réplique)", + name: "Order Shot Replica", + }, + { + text: "Lanceur Octaling (réplique)", + name: "Octo Shot Replica", + }, + { + text: "Marqueur léger", + name: "Splash-o-matic", + }, + { + text: "Marqueur léger Néo", + name: "Neo Splash-o-matic", + }, + { + text: "Marqueur léger G-KO", + name: "Splash-o-matic GCK-O", + }, + { + text: "Lanceur spatial", + name: "Splattershot Nova", + }, + { + text: "Lanceur spatial Aroz", + name: "Annaki Splattershot Nova", + }, + { + text: "N-ZAP 85", + name: "N-ZAP '85", + }, + { + text: "N-ZAP 89", + name: "N-ZAP '89", + }, + { + text: "Marqueur lourd", + name: "Sploosh-o-matic", + }, + { + text: "Marqueur lourd Néo", + name: "Neo Sploosh-o-matic", + }, + { + text: "Arroseur lourd", + name: "H-3 Nozzlenose", + }, + { + text: "Arroseur lourd Cétacé", + name: "H-3 Nozzlenose D", + }, + { + text: "Arroseur lourd VIP-R", + name: "H-3 Nozzlenose VIP-R", + }, + { + text: "Arroseur léger", + name: "L-3 Nozzlenose", + }, + { + text: "Arroseur léger Cétacé", + name: "L-3 Nozzlenose D", + }, + { + text: "Arroseur léger éclapex", + name: "Glitterz L-3 Nozzlenose", + }, + { + text: "Bassineur", + name: "Bloblobber", + }, + { + text: "Bassineur chic", + name: "Bloblobber Deco", + }, + { + text: "Dépoteur", + name: "Tri-Slosher", + }, + { + text: "Dépoteur nuancé", + name: "Tri-Slosher Nouveau", + }, + { + text: "Dépoteur 100-DRÉ", + name: "Tri-Slosher ASH-N", + }, + { + text: "Seau S-Horreur", + name: "Dread Wringer", + }, + { + text: "Seau S-Horreur Cétacé", + name: "Dread Wringer D", + }, + { + text: "Seau S-Horreur kérapex", + name: "Hornz Dread Wringer", + }, + { + text: "Encrifugeur", + name: "Sloshing Machine", + }, + { + text: "Encrifugeur Néo", + name: "Sloshing Machine Neo", + }, + { + text: "Seauceur", + name: "Slosher", + }, + { + text: "Seauceur chic", + name: "Slosher Deco", + }, + { + text: "Seau d'Ordre (réplique)", + name: "Order Slosher Replica", + }, + { + text: "Détoneur", + name: "Explosher", + }, + { + text: "Détoneur modifié", + name: "Custom Explosher", + }, + { + text: "Badigeonneur stylo", + name: "Ballpoint Splatling", + }, + { + text: "Badigeonneur stylo nuancé", + name: "Ballpoint Splatling Nouveau", + }, + { + text: "Correcteur", + name: "Heavy Edit Splatling", + }, + { + text: "Correcteur nuancé", + name: "Heavy Edit Splatling Nouveau", + }, + { + text: "Exteinteur", + name: "Hydra Splatling", + }, + { + text: "Exteinteur modifié", + name: "Custom Hydra Splatling", + }, + { + text: "Exteinteur cascapex", + name: "Torrentz Hydra Splatling", + }, + { + text: "Badigeonneur XS", + name: "Mini Splatling", + }, + { + text: "Badigeonneur XS griffé", + name: "Zink Mini Splatling", + }, + { + text: "Badigeonneur XS VN-1", + name: "Mini Splatling RTL-R", + }, + { + text: "Badigeonneur", + name: "Heavy Splatling", + }, + { + text: "Badigeonneur chic", + name: "Heavy Splatling Deco", + }, + { + text: "Badigeonneur d'Ordre (réplique)", + name: "Order Splatling Replica", + }, + { + text: "Multisperceur V", + name: "Wellstring V", + }, + { + text: "Multisperceur V modifié", + name: "Custom Wellstring V", + }, + { + text: "Trisperceur", + name: "Tri-Stringer", + }, + { + text: "Trisperceur Abysma", + name: "Inkline Tri-Stringer", + }, + { + text: "Trisperceur prismapex", + name: "Bulbz Tri-Stringer", + }, + { + text: "Transperceur d'Ordre (réplique)", + name: "Order Stringer Replica", + }, + { + text: "Coralux 450", + name: "REEF-LUX 450", + }, + { + text: "Coralux 450 chic", + name: "REEF-LUX 450 Deco", + }, + { + text: "Coralux 450 LACTO", + name: "REEF-LUX 450 MIL-K", + }, + { + text: "Balise de saut", + name: "Squid Beakon", + }, + { + text: "Bombe curling", + name: "Curling Bomb", + }, + { + text: "Bombe soda", + name: "Fizzy Bomb", + }, + { + text: "Bombe ballon", + name: "Burst Bomb", + }, + { + text: "Bombe robot", + name: "Autobomb", + }, + { + text: "Bombe splash", + name: "Splat Bomb", + }, + { + text: "Bombe gluante", + name: "Suction Bomb", + }, + { + text: "Bentorpille", + name: "Torpedo", + }, + { + text: "Ricocheur", + name: "Angle Shooter", + }, + { + text: "Détecteur", + name: "Point Sensor", + }, + { + text: "Brume toxique", + name: "Toxic Mist", + }, + { + text: "Mur d'encre", + name: "Splash Wall", + }, + { + text: "Fontaine", + name: "Sprinkler", + }, + { + text: "Mine", + name: "Ink Mine", + }, + { + text: "Aspirencre", + name: "Ink Vac", + }, + { + text: "Kraken royal", + name: "Kraken Royale", + }, + { + text: "Crabe d'assaut", + name: "Crab Tank", + }, + { + text: "Barrière barbouillée", + name: "Splattercolor Screen", + }, + { + text: "Districool", + name: "Tacticooler", + }, + { + text: "Multi-leurres", + name: "Super Chump", + }, + { + text: "Super bouclier", + name: "Big Bubbler", + }, + { + text: "Pluie d'encre", + name: "Ink Storm", + }, + { + text: "Chromo-jet", + name: "Inkjet", + }, + { + text: "Haut-perceur 5.1", + name: "Killer Wail 5.1", + }, + { + text: "Multi-missile", + name: "Tenta Missiles", + }, + { + text: "Jolizator", + name: "Booyah Bomb", + }, + { + text: "Triple choc chromatique", + name: "Triple Splashdown", + }, + { + text: "Sonar paf", + name: "Wave Breaker", + }, + { + text: "Cavalsquale", + name: "Reefslider", + }, + { + text: "Super Mollusque", + name: "Zipcaster", + }, + { + text: "Trimissile tornade", + name: "Triple Inkstrike", + }, + { + text: "Lance-rafales", + name: "Trizooka", + }, + { + text: "Ultra-tamponneur", + name: "Ultra Stamp", + }, + ], + EUit: [ + { + text: "Blaster élite", + name: "Rapid Blaster Pro", + }, + { + text: "Blaster élite DX", + name: "Rapid Blaster Pro Deco", + }, + { + text: "Blaster élite INV-N", + name: "Rapid Blaster Pro WNT-R", + }, + { + text: "Blaster da mischia", + name: "Clash Blaster", + }, + { + text: "Blaster da mischia Neo", + name: "Clash Blaster Neo", + }, + { + text: "Blaster rapido", + name: "Rapid Blaster", + }, + { + text: "Blaster rapido DX", + name: "Rapid Blaster Deco", + }, + { + text: "Blaster a distanza", + name: "Range Blaster", + }, + { + text: "Blaster a distanza CM", + name: "Custom Range Blaster", + }, + { + text: "Blaster CM", + name: "Custom Blaster", + }, + { + text: "Blaster glam-bon", + name: "Gleamz Blaster", + }, + { + text: "S-BLAST92", + name: "S-BLAST '92", + }, + { + text: "S-BLAST91", + name: "S-BLAST '91", + }, + { + text: "Blaster lunar", + name: "Luna Blaster", + }, + { + text: "Blaster lunar Neo", + name: "Luna Blaster Neo", + }, + { + text: "Blaster Ordine replica", + name: "Order Blaster Replica", + }, + { + text: "Tonnoretto", + name: "Painbrush", + }, + { + text: "Tonnoretto master", + name: "Painbrush Nouveau", + }, + { + text: "Tonnoretto BRN-Z", + name: "Painbrush BRN-Z", + }, + { + text: "Calamaravaggio", + name: "Inkbrush", + }, + { + text: "Calamaravaggio master", + name: "Inkbrush Nouveau", + }, + { + text: "Tinturicchio", + name: "Octobrush", + }, + { + text: "Tinturicchio master", + name: "Octobrush Nouveau", + }, + { + text: "Tinturicchio comet-bon", + name: "Cometz Octobrush", + }, + { + text: "Pennello Ordine replica", + name: "Orderbrush Replica", + }, + { + text: "Tintubator", + name: "Goo Tuber", + }, + { + text: "Tintubator CM", + name: "Custom Goo Tuber", + }, + { + text: "Bimbamboom 14-I", + name: "Bamboozler 14 Mk I", + }, + { + text: "Bimbamboom 14-II", + name: "Bamboozler 14 Mk II", + }, + { + text: "Tinter con mirino 4K", + name: "E-liter 4K Scope", + }, + { + text: "Tinter con mirino 4K CM", + name: "Custom E-liter 4K Scope", + }, + { + text: "Tinter a carica 4K", + name: "E-liter 4K", + }, + { + text: "Tinter a carica 4K CM", + name: "Custom E-liter 4K", + }, + { + text: "Splatter con mirino", + name: "Splatterscope", + }, + { + text: "Splatter con mirino logo", + name: "Z+F Splatterscope", + }, + { + text: "Splatter con mirino FRS-T", + name: "Splatterscope CAM-O", + }, + { + text: "Splatter a carica", + name: "Splat Charger", + }, + { + text: "Splatter a carica logo", + name: "Z+F Splat Charger", + }, + { + text: "Splatter a carica FRS-T", + name: "Splat Charger CAM-O", + }, + { + text: "Splatter Ordine replica", + name: "Order Charger Replica", + }, + { + text: "Lapis cecchino 5H", + name: "Snipewriter 5H", + }, + { + text: "Lapis cecchino 5B", + name: "Snipewriter 5B", + }, + { + text: "Kalamarapid α", + name: "Classic Squiffer", + }, + { + text: "Kalamarapid β", + name: "New Squiffer", + }, + { + text: "Sweeper duplo", + name: "Dualie Squelchers", + }, + { + text: "Sweeper duplo CM", + name: "Custom Dualie Squelchers", + }, + { + text: "Sweeper duplo zebra-bon", + name: "Hoofz Dualie Squelchers", + }, + { + text: "Kelvin duplo 525", + name: "Glooga Dualies", + }, + { + text: "Kelvin duplo 525 DX", + name: "Glooga Dualies Deco", + }, + { + text: "Repolper estintori FF", + name: "Douser Dualies FF", + }, + { + text: "Repolper estintori FF CM", + name: "Custom Douser Dualies FF", + }, + { + text: "Repolper duplo", + name: "Splat Dualies", + }, + { + text: "Repolper duplo logo", + name: "Enperry Splat Dualies", + }, + { + text: "Repolper duplo metal-bon", + name: "Twinklez Splat Dualies", + }, + { + text: "Repolper Ordine replica", + name: "Order Dualie Replicas", + }, + { + text: "Ripolpella duplo", + name: "Dapple Dualies", + }, + { + text: "Ripolpella duplo master", + name: "Dapple Dualies Nouveau", + }, + { + text: "Ripolpella duplo NOT-T", + name: "Dapple Dualies NOC-T", + }, + { + text: "Moscarpino duplo nero", + name: "Dark Tetra Dualies", + }, + { + text: "Moscarpino duplo bianco", + name: "Light Tetra Dualies", + }, + { + text: "Rullo di carbonio", + name: "Carbon Roller", + }, + { + text: "Rullo di carbonio DX", + name: "Carbon Roller Deco", + }, + { + text: "Rullo di carbonio PSC-AB", + name: "Carbon Roller ANG-L", + }, + { + text: "Rullo dinamo", + name: "Dynamo Roller", + }, + { + text: "Rullo dinamo dorato", + name: "Gold Dynamo Roller", + }, + { + text: "Rullo dinamo galax-bon", + name: "Starz Dynamo Roller", + }, + { + text: "Flexi-rullo", + name: "Flingza Roller", + }, + { + text: "Flexi-rullo B", + name: "Foil Flingza Roller", + }, + { + text: "Rullo splat", + name: "Splat Roller", + }, + { + text: "Rullo splat logo", + name: "Krak-On Splat Roller", + }, + { + text: "Rullo Ordine replica", + name: "Order Roller Replica", + }, + { + text: "Ampiorullo", + name: "Big Swig Roller", + }, + { + text: "Ampiorullo Express", + name: "Big Swig Roller Express", + }, + { + text: "Ampiorullo astra-bon", + name: "Planetz Big Swig Roller", + }, + { + text: "Spazzolama menta", + name: "Mint Decavitator", + }, + { + text: "Spazzolama carbone", + name: "Charcoal Decavitator", + }, + { + text: "Tergilama", + name: "Splatana Wiper", + }, + { + text: "Tergilama DX", + name: "Splatana Wiper Deco", + }, + { + text: "Tergilama RUG-N", + name: "Splatana Wiper RUS-T", + }, + { + text: "Splattalama", + name: "Splatana Stamper", + }, + { + text: "Splattalama master", + name: "Splatana Stamper Nouveau", + }, + { + text: "Splattalama adesi-bon", + name: "Stickerz Splatana Stamper", + }, + { + text: "Lama Ordine replica", + name: "Order Splatana Replica", + }, + { + text: "Bombrello", + name: "Undercover Brella", + }, + { + text: "Bombrello Solerra", + name: "Undercover Sorella Brella", + }, + { + text: "Bombrello sgargia-bon", + name: "Patternz Undercover Brella", + }, + { + text: "Sparasole riciclo 24-I", + name: "Recycled Brella 24 Mk I", + }, + { + text: "Sparasole riciclo 24-II", + name: "Recycled Brella 24 Mk II", + }, + { + text: "Sparasole", + name: "Splat Brella", + }, + { + text: "Sparasole Solerra", + name: "Sorella Brella", + }, + { + text: "Sparasole Ordine replica", + name: "Order Brella Replica", + }, + { + text: "Sparatenda", + name: "Tenta Brella", + }, + { + text: "Sparatenda Solerra", + name: "Tenta Sorella Brella", + }, + { + text: "Sparatenda CRM-A", + name: "Tenta Brella CRE-M", + }, + { + text: "Aerografo", + name: "Aerospray MG", + }, + { + text: "Aerografo élite", + name: "Aerospray RG", + }, + { + text: "Aerografo color-bon", + name: "Colorz Aerospray", + }, + { + text: "Splasher élite", + name: "Splattershot Pro", + }, + { + text: "Splasher élite logo", + name: "Forge Splattershot Pro", + }, + { + text: "Splasher élite GLA-C", + name: "Splattershot Pro FRZ-N", + }, + { + text: "Sparacolore recluta", + name: "Splattershot Jr.", + }, + { + text: "Sparacolore logo", + name: "Custom Splattershot Jr.", + }, + { + text: "Strizzer", + name: "Squeezer", + }, + { + text: "Strizzer B", + name: "Foil Squeezer", + }, + { + text: "Calibro 2000", + name: ".52 Gal", + }, + { + text: "Calibro 2000 DX", + name: ".52 Gal Deco", + }, + { + text: "Calibro 3000", + name: ".96 Gal", + }, + { + text: "Calibro 3000 DX", + name: ".96 Gal Deco", + }, + { + text: "Calibro 3000 leo-bon", + name: "Clawz .96 Gal", + }, + { + text: "Sweeper", + name: "Jet Squelcher", + }, + { + text: "Sweeper CM", + name: "Custom Jet Squelcher", + }, + { + text: "Sweeper COB-R", + name: "Jet Squelcher COB-R", + }, + { + text: "Splasher", + name: "Splattershot", + }, + { + text: "Splasher logo", + name: "Tentatek Splattershot", + }, + { + text: "Splasher iride-bon", + name: "Glamorz Splattershot", + }, + { + text: "Pistola élite replica", + name: "Hero Shot Replica", + }, + { + text: "Pistola Ordine replica", + name: "Order Shot Replica", + }, + { + text: "Octosplasher replica", + name: "Octo Shot Replica", + }, + { + text: "Marker d'assalto", + name: "Splash-o-matic", + }, + { + text: "Marker d'assalto Neo", + name: "Neo Splash-o-matic", + }, + { + text: "Marker d'assalto GEC-O", + name: "Splash-o-matic GCK-O", + }, + { + text: "Splasher Nova", + name: "Splattershot Nova", + }, + { + text: "Splasher Nova Annak", + name: "Annaki Splattershot Nova", + }, + { + text: "N-ZAP85", + name: "N-ZAP '85", + }, + { + text: "N-ZAP89", + name: "N-ZAP '89", + }, + { + text: "Marker", + name: "Sploosh-o-matic", + }, + { + text: "Marker Neo", + name: "Neo Sploosh-o-matic", + }, + { + text: "Triplete", + name: "H-3 Nozzlenose", + }, + { + text: "Triplete D", + name: "H-3 Nozzlenose D", + }, + { + text: "Triplete VIP-R", + name: "H-3 Nozzlenose VIP-R", + }, + { + text: "Triplete compatto", + name: "L-3 Nozzlenose", + }, + { + text: "Triplete compatto D", + name: "L-3 Nozzlenose D", + }, + { + text: "Triplete compatto lucci-bon", + name: "Glitterz L-3 Nozzlenose", + }, + { + text: "Secchiostro vasca", + name: "Bloblobber", + }, + { + text: "Secchiostro vasca DX", + name: "Bloblobber Deco", + }, + { + text: "Secchiostro triplo", + name: "Tri-Slosher", + }, + { + text: "Secchiostro triplo master", + name: "Tri-Slosher Nouveau", + }, + { + text: "Secchiostro triplo POL-V", + name: "Tri-Slosher ASH-N", + }, + { + text: "Secchiapavimenti", + name: "Dread Wringer", + }, + { + text: "Secchiapavimenti D", + name: "Dread Wringer D", + }, + { + text: "Secchiapavimenti acu-bon", + name: "Hornz Dread Wringer", + }, + { + text: "Secchiostro centrifuga", + name: "Sloshing Machine", + }, + { + text: "Secchiostro centrifuga Neo", + name: "Sloshing Machine Neo", + }, + { + text: "Secchiostro", + name: "Slosher", + }, + { + text: "Secchiostro DX", + name: "Slosher Deco", + }, + { + text: "Secchiostro Ordine replica", + name: "Order Slosher Replica", + }, + { + text: "Termosecchiostro", + name: "Explosher", + }, + { + text: "Termosecchiostro master", + name: "Custom Explosher", + }, + { + text: "Tetrapenna", + name: "Ballpoint Splatling", + }, + { + text: "Tetrapenna master", + name: "Ballpoint Splatling Nouveau", + }, + { + text: "Splatling a pennarelli", + name: "Heavy Edit Splatling", + }, + { + text: "Splatling a pennarelli master", + name: "Heavy Edit Splatling Nouveau", + }, + { + text: "Idrante", + name: "Hydra Splatling", + }, + { + text: "Idrante CM", + name: "Custom Hydra Splatling", + }, + { + text: "Idrante spray-bon", + name: "Torrentz Hydra Splatling", + }, + { + text: "Mini splatling logo", + name: "Zink Mini Splatling", + }, + { + text: "Mini splatling PIT-N", + name: "Mini Splatling RTL-R", + }, + { + text: "Splatling a tanica", + name: "Heavy Splatling", + }, + { + text: "Splatling a tanica DX", + name: "Heavy Splatling Deco", + }, + { + text: "Splatling Ordine replica", + name: "Order Splatling Replica", + }, + { + text: "Fontanarco V", + name: "Wellstring V", + }, + { + text: "Fontanarco V CM", + name: "Custom Wellstring V", + }, + { + text: "Tri-calamarco", + name: "Tri-Stringer", + }, + { + text: "Tri-calamarco Maremont", + name: "Inkline Tri-Stringer", + }, + { + text: "Tri-calamarco LED-bon", + name: "Bulbz Tri-Stringer", + }, + { + text: "Calamarco Ordine replica", + name: "Order Stringer Replica", + }, + { + text: "ARCO-RAL 450", + name: "REEF-LUX 450", + }, + { + text: "ARCO-RAL 450 DX", + name: "REEF-LUX 450 Deco", + }, + { + text: "ARCO-RAL 450 LAT-T", + name: "REEF-LUX 450 MIL-K", + }, + { + text: "Trasferitore", + name: "Squid Beakon", + }, + { + text: "Bomba curling", + name: "Curling Bomb", + }, + { + text: "Bomba a gassosa", + name: "Fizzy Bomb", + }, + { + text: "Granata", + name: "Burst Bomb", + }, + { + text: "Robo-bomba", + name: "Autobomb", + }, + { + text: "Bomba splash", + name: "Splat Bomb", + }, + { + text: "Appiccibomba", + name: "Suction Bomb", + }, + { + text: "Torpedinatore", + name: "Torpedo", + }, + { + text: "Sparalinee", + name: "Angle Shooter", + }, + { + text: "Cimice", + name: "Point Sensor", + }, + { + text: "Nebbia tossica", + name: "Toxic Mist", + }, + { + text: "Muro di colore", + name: "Splash Wall", + }, + { + text: "Spruzzatore", + name: "Sprinkler", + }, + { + text: "Mina", + name: "Ink Mine", + }, + { + text: "Vampinchiostro", + name: "Ink Vac", + }, + { + text: "Kraken reale", + name: "Kraken Royale", + }, + { + text: "Granchio armato", + name: "Crab Tank", + }, + { + text: "Cortina ingannevole", + name: "Splattercolor Screen", + }, + { + text: "Rinfrigorente", + name: "Tacticooler", + }, + { + text: "Diversivo esplosivo", + name: "Super Chump", + }, + { + text: "Gran bolla scudo", + name: "Big Bubbler", + }, + { + text: "Pioggia di colore", + name: "Ink Storm", + }, + { + text: "Jet splat", + name: "Inkjet", + }, + { + text: "Tintofono 5.1", + name: "Killer Wail 5.1", + }, + { + text: "Lanciarazzi", + name: "Tenta Missiles", + }, + { + text: "Granbotto", + name: "Booyah Bomb", + }, + { + text: "Triplo vernischianto", + name: "Triple Splashdown", + }, + { + text: "Sonar esplosivo", + name: "Wave Breaker", + }, + { + text: "Motosqualo", + name: "Reefslider", + }, + { + text: "Molluscatto", + name: "Zipcaster", + }, + { + text: "Triplo tornado", + name: "Triple Inkstrike", + }, + { + text: "Ultraturbinator", + name: "Trizooka", + }, + { + text: "Mega timbro", + name: "Ultra Stamp", + }, + ], + EUnl: [ + { + text: "Flitsblaster Pro", + name: "Rapid Blaster Pro", + }, + { + text: "Flitsblaster Pro Deco", + name: "Rapid Blaster Pro Deco", + }, + { + text: "Flitsblaster Pro WNT-R", + name: "Rapid Blaster Pro WNT-R", + }, + { + text: "Knalblaster", + name: "Clash Blaster", + }, + { + text: "Knalblaster Neo", + name: "Clash Blaster Neo", + }, + { + text: "Flitsblaster", + name: "Rapid Blaster", + }, + { + text: "Flitsblaster Deco", + name: "Rapid Blaster Deco", + }, + { + text: "Teleblaster", + name: "Range Blaster", + }, + { + text: "Gemodde Teleblaster", + name: "Custom Range Blaster", + }, + { + text: "Klodderblaster", + name: "Blaster", + }, + { + text: "Gemodde Klodderblaster", + name: "Custom Blaster", + }, + { + text: "Klodderblaster Schÿn", + name: "Gleamz Blaster", + }, + { + text: "Ordeblaster (replica)", + name: "Order Blaster Replica", + }, + { + text: "Kwistkwast", + name: "Painbrush", + }, + { + text: "Kwistkwast Nouveau", + name: "Painbrush Nouveau", + }, + { + text: "Kwistkwast BRN-S", + name: "Painbrush BRN-Z", + }, + { + text: "Inktkwast", + name: "Inkbrush", + }, + { + text: "Inktkwast Nouveau", + name: "Inkbrush Nouveau", + }, + { + text: "Octokwast", + name: "Octobrush", + }, + { + text: "Octokwast Nouveau", + name: "Octobrush Nouveau", + }, + { + text: "Octokwast Kösmös", + name: "Cometz Octobrush", + }, + { + text: "Ordekwast (replica)", + name: "Orderbrush Replica", + }, + { + text: "Spetbuizer", + name: "Goo Tuber", + }, + { + text: "Gemodde Spetbuizer", + name: "Custom Goo Tuber", + }, + { + text: "Bamboem 14-A", + name: "Bamboozler 14 Mk I", + }, + { + text: "Bamboem 14-B", + name: "Bamboozler 14 Mk II", + }, + { + text: "E-liter 4K met vizier", + name: "E-liter 4K Scope", + }, + { + text: "Gemodde E-liter 4K met vizier", + name: "Custom E-liter 4K Scope", + }, + { + text: "Gemodde E-liter 4K", + name: "Custom E-liter 4K", + }, + { + text: "Schelpschutter", + name: "Splatterscope", + }, + { + text: "Zekkofin Schelpschutter", + name: "Z+F Splatterscope", + }, + { + text: "Schelpschutter CAM-O", + name: "Splatterscope CAM-O", + }, + { + text: "Bloplader", + name: "Splat Charger", + }, + { + text: "Zekkofin Bloplader", + name: "Z+F Splat Charger", + }, + { + text: "Bloplader CAM-O", + name: "Splat Charger CAM-O", + }, + { + text: "Ordelader (replica)", + name: "Order Charger Replica", + }, + { + text: "Schotlood 5H", + name: "Snipewriter 5H", + }, + { + text: "Schotlood 5B", + name: "Snipewriter 5B", + }, + { + text: "Octopoets", + name: "Classic Squiffer", + }, + { + text: "Octopoets Ultra", + name: "New Squiffer", + }, + { + text: "Dubbelplonzers", + name: "Dualie Squelchers", + }, + { + text: "Gemodde Dubbelplonzers", + name: "Custom Dualie Squelchers", + }, + { + text: "Dubbelplonzers Zëbra", + name: "Hoofz Dualie Squelchers", + }, + { + text: "Kelvin 525", + name: "Glooga Dualies", + }, + { + text: "Kelvin 525 Deco", + name: "Glooga Dualies Deco", + }, + { + text: "Blusknallers BW", + name: "Douser Dualies FF", + }, + { + text: "Gemodde Blusknallers BW", + name: "Custom Douser Dualies FF", + }, + { + text: "Dubbelknallers", + name: "Splat Dualies", + }, + { + text: "Enperry Dubbelknallers", + name: "Enperry Splat Dualies", + }, + { + text: "Dubbelknallers Fönkel", + name: "Twinklez Splat Dualies", + }, + { + text: "Ordeknallers (replica)", + name: "Order Dualie Replicas", + }, + { + text: "Dubbelspatters", + name: "Dapple Dualies", + }, + { + text: "Dubbelspatters Nouveau", + name: "Dapple Dualies Nouveau", + }, + { + text: "Dubbelspatters VLR-M", + name: "Dapple Dualies NOC-T", + }, + { + text: "Quadhoppers Zwart", + name: "Dark Tetra Dualies", + }, + { + text: "Quadhoppers Wit", + name: "Light Tetra Dualies", + }, + { + text: "Carbonroller HNG-L", + name: "Carbon Roller ANG-L", + }, + { + text: "Gouden Dynamoroller", + name: "Gold Dynamo Roller", + }, + { + text: "Dynamoroller Aströ", + name: "Starz Dynamo Roller", + }, + { + text: "Flexroller", + name: "Flingza Roller", + }, + { + text: "Foil Flexroller", + name: "Foil Flingza Roller", + }, + { + text: "Klodderroller", + name: "Splat Roller", + }, + { + text: "Krak-On Klodderroller", + name: "Krak-On Splat Roller", + }, + { + text: "Orderoller (replica)", + name: "Order Roller Replica", + }, + { + text: "Breedroller", + name: "Big Swig Roller", + }, + { + text: "Breedroller Impuls", + name: "Big Swig Roller Express", + }, + { + text: "Breedroller Fütür", + name: "Planetz Big Swig Roller", + }, + { + text: "Schrobbehak PM", + name: "Mint Decavitator", + }, + { + text: "Schrobbehak HK", + name: "Charcoal Decavitator", + }, + { + text: "Wisserbladzwieper", + name: "Splatana Wiper", + }, + { + text: "Wisserbladzwieper Deco", + name: "Splatana Wiper Deco", + }, + { + text: "Wisserbladzwieper O-XID", + name: "Splatana Wiper RUS-T", + }, + { + text: "Stempelzwieper", + name: "Splatana Stamper", + }, + { + text: "Stempelzwieper Nouveau", + name: "Splatana Stamper Nouveau", + }, + { + text: "Stempelzwieper Pläkkerz", + name: "Stickerz Splatana Stamper", + }, + { + text: "Ordezwieper (replica)", + name: "Order Splatana Replica", + }, + { + text: "Paraplenzer", + name: "Undercover Brella", + }, + { + text: "Sorella Paraplenzer", + name: "Undercover Sorella Brella", + }, + { + text: "Paraplenzer Fräppant", + name: "Patternz Undercover Brella", + }, + { + text: "Protoplenzer 24-I", + name: "Recycled Brella 24 Mk I", + }, + { + text: "Protoplenzer 24-II", + name: "Recycled Brella 24 Mk II", + }, + { + text: "Plenzer", + name: "Splat Brella", + }, + { + text: "Sorella Plenzer", + name: "Sorella Brella", + }, + { + text: "Ordeplenzer (replica)", + name: "Order Brella Replica", + }, + { + text: "Kampeerplenzer", + name: "Tenta Brella", + }, + { + text: "Sorella Kampeerplenzer", + name: "Tenta Sorella Brella", + }, + { + text: "Kampeerplenzer CRE-M", + name: "Tenta Brella CRE-M", + }, + { + text: "Kladderwerper", + name: "Aerospray MG", + }, + { + text: "Kladderwerper Pro", + name: "Aerospray RG", + }, + { + text: "Kladderwerper Bönt", + name: "Colorz Aerospray", + }, + { + text: "Superspetter Pro", + name: "Splattershot Pro", + }, + { + text: "Forge Superspetter Pro", + name: "Forge Splattershot Pro", + }, + { + text: "Superspetter Pro BIB-R", + name: "Splattershot Pro FRZ-N", + }, + { + text: "Superspetter jr.", + name: "Splattershot Jr.", + }, + { + text: "Gemodde Superspetter jr.", + name: "Custom Splattershot Jr.", + }, + { + text: "Floeper", + name: "Squeezer", + }, + { + text: "Foil Floeper", + name: "Foil Squeezer", + }, + { + text: ".52 Kaliter", + name: ".52 Gal", + }, + { + text: ".52 Kaliter Deco", + name: ".52 Gal Deco", + }, + { + text: ".96 Kaliter", + name: ".96 Gal", + }, + { + text: ".96 Kaliter Deco", + name: ".96 Gal Deco", + }, + { + text: ".96 Kaliter Pänter", + name: "Clawz .96 Gal", + }, + { + text: "Straalplonzer", + name: "Jet Squelcher", + }, + { + text: "Gemodde Straalplonzer", + name: "Custom Jet Squelcher", + }, + { + text: "Straalplonzer COB-R", + name: "Jet Squelcher COB-R", + }, + { + text: "Superspetter", + name: "Splattershot", + }, + { + text: "Tentatek Superspetter", + name: "Tentatek Splattershot", + }, + { + text: "Superspetter Glänz", + name: "Glamorz Splattershot", + }, + { + text: "Heldenspetter (replica)", + name: "Hero Shot Replica", + }, + { + text: "Ordespetter (replica)", + name: "Order Shot Replica", + }, + { + text: "Octospetter (replica)", + name: "Octo Shot Replica", + }, + { + text: "Spetterspuit", + name: "Splash-o-matic", + }, + { + text: "Spetterspuit Neo", + name: "Neo Splash-o-matic", + }, + { + text: "Spetterspuit GKK-O", + name: "Splash-o-matic GCK-O", + }, + { + text: "Superspetter Nova", + name: "Splattershot Nova", + }, + { + text: "Annaki Superspetter Nova", + name: "Annaki Splattershot Nova", + }, + { + text: "Spettertuit", + name: "Sploosh-o-matic", + }, + { + text: "Spettertuit Neo", + name: "Neo Sploosh-o-matic", + }, + { + text: "H-3 Langsnuit", + name: "H-3 Nozzlenose", + }, + { + text: "H-3 Langsnuit D", + name: "H-3 Nozzlenose D", + }, + { + text: "H-3 Langsnuit ANC-D", + name: "H-3 Nozzlenose VIP-R", + }, + { + text: "L-3 Stompsnuit", + name: "L-3 Nozzlenose", + }, + { + text: "L-3 Stompsnuit D", + name: "L-3 Nozzlenose D", + }, + { + text: "L-3 Stompsnuit Spränkel", + name: "Glitterz L-3 Nozzlenose", + }, + { + text: "Spatkuip", + name: "Bloblobber", + }, + { + text: "Spatkuip Deco", + name: "Bloblobber Deco", + }, + { + text: "Triomorser", + name: "Tri-Slosher", + }, + { + text: "Triomorser Nouveau", + name: "Tri-Slosher Nouveau", + }, + { + text: "Triomorser GRW-G", + name: "Tri-Slosher ASH-N", + }, + { + text: "Wringmorser", + name: "Dread Wringer", + }, + { + text: "Wringmorser D", + name: "Dread Wringer D", + }, + { + text: "Wringmorser Hörnz", + name: "Hornz Dread Wringer", + }, + { + text: "Klotstrommel", + name: "Sloshing Machine", + }, + { + text: "Klotstrommel Neo", + name: "Sloshing Machine Neo", + }, + { + text: "Morser", + name: "Slosher", + }, + { + text: "Morser Deco", + name: "Slosher Deco", + }, + { + text: "Ordemorser (replica)", + name: "Order Slosher Replica", + }, + { + text: "Kliederkachel", + name: "Explosher", + }, + { + text: "Gemodde Kliederkachel", + name: "Custom Explosher", + }, + { + text: "Stylospetling", + name: "Ballpoint Splatling", + }, + { + text: "Stylospetling Nouveau", + name: "Ballpoint Splatling Nouveau", + }, + { + text: "Examinator", + name: "Heavy Edit Splatling", + }, + { + text: "Examinator Nouveau", + name: "Heavy Edit Splatling Nouveau", + }, + { + text: "Blusling", + name: "Hydra Splatling", + }, + { + text: "Gemodde Blusling", + name: "Custom Hydra Splatling", + }, + { + text: "Blusling Störtvlud", + name: "Torrentz Hydra Splatling", + }, + { + text: "Mini-Spetling", + name: "Mini Splatling", + }, + { + text: "Zink Mini-Spetling", + name: "Zink Mini Splatling", + }, + { + text: "Mini-Spetling PYT-N", + name: "Mini Splatling RTL-R", + }, + { + text: "Spetling", + name: "Heavy Splatling", + }, + { + text: "Spetling Deco", + name: "Heavy Splatling Deco", + }, + { + text: "Ordespetling (replica)", + name: "Order Splatling Replica", + }, + { + text: "Pentapomper", + name: "Wellstring V", + }, + { + text: "Gemodde Pentapomper", + name: "Custom Wellstring V", + }, + { + text: "Triospanner", + name: "Tri-Stringer", + }, + { + text: "Inkline Triospanner", + name: "Inkline Tri-Stringer", + }, + { + text: "Triospanner Lüminus", + name: "Bulbz Tri-Stringer", + }, + { + text: "Ordespanner (replica)", + name: "Order Stringer Replica", + }, + { + text: "RIF-LEX 450", + name: "REEF-LUX 450", + }, + { + text: "RIF-LEX 450 Deco", + name: "REEF-LUX 450 Deco", + }, + { + text: "RIF-LEX 450 MEL-K", + name: "REEF-LUX 450 MIL-K", + }, + { + text: "Springschotel", + name: "Squid Beakon", + }, + { + text: "Curlingbom", + name: "Curling Bomb", + }, + { + text: "Bomblikje", + name: "Fizzy Bomb", + }, + { + text: "Ballonbom", + name: "Burst Bomb", + }, + { + text: "Robobom", + name: "Autobomb", + }, + { + text: "Klodderbom", + name: "Splat Bomb", + }, + { + text: "Kleefbom", + name: "Suction Bomb", + }, + { + text: "Torpedobom", + name: "Torpedo", + }, + { + text: "Ketskogel", + name: "Angle Shooter", + }, + { + text: "Detector", + name: "Point Sensor", + }, + { + text: "Gifmist", + name: "Toxic Mist", + }, + { + text: "Inktgordijn", + name: "Splash Wall", + }, + { + text: "Inktsprinkler", + name: "Sprinkler", + }, + { + text: "Inktmijn", + name: "Ink Mine", + }, + { + text: "Inktdief", + name: "Ink Vac", + }, + { + text: "Monstermorfose", + name: "Kraken Royale", + }, + { + text: "Krabbentank", + name: "Crab Tank", + }, + { + text: "PLED-scherm", + name: "Splattercolor Screen", + }, + { + text: "Reddingsbar", + name: "Tacticooler", + }, + { + text: "Voorschutter", + name: "Super Chump", + }, + { + text: "Joekelschild", + name: "Big Bubbler", + }, + { + text: "Spetterbui", + name: "Ink Storm", + }, + { + text: "Inktjet", + name: "Inkjet", + }, + { + text: "Megalofoon 5.1", + name: "Killer Wail 5.1", + }, + { + text: "Spetterraketten", + name: "Tenta Missiles", + }, + { + text: "Coole bom", + name: "Booyah Bomb", + }, + { + text: "Ultralanding", + name: "Triple Splashdown", + }, + { + text: "Golfbeker", + name: "Wave Breaker", + }, + { + text: "Opblaashaai", + name: "Reefslider", + }, + { + text: "Tentakabel", + name: "Zipcaster", + }, + { + text: "Tri-tornado", + name: "Triple Inkstrike", + }, + { + text: "Ultrastempel", + name: "Ultra Stamp", + }, + ], + EUru: [ + { + text: "Бластермат ПРО", + name: "Rapid Blaster Pro", + }, + { + text: "Бластермат ПРО «Деко»", + name: "Rapid Blaster Pro Deco", + }, + { + text: "Бластермат ПРО «К-ЛОТУН»", + name: "Rapid Blaster Pro WNT-R", + }, + { + text: "Контрабластер", + name: "Clash Blaster", + }, + { + text: "Контрабластер «Нео»", + name: "Clash Blaster Neo", + }, + { + text: "Бластермат", + name: "Rapid Blaster", + }, + { + text: "Бластермат «Деко»", + name: "Rapid Blaster Deco", + }, + { + text: "Дальнобластер", + name: "Range Blaster", + }, + { + text: "Дальнобластер «Понт»", + name: "Custom Range Blaster", + }, + { + text: "Бластер", + name: "Blaster", + }, + { + text: "Бластер «Понт»", + name: "Custom Blaster", + }, + { + text: "Бластер «Лоск»", + name: "Gleamz Blaster", + }, + { + text: "S-BLAST 92", + name: "S-BLAST '92", + }, + { + text: "S-BLAST 91", + name: "S-BLAST '91", + }, + { + text: "Лунобластер", + name: "Luna Blaster", + }, + { + text: "Лунобластер «Нео»", + name: "Luna Blaster Neo", + }, + { + text: "Бластер Порядка (клон)", + name: "Order Blaster Replica", + }, + { + text: "Веерокисть «Плюхассо»", + name: "Painbrush", + }, + { + text: "Веерокисть «НуВо»", + name: "Painbrush Nouveau", + }, + { + text: "Веерокисть «БРОН-З»", + name: "Painbrush BRN-Z", + }, + { + text: "Кисть", + name: "Inkbrush", + }, + { + text: "Кисть «НуВо»", + name: "Inkbrush Nouveau", + }, + { + text: "Арт-кисть", + name: "Octobrush", + }, + { + text: "Арт-кисть «НуВо»", + name: "Octobrush Nouveau", + }, + { + text: "Арт-кисть «Комета»", + name: "Cometz Octobrush", + }, + { + text: "Кисть Порядка (клон)", + name: "Orderbrush Replica", + }, + { + text: "Трубастер", + name: "Goo Tuber", + }, + { + text: "Трубастер «Понт»", + name: "Custom Goo Tuber", + }, + { + text: "Бамбух 14-I", + name: "Bamboozler 14 Mk I", + }, + { + text: "Бамбух 14-II", + name: "Bamboozler 14 Mk II", + }, + { + text: "Снайп-э-литр 95", + name: "E-liter 4K Scope", + }, + { + text: "Снайп-э-литр 95 «Понт»", + name: "Custom E-liter 4K Scope", + }, + { + text: "Э-литр 95", + name: "E-liter 4K", + }, + { + text: "Э-литр 95 «Понт»", + name: "Custom E-liter 4K", + }, + { + text: "Снайпокрас", + name: "Splatterscope", + }, + { + text: "Снайпокрас «ЙожкоТюр»", + name: "Z+F Splatterscope", + }, + { + text: "Снайпокрас «К-ФЛЯЖ»", + name: "Splatterscope CAM-O", + }, + { + text: "Сплатган", + name: "Splat Charger", + }, + { + text: "Сплатган «ЙожкоТюр»", + name: "Z+F Splat Charger", + }, + { + text: "Сплатган «К-ФЛЯЖ»", + name: "Splat Charger CAM-O", + }, + { + text: "Сплатган Порядка (клон)", + name: "Order Charger Replica", + }, + { + text: "Патрондаш 5Т", + name: "Snipewriter 5H", + }, + { + text: "Патрондаш 5М", + name: "Snipewriter 5B", + }, + { + text: "Стиратель", + name: "Classic Squiffer", + }, + { + text: "Стиратель-II", + name: "New Squiffer", + }, + { + text: "Спурт-плескари", + name: "Dualie Squelchers", + }, + { + text: "Спурт-плескари «Понт»", + name: "Custom Dualie Squelchers", + }, + { + text: "Спурт-плескари «Копыта»", + name: "Hoofz Dualie Squelchers", + }, + { + text: "Термоплюхи 525", + name: "Glooga Dualies", + }, + { + text: "Термоплюхи 525 «Деко»", + name: "Glooga Dualies Deco", + }, + { + text: "Краскотушители 255", + name: "Douser Dualies FF", + }, + { + text: "Краскотушители «Понт»", + name: "Custom Douser Dualies FF", + }, + { + text: "Спуртометы", + name: "Splat Dualies", + }, + { + text: "Спуртометы «К.К.»", + name: "Enperry Splat Dualies", + }, + { + text: "Спуртометы «Искра»", + name: "Twinklez Splat Dualies", + }, + { + text: "Спуртометы Порядка (кл.)", + name: "Order Dualie Replicas", + }, + { + text: "Импрессиометы", + name: "Dapple Dualies", + }, + { + text: "Импрессиометы «НуВо»", + name: "Dapple Dualies Nouveau", + }, + { + text: "Импрессиометы «Ф-ЛИН»", + name: "Dapple Dualies NOC-T", + }, + { + text: "Черные спуртокроссы", + name: "Dark Tetra Dualies", + }, + { + text: "Белые спуртокроссы", + name: "Light Tetra Dualies", + }, + { + text: "Валик ЛАЙТ", + name: "Carbon Roller", + }, + { + text: "Валик ЛАЙТ «Деко»", + name: "Carbon Roller Deco", + }, + { + text: "Валик ЛАЙТ «У-ДИЛЬ»", + name: "Carbon Roller ANG-L", + }, + { + text: "Мотовалик", + name: "Dynamo Roller", + }, + { + text: "Мотовалик VIP", + name: "Gold Dynamo Roller", + }, + { + text: "Мотовалик «Пульсар»", + name: "Starz Dynamo Roller", + }, + { + text: "Омнивалик", + name: "Flingza Roller", + }, + { + text: "Омнивалик «Блеск»", + name: "Foil Flingza Roller", + }, + { + text: "Сплат-валик", + name: "Splat Roller", + }, + { + text: "Сплат-валик «Кра-Кен»", + name: "Krak-On Splat Roller", + }, + { + text: "Валик Порядка (клон)", + name: "Order Roller Replica", + }, + { + text: "Макси-валик", + name: "Big Swig Roller", + }, + { + text: "Макси-валик «Экспресс»", + name: "Big Swig Roller Express", + }, + { + text: "Макси-валик «Планета»", + name: "Planetz Big Swig Roller", + }, + { + text: "Мятная дентана", + name: "Mint Decavitator", + }, + { + text: "Угольная дентана", + name: "Charcoal Decavitator", + }, + { + text: "Cплат-дворник", + name: "Splatana Wiper", + }, + { + text: "Cплат-дворник «Деко»", + name: "Splatana Wiper Deco", + }, + { + text: "Сплат-дворник «РЖАВ-Ч»", + name: "Splatana Wiper RUS-T", + }, + { + text: "Сплат-печатель", + name: "Splatana Stamper", + }, + { + text: "Сплат-печатель «НуВо»", + name: "Splatana Stamper Nouveau", + }, + { + text: "Сплат-печатель «Уклейка»", + name: "Stickerz Splatana Stamper", + }, + { + text: "Сплат-катана Порядка (кл.)", + name: "Order Splatana Replica", + }, + { + text: "Шпионский зонт", + name: "Undercover Brella", + }, + { + text: "Шпионский зонт «Родиччи»", + name: "Undercover Sorella Brella", + }, + { + text: "Шпионский зонт «Коллаж»", + name: "Patternz Undercover Brella", + }, + { + text: "Ребут-зонтган М-1", + name: "Recycled Brella 24 Mk I", + }, + { + text: "Ребут-зонтган М-2", + name: "Recycled Brella 24 Mk II", + }, + { + text: "Сплат-зонт", + name: "Splat Brella", + }, + { + text: "Зонтган «Родиччи»", + name: "Sorella Brella", + }, + { + text: "Зонтган Порядка (клон)", + name: "Order Brella Replica", + }, + { + text: "Зонтент", + name: "Tenta Brella", + }, + { + text: "Зонтент «Родиччи»", + name: "Tenta Sorella Brella", + }, + { + text: "Зонтент «СЛИВ-К»", + name: "Tenta Brella CRE-M", + }, + { + text: "Аэроспрей", + name: "Aerospray MG", + }, + { + text: "Аэроспрей ДЕЛЮКС", + name: "Aerospray RG", + }, + { + text: "Аэроспрей «Перелив»", + name: "Colorz Aerospray", + }, + { + text: "Каплестрел ПРО", + name: "Splattershot Pro", + }, + { + text: "Каплестрел ПРО «Блиц»", + name: "Forge Splattershot Pro", + }, + { + text: "Каплестрел ПРО «СТУ-Ж»", + name: "Splattershot Pro FRZ-N", + }, + { + text: "Каплестрел-У", + name: "Splattershot Jr.", + }, + { + text: "Каплестрел-У 2.0", + name: "Custom Splattershot Jr.", + }, + { + text: "Бутылятор", + name: "Squeezer", + }, + { + text: "Бутылятор «Блеск»", + name: "Foil Squeezer", + }, + { + text: "Струевик .52", + name: ".52 Gal", + }, + { + text: "Струевик .52 «Деко»", + name: ".52 Gal Deco", + }, + { + text: "Струевик .96", + name: ".96 Gal", + }, + { + text: "Струевик .96 «Деко»", + name: ".96 Gal Deco", + }, + { + text: "Струевик .96 «Царап»", + name: "Clawz .96 Gal", + }, + { + text: "Плескарь", + name: "Jet Squelcher", + }, + { + text: "Плескарь «Понт»", + name: "Custom Jet Squelcher", + }, + { + text: "Плескарь «КОБ-Р»", + name: "Jet Squelcher COB-R", + }, + { + text: "Каплестрел", + name: "Splattershot", + }, + { + text: "Каплестрел «Щуччи»", + name: "Tentatek Splattershot", + }, + { + text: "Каплестрел «Блесна»", + name: "Glamorz Splattershot", + }, + { + text: "Каплестрел героя (клон)", + name: "Hero Shot Replica", + }, + { + text: "Каплестрел Порядка (клон)", + name: "Order Shot Replica", + }, + { + text: "Осьмострел (клон)", + name: "Octo Shot Replica", + }, + { + text: "Плюхомет", + name: "Splash-o-matic", + }, + { + text: "Плюхомет «Нео»", + name: "Neo Splash-o-matic", + }, + { + text: "Плюхомет «Г-КОН»", + name: "Splash-o-matic GCK-O", + }, + { + text: "Каплестрел НОВА", + name: "Splattershot Nova", + }, + { + text: "Каплестрел НОВА-ПАНК", + name: "Annaki Splattershot Nova", + }, + { + text: "N-ZAP 85", + name: "N-ZAP '85", + }, + { + text: "N-ZAP 89", + name: "N-ZAP '89", + }, + { + text: "Плюхотрон", + name: "Sploosh-o-matic", + }, + { + text: "Плюхотрон «Нео»", + name: "Neo Sploosh-o-matic", + }, + { + text: "Тяжелый каплетрон", + name: "H-3 Nozzlenose", + }, + { + text: "Тяжелый каплетрон 2.0", + name: "H-3 Nozzlenose D", + }, + { + text: "Т-каплетрон «МАМ-Б»", + name: "H-3 Nozzlenose VIP-R", + }, + { + text: "Каплетрон-компакт", + name: "L-3 Nozzlenose", + }, + { + text: "Каплетрон-компакт 2.0", + name: "L-3 Nozzlenose D", + }, + { + text: "Каплетрон «Страз»", + name: "Glitterz L-3 Nozzlenose", + }, + { + text: "Ваннаган", + name: "Bloblobber", + }, + { + text: "Ваннаган «Деко»", + name: "Bloblobber Deco", + }, + { + text: "Ведроган 3 в 1", + name: "Tri-Slosher", + }, + { + text: "Ведроган 3 в 1 «НуВо»", + name: "Tri-Slosher Nouveau", + }, + { + text: "Ведроган 3 в 1 «ГАЛЬ-Н»", + name: "Tri-Slosher ASH-N", + }, + { + text: "Отжиматель", + name: "Dread Wringer", + }, + { + text: "Отжиматель 2.0", + name: "Dread Wringer D", + }, + { + text: "Отжиматель «Рога»", + name: "Hornz Dread Wringer", + }, + { + text: "Центрифугер", + name: "Sloshing Machine", + }, + { + text: "Центрифугер «Нео»", + name: "Sloshing Machine Neo", + }, + { + text: "Ведроган", + name: "Slosher", + }, + { + text: "Ведроган «Деко»", + name: "Slosher Deco", + }, + { + text: "Ведроган Порядка (клон)", + name: "Order Slosher Replica", + }, + { + text: "Шпарган", + name: "Explosher", + }, + { + text: "Шпарган «Понт»", + name: "Custom Explosher", + }, + { + text: "Кугельшрайбер", + name: "Ballpoint Splatling", + }, + { + text: "Кугельшрайбер «НуВо»", + name: "Ballpoint Splatling Nouveau", + }, + { + text: "Брызгомет XL «Корректор»", + name: "Heavy Edit Splatling", + }, + { + text: "Корректор «НуВо»", + name: "Heavy Edit Splatling Nouveau", + }, + { + text: "Брызгомет «Горыныч»", + name: "Hydra Splatling", + }, + { + text: "«Понт-Горыныч»", + name: "Custom Hydra Splatling", + }, + { + text: "«Баллон-Горыныч»", + name: "Torrentz Hydra Splatling", + }, + { + text: "Брызгомет-М", + name: "Mini Splatling", + }, + { + text: "Брызгомет-М «Шпротус»", + name: "Zink Mini Splatling", + }, + { + text: "Брызгомет-М «Г-ДЮК»", + name: "Mini Splatling RTL-R", + }, + { + text: "Наутилус-47", + name: "Nautilus 47", + }, + { + text: "Наутилус-79", + name: "Nautilus 79", + }, + { + text: "Брызгомет XL", + name: "Heavy Splatling", + }, + { + text: "Брызгомет XL «Деко»", + name: "Heavy Splatling Deco", + }, + { + text: "Брызгомет Порядка (клон)", + name: "Order Splatling Replica", + }, + { + text: "Квинтиватор «Колодезь»", + name: "Wellstring V", + }, + { + text: "Квинтиватор «Понт»", + name: "Custom Wellstring V", + }, + { + text: "Тритиватор", + name: "Tri-Stringer", + }, + { + text: "Тритиватор «Коллаб»", + name: "Inkline Tri-Stringer", + }, + { + text: "Тритиватор «Диод»", + name: "Bulbz Tri-Stringer", + }, + { + text: "Тетиватор Порядка (клон)", + name: "Order Stringer Replica", + }, + { + text: "КО-РАЛЛ 450", + name: "REEF-LUX 450", + }, + { + text: "КО-РАЛЛ 450 «Деко»", + name: "REEF-LUX 450 Deco", + }, + { + text: "КО-РАЛЛ 450 «М-ЛОКО»", + name: "REEF-LUX 450 MIL-K", + }, + { + text: "Прыжковый маячок", + name: "Squid Beakon", + }, + { + text: "Керлинг-бомба", + name: "Curling Bomb", + }, + { + text: "Содовая бомба", + name: "Fizzy Bomb", + }, + { + text: "Разрывная бомба", + name: "Burst Bomb", + }, + { + text: "Робобомба", + name: "Autobomb", + }, + { + text: "Брызгающая бомба", + name: "Splat Bomb", + }, + { + text: "Бомба на присоске", + name: "Suction Bomb", + }, + { + text: "Торпедная бомба", + name: "Torpedo", + }, + { + text: "Углострел", + name: "Angle Shooter", + }, + { + text: "Маркер движения", + name: "Point Sensor", + }, + { + text: "Едкий туман", + name: "Toxic Mist", + }, + { + text: "Чернильный занавес", + name: "Splash Wall", + }, + { + text: "Распылятор", + name: "Sprinkler", + }, + { + text: "Мина", + name: "Ink Mine", + }, + { + text: "Краскосос", + name: "Ink Vac", + }, + { + text: "Тираннокракен", + name: "Kraken Royale", + }, + { + text: "Кработанк", + name: "Crab Tank", + }, + { + text: "Заливной барьер", + name: "Splattercolor Screen", + }, + { + text: "Тоникулер", + name: "Tacticooler", + }, + { + text: "Суперпрыжатор", + name: "Super Chump", + }, + { + text: "Шарощит", + name: "Big Bubbler", + }, + { + text: "Туча краски", + name: "Ink Storm", + }, + { + text: "Красколет", + name: "Inkjet", + }, + { + text: "Мегалофон 5.1", + name: "Killer Wail 5.1", + }, + { + text: "Каракатница", + name: "Tenta Missiles", + }, + { + text: "Йо-хо-плюхер", + name: "Booyah Bomb", + }, + { + text: "Тройной мегаплюх", + name: "Triple Splashdown", + }, + { + text: "Волноплюхер", + name: "Wave Breaker", + }, + { + text: "Мотокула", + name: "Reefslider", + }, + { + text: "Краскотрос", + name: "Zipcaster", + }, + { + text: "Тройной торнадо", + name: "Triple Inkstrike", + }, + { + text: "Гранатомет «Трезубец»", + name: "Trizooka", + }, + { + text: "Припечать", + name: "Ultra Stamp", + }, + ], + JPja: [ + { + text: "Rブラスターエリート", + name: "Rapid Blaster Pro", + }, + { + text: "Rブラスターエリートデコ", + name: "Rapid Blaster Pro Deco", + }, + { + text: "RブラスターエリートWNTR", + name: "Rapid Blaster Pro WNT-R", + }, + { + text: "クラッシュブラスター", + name: "Clash Blaster", + }, + { + text: "クラッシュブラスターネオ", + name: "Clash Blaster Neo", + }, + { + text: "ラピッドブラスター", + name: "Rapid Blaster", + }, + { + text: "ラピッドブラスターデコ", + name: "Rapid Blaster Deco", + }, + { + text: "ロングブラスター", + name: "Range Blaster", + }, + { + text: "ロングブラスターカスタム", + name: "Custom Range Blaster", + }, + { + text: "ホットブラスター", + name: "Blaster", + }, + { + text: "ホットブラスターカスタム", + name: "Custom Blaster", + }, + { + text: "ホットブラスター艶", + name: "Gleamz Blaster", + }, + { + text: "S-BLAST92", + name: "S-BLAST '92", + }, + { + text: "S-BLAST91", + name: "S-BLAST '91", + }, + { + text: "ノヴァブラスター", + name: "Luna Blaster", + }, + { + text: "ノヴァブラスターネオ", + name: "Luna Blaster Neo", + }, + { + text: "オーダーブラスター レプリカ", + name: "Order Blaster Replica", + }, + { + text: "フィンセント", + name: "Painbrush", + }, + { + text: "フィンセント・ヒュー", + name: "Painbrush Nouveau", + }, + { + text: "フィンセントBRNZ", + name: "Painbrush BRN-Z", + }, + { + text: "パブロ", + name: "Inkbrush", + }, + { + text: "パブロ・ヒュー", + name: "Inkbrush Nouveau", + }, + { + text: "ホクサイ", + name: "Octobrush", + }, + { + text: "ホクサイ・ヒュー", + name: "Octobrush Nouveau", + }, + { + text: "ホクサイ彗", + name: "Cometz Octobrush", + }, + { + text: "オーダーブラシ レプリカ", + name: "Orderbrush Replica", + }, + { + text: "ソイチューバー", + name: "Goo Tuber", + }, + { + text: "ソイチューバーカスタム", + name: "Custom Goo Tuber", + }, + { + text: "14式竹筒銃・甲", + name: "Bamboozler 14 Mk I", + }, + { + text: "14式竹筒銃・乙", + name: "Bamboozler 14 Mk II", + }, + { + text: "4Kスコープ", + name: "E-liter 4K Scope", + }, + { + text: "4Kスコープカスタム", + name: "Custom E-liter 4K Scope", + }, + { + text: "リッター4K", + name: "E-liter 4K", + }, + { + text: "リッター4Kカスタム", + name: "Custom E-liter 4K", + }, + { + text: "スプラスコープ", + name: "Splatterscope", + }, + { + text: "スプラスコープコラボ", + name: "Z+F Splatterscope", + }, + { + text: "スプラスコープFRST", + name: "Splatterscope CAM-O", + }, + { + text: "スプラチャージャー", + name: "Splat Charger", + }, + { + text: "スプラチャージャーコラボ", + name: "Z+F Splat Charger", + }, + { + text: "スプラチャージャーFRST", + name: "Splat Charger CAM-O", + }, + { + text: "オーダーチャージャー レプリカ", + name: "Order Charger Replica", + }, + { + text: "R-PEN/5H", + name: "Snipewriter 5H", + }, + { + text: "R-PEN/5B", + name: "Snipewriter 5B", + }, + { + text: "スクイックリンα", + name: "Classic Squiffer", + }, + { + text: "スクイックリンβ", + name: "New Squiffer", + }, + { + text: "デュアルスイーパー", + name: "Dualie Squelchers", + }, + { + text: "デュアルスイーパーカスタム", + name: "Custom Dualie Squelchers", + }, + { + text: "デュアルスイーパー蹄", + name: "Hoofz Dualie Squelchers", + }, + { + text: "ケルビン525", + name: "Glooga Dualies", + }, + { + text: "ケルビン525デコ", + name: "Glooga Dualies Deco", + }, + { + text: "ガエンFF", + name: "Douser Dualies FF", + }, + { + text: "ガエンFFカスタム", + name: "Custom Douser Dualies FF", + }, + { + text: "スプラマニューバー", + name: "Splat Dualies", + }, + { + text: "スプラマニューバーコラボ", + name: "Enperry Splat Dualies", + }, + { + text: "スプラマニューバー耀", + name: "Twinklez Splat Dualies", + }, + { + text: "オーダーマニューバー レプリカ", + name: "Order Dualie Replicas", + }, + { + text: "スパッタリー", + name: "Dapple Dualies", + }, + { + text: "スパッタリー・ヒュー", + name: "Dapple Dualies Nouveau", + }, + { + text: "スパッタリーOWL", + name: "Dapple Dualies NOC-T", + }, + { + text: "クアッドホッパーブラック", + name: "Dark Tetra Dualies", + }, + { + text: "クアッドホッパーホワイト", + name: "Light Tetra Dualies", + }, + { + text: "カーボンローラー", + name: "Carbon Roller", + }, + { + text: "カーボンローラーデコ", + name: "Carbon Roller Deco", + }, + { + text: "カーボンローラーANGL", + name: "Carbon Roller ANG-L", + }, + { + text: "ダイナモローラー", + name: "Dynamo Roller", + }, + { + text: "ダイナモローラーテスラ", + name: "Gold Dynamo Roller", + }, + { + text: "ダイナモローラー冥", + name: "Starz Dynamo Roller", + }, + { + text: "ヴァリアブルローラー", + name: "Flingza Roller", + }, + { + text: "ヴァリアブルローラーフォイル", + name: "Foil Flingza Roller", + }, + { + text: "スプラローラー", + name: "Splat Roller", + }, + { + text: "スプラローラーコラボ", + name: "Krak-On Splat Roller", + }, + { + text: "オーダーローラー レプリカ", + name: "Order Roller Replica", + }, + { + text: "ワイドローラー", + name: "Big Swig Roller", + }, + { + text: "ワイドローラーコラボ", + name: "Big Swig Roller Express", + }, + { + text: "ワイドローラー惑", + name: "Planetz Big Swig Roller", + }, + { + text: "デンタルワイパーミント", + name: "Mint Decavitator", + }, + { + text: "デンタルワイパースミ", + name: "Charcoal Decavitator", + }, + { + text: "ドライブワイパー", + name: "Splatana Wiper", + }, + { + text: "ドライブワイパーデコ", + name: "Splatana Wiper Deco", + }, + { + text: "ドライブワイパーRUST", + name: "Splatana Wiper RUS-T", + }, + { + text: "ジムワイパー", + name: "Splatana Stamper", + }, + { + text: "ジムワイパー・ヒュー", + name: "Splatana Stamper Nouveau", + }, + { + text: "ジムワイパー封", + name: "Stickerz Splatana Stamper", + }, + { + text: "オーダーワイパー レプリカ", + name: "Order Splatana Replica", + }, + { + text: "スパイガジェット", + name: "Undercover Brella", + }, + { + text: "スパイガジェットソレーラ", + name: "Undercover Sorella Brella", + }, + { + text: "スパイガジェット繚", + name: "Patternz Undercover Brella", + }, + { + text: "24式張替傘・甲", + name: "Recycled Brella 24 Mk I", + }, + { + text: "24式張替傘・乙", + name: "Recycled Brella 24 Mk II", + }, + { + text: "パラシェルター", + name: "Splat Brella", + }, + { + text: "パラシェルターソレーラ", + name: "Sorella Brella", + }, + { + text: "オーダーシェルター レプリカ", + name: "Order Brella Replica", + }, + { + text: "キャンピングシェルター", + name: "Tenta Brella", + }, + { + text: "キャンピングシェルターソレーラ", + name: "Tenta Sorella Brella", + }, + { + text: "キャンピングシェルターCREM", + name: "Tenta Brella CRE-M", + }, + { + text: "プロモデラーMG", + name: "Aerospray MG", + }, + { + text: "プロモデラーRG", + name: "Aerospray RG", + }, + { + text: "プロモデラー彩", + name: "Colorz Aerospray", + }, + { + text: "プライムシューター", + name: "Splattershot Pro", + }, + { + text: "プライムシューターコラボ", + name: "Forge Splattershot Pro", + }, + { + text: "プライムシューターFRZN", + name: "Splattershot Pro FRZ-N", + }, + { + text: "わかばシューター", + name: "Splattershot Jr.", + }, + { + text: "もみじシューター", + name: "Custom Splattershot Jr.", + }, + { + text: "ボトルガイザー", + name: "Squeezer", + }, + { + text: "ボトルガイザーフォイル", + name: "Foil Squeezer", + }, + { + text: ".52ガロン", + name: ".52 Gal", + }, + { + text: ".52ガロンデコ", + name: ".52 Gal Deco", + }, + { + text: ".96ガロン", + name: ".96 Gal", + }, + { + text: ".96ガロンデコ", + name: ".96 Gal Deco", + }, + { + text: ".96ガロン爪", + name: "Clawz .96 Gal", + }, + { + text: "ジェットスイーパー", + name: "Jet Squelcher", + }, + { + text: "ジェットスイーパーカスタム", + name: "Custom Jet Squelcher", + }, + { + text: "ジェットスイーパーCOBR", + name: "Jet Squelcher COB-R", + }, + { + text: "スプラシューター", + name: "Splattershot", + }, + { + text: "スプラシューターコラボ", + name: "Tentatek Splattershot", + }, + { + text: "スプラシューター煌", + name: "Glamorz Splattershot", + }, + { + text: "ヒーローシューター レプリカ", + name: "Hero Shot Replica", + }, + { + text: "オーダーシューター レプリカ", + name: "Order Shot Replica", + }, + { + text: "オクタシューター レプリカ", + name: "Octo Shot Replica", + }, + { + text: "シャープマーカー", + name: "Splash-o-matic", + }, + { + text: "シャープマーカーネオ", + name: "Neo Splash-o-matic", + }, + { + text: "シャープマーカーGECK", + name: "Splash-o-matic GCK-O", + }, + { + text: "スペースシューター", + name: "Splattershot Nova", + }, + { + text: "スペースシューターコラボ", + name: "Annaki Splattershot Nova", + }, + { + text: "N-ZAP85", + name: "N-ZAP '85", + }, + { + text: "N-ZAP89", + name: "N-ZAP '89", + }, + { + text: "ボールドマーカー", + name: "Sploosh-o-matic", + }, + { + text: "ボールドマーカーネオ", + name: "Neo Sploosh-o-matic", + }, + { + text: "H3リールガン", + name: "H-3 Nozzlenose", + }, + { + text: "H3リールガンD", + name: "H-3 Nozzlenose D", + }, + { + text: "H3リールガンSNAK", + name: "H-3 Nozzlenose VIP-R", + }, + { + text: "L3リールガン", + name: "L-3 Nozzlenose", + }, + { + text: "L3リールガンD", + name: "L-3 Nozzlenose D", + }, + { + text: "L3リールガン箔", + name: "Glitterz L-3 Nozzlenose", + }, + { + text: "オーバーフロッシャー", + name: "Bloblobber", + }, + { + text: "オーバーフロッシャーデコ", + name: "Bloblobber Deco", + }, + { + text: "ヒッセン", + name: "Tri-Slosher", + }, + { + text: "ヒッセン・ヒュー", + name: "Tri-Slosher Nouveau", + }, + { + text: "ヒッセンASH", + name: "Tri-Slosher ASH-N", + }, + { + text: "モップリン", + name: "Dread Wringer", + }, + { + text: "モップリンD", + name: "Dread Wringer D", + }, + { + text: "モップリン角", + name: "Hornz Dread Wringer", + }, + { + text: "スクリュースロッシャー", + name: "Sloshing Machine", + }, + { + text: "スクリュースロッシャーネオ", + name: "Sloshing Machine Neo", + }, + { + text: "バケットスロッシャー", + name: "Slosher", + }, + { + text: "バケットスロッシャーデコ", + name: "Slosher Deco", + }, + { + text: "オーダースロッシャー レプリカ", + name: "Order Slosher Replica", + }, + { + text: "エクスプロッシャー", + name: "Explosher", + }, + { + text: "エクスプロッシャーカスタム", + name: "Custom Explosher", + }, + { + text: "クーゲルシュライバー", + name: "Ballpoint Splatling", + }, + { + text: "クーゲルシュライバー・ヒュー", + name: "Ballpoint Splatling Nouveau", + }, + { + text: "イグザミナー", + name: "Heavy Edit Splatling", + }, + { + text: "イグザミナー・ヒュー", + name: "Heavy Edit Splatling Nouveau", + }, + { + text: "ハイドラント", + name: "Hydra Splatling", + }, + { + text: "ハイドラントカスタム", + name: "Custom Hydra Splatling", + }, + { + text: "ハイドラント圧", + name: "Torrentz Hydra Splatling", + }, + { + text: "スプラスピナー", + name: "Mini Splatling", + }, + { + text: "スプラスピナーコラボ", + name: "Zink Mini Splatling", + }, + { + text: "スプラスピナーPYTN", + name: "Mini Splatling RTL-R", + }, + { + text: "ノーチラス47", + name: "Nautilus 47", + }, + { + text: "ノーチラス79", + name: "Nautilus 79", + }, + { + text: "バレルスピナー", + name: "Heavy Splatling", + }, + { + text: "バレルスピナーデコ", + name: "Heavy Splatling Deco", + }, + { + text: "オーダースピナー レプリカ", + name: "Order Splatling Replica", + }, + { + text: "フルイドV", + name: "Wellstring V", + }, + { + text: "フルイドVカスタム", + name: "Custom Wellstring V", + }, + { + text: "トライストリンガー", + name: "Tri-Stringer", + }, + { + text: "トライストリンガーコラボ", + name: "Inkline Tri-Stringer", + }, + { + text: "トライストリンガー燈", + name: "Bulbz Tri-Stringer", + }, + { + text: "オーダーストリンガー レプリカ", + name: "Order Stringer Replica", + }, + { + text: "LACT-450", + name: "REEF-LUX 450", + }, + { + text: "LACT-450デコ", + name: "REEF-LUX 450 Deco", + }, + { + text: "LACT-450MILK", + name: "REEF-LUX 450 MIL-K", + }, + { + text: "ジャンプビーコン", + name: "Squid Beakon", + }, + { + text: "カーリングボム", + name: "Curling Bomb", + }, + { + text: "タンサンボム", + name: "Fizzy Bomb", + }, + { + text: "クイックボム", + name: "Burst Bomb", + }, + { + text: "ロボットボム", + name: "Autobomb", + }, + { + text: "スプラッシュボム", + name: "Splat Bomb", + }, + { + text: "キューバンボム", + name: "Suction Bomb", + }, + { + text: "トーピード", + name: "Torpedo", + }, + { + text: "ラインマーカー", + name: "Angle Shooter", + }, + { + text: "ポイントセンサー", + name: "Point Sensor", + }, + { + text: "ポイズンミスト", + name: "Toxic Mist", + }, + { + text: "スプラッシュシールド", + name: "Splash Wall", + }, + { + text: "スプリンクラー", + name: "Sprinkler", + }, + { + text: "トラップ", + name: "Ink Mine", + }, + { + text: "キューインキ", + name: "Ink Vac", + }, + { + text: "テイオウイカ", + name: "Kraken Royale", + }, + { + text: "カニタンク", + name: "Crab Tank", + }, + { + text: "スミナガシート", + name: "Splattercolor Screen", + }, + { + text: "エナジースタンド", + name: "Tacticooler", + }, + { + text: "デコイチラシ", + name: "Super Chump", + }, + { + text: "グレートバリア", + name: "Big Bubbler", + }, + { + text: "アメフラシ", + name: "Ink Storm", + }, + { + text: "ジェットパック", + name: "Inkjet", + }, + { + text: "メガホンレーザー5.1ch", + name: "Killer Wail 5.1", + }, + { + text: "マルチミサイル", + name: "Tenta Missiles", + }, + { + text: "ナイスダマ", + name: "Booyah Bomb", + }, + { + text: "ウルトラチャクチ", + name: "Triple Splashdown", + }, + { + text: "ホップソナー", + name: "Wave Breaker", + }, + { + text: "サメライド", + name: "Reefslider", + }, + { + text: "ショクワンダー", + name: "Zipcaster", + }, + { + text: "トリプルトルネード", + name: "Triple Inkstrike", + }, + { + text: "ウルトラショット", + name: "Trizooka", + }, + { + text: "ウルトラハンコ", + name: "Ultra Stamp", + }, + ], + KRko: [ + { + text: "R 블래스터 엘리트", + name: "Rapid Blaster Pro", + }, + { + text: "R 블래스터 엘리트 데코", + name: "Rapid Blaster Pro Deco", + }, + { + text: "R 블래스터 엘리트 WNTR", + name: "Rapid Blaster Pro WNT-R", + }, + { + text: "크래시 블래스터", + name: "Clash Blaster", + }, + { + text: "네오 크래시 블래스터", + name: "Clash Blaster Neo", + }, + { + text: "래피드 블래스터", + name: "Rapid Blaster", + }, + { + text: "래피드 블래스터 데코", + name: "Rapid Blaster Deco", + }, + { + text: "롱 블래스터", + name: "Range Blaster", + }, + { + text: "커스텀 롱 블래스터", + name: "Custom Range Blaster", + }, + { + text: "핫 블래스터", + name: "Blaster", + }, + { + text: "커스텀 핫 블래스터", + name: "Custom Blaster", + }, + { + text: "글림 핫 블래스터", + name: "Gleamz Blaster", + }, + { + text: "S-BLAST92", + name: "S-BLAST '92", + }, + { + text: "S-BLAST91", + name: "S-BLAST '91", + }, + { + text: "노바 블래스터", + name: "Luna Blaster", + }, + { + text: "네오 노바 블래스터", + name: "Luna Blaster Neo", + }, + { + text: "오더 블래스터 레플리카", + name: "Order Blaster Replica", + }, + { + text: "빈센트", + name: "Painbrush", + }, + { + text: "빈센트 휴", + name: "Painbrush Nouveau", + }, + { + text: "빈센트 BRNZ", + name: "Painbrush BRN-Z", + }, + { + text: "파블로", + name: "Inkbrush", + }, + { + text: "파블로 휴", + name: "Inkbrush Nouveau", + }, + { + text: "호쿠사이", + name: "Octobrush", + }, + { + text: "호쿠사이 휴", + name: "Octobrush Nouveau", + }, + { + text: "코메트 호쿠사이", + name: "Cometz Octobrush", + }, + { + text: "오더 브러시 레플리카", + name: "Orderbrush Replica", + }, + { + text: "소이 튜버", + name: "Goo Tuber", + }, + { + text: "커스텀 소이 튜버", + name: "Custom Goo Tuber", + }, + { + text: "14식 대나무 총 갑", + name: "Bamboozler 14 Mk I", + }, + { + text: "14식 대나무 총 을", + name: "Bamboozler 14 Mk II", + }, + { + text: "4K 스코프", + name: "E-liter 4K Scope", + }, + { + text: "커스텀 4K 스코프", + name: "Custom E-liter 4K Scope", + }, + { + text: "리터 4K", + name: "E-liter 4K", + }, + { + text: "커스텀 리터 4K", + name: "Custom E-liter 4K", + }, + { + text: "스플랫 스코프", + name: "Splatterscope", + }, + { + text: "스플랫 스코프 컬래버", + name: "Z+F Splatterscope", + }, + { + text: "스플랫 스코프 FRST", + name: "Splatterscope CAM-O", + }, + { + text: "스플랫 차저", + name: "Splat Charger", + }, + { + text: "스플랫 차저 컬래버", + name: "Z+F Splat Charger", + }, + { + text: "스플랫 차저 FRST", + name: "Splat Charger CAM-O", + }, + { + text: "오더 차저 레플리카", + name: "Order Charger Replica", + }, + { + text: "R-PEN/5H", + name: "Snipewriter 5H", + }, + { + text: "R-PEN/5B", + name: "Snipewriter 5B", + }, + { + text: "스퀵 클린 α", + name: "Classic Squiffer", + }, + { + text: "스퀵 클린 β", + name: "New Squiffer", + }, + { + text: "듀얼 스위퍼", + name: "Dualie Squelchers", + }, + { + text: "커스텀 듀얼 스위퍼", + name: "Custom Dualie Squelchers", + }, + { + text: "후프 듀얼 스위퍼", + name: "Hoofz Dualie Squelchers", + }, + { + text: "켈빈 525", + name: "Glooga Dualies", + }, + { + text: "켈빈 525 데코", + name: "Glooga Dualies Deco", + }, + { + text: "소방 FF", + name: "Douser Dualies FF", + }, + { + text: "커스텀 소방 FF", + name: "Custom Douser Dualies FF", + }, + { + text: "스플랫 머누버", + name: "Splat Dualies", + }, + { + text: "스플랫 머누버 컬래버", + name: "Enperry Splat Dualies", + }, + { + text: "트윙클 스플랫 머누버", + name: "Twinklez Splat Dualies", + }, + { + text: "오더 머누버 레플리카", + name: "Order Dualie Replicas", + }, + { + text: "스퍼터리", + name: "Dapple Dualies", + }, + { + text: "스퍼터리 휴", + name: "Dapple Dualies Nouveau", + }, + { + text: "스퍼터리 OWL", + name: "Dapple Dualies NOC-T", + }, + { + text: "블랙 쿼드 호퍼", + name: "Dark Tetra Dualies", + }, + { + text: "화이트 쿼드 호퍼", + name: "Light Tetra Dualies", + }, + { + text: "카본 롤러", + name: "Carbon Roller", + }, + { + text: "카본 롤러 데코", + name: "Carbon Roller Deco", + }, + { + text: "카본 롤러 ANGL", + name: "Carbon Roller ANG-L", + }, + { + text: "다이너모 롤러", + name: "Dynamo Roller", + }, + { + text: "골드 다이너모 롤러", + name: "Gold Dynamo Roller", + }, + { + text: "스타 다이너모 롤러", + name: "Starz Dynamo Roller", + }, + { + text: "베리어블 롤러", + name: "Flingza Roller", + }, + { + text: "포일 베리어블 롤러", + name: "Foil Flingza Roller", + }, + { + text: "스플랫 롤러", + name: "Splat Roller", + }, + { + text: "스플랫 롤러 컬래버", + name: "Krak-On Splat Roller", + }, + { + text: "오더 롤러 레플리카", + name: "Order Roller Replica", + }, + { + text: "와이드 롤러", + name: "Big Swig Roller", + }, + { + text: "와이드 롤러 컬래버", + name: "Big Swig Roller Express", + }, + { + text: "플래닛 와이드 롤러", + name: "Planetz Big Swig Roller", + }, + { + text: "민트 덴탈 와이퍼", + name: "Mint Decavitator", + }, + { + text: "잉크 덴탈 와이퍼", + name: "Charcoal Decavitator", + }, + { + text: "드라이브 와이퍼", + name: "Splatana Wiper", + }, + { + text: "드라이브 와이퍼 데코", + name: "Splatana Wiper Deco", + }, + { + text: "드라이브 와이퍼 RUST", + name: "Splatana Wiper RUS-T", + }, + { + text: "사무 와이퍼", + name: "Splatana Stamper", + }, + { + text: "사무 와이퍼 휴", + name: "Splatana Stamper Nouveau", + }, + { + text: "스티커 사무 와이퍼", + name: "Stickerz Splatana Stamper", + }, + { + text: "오더 와이퍼 레플리카", + name: "Order Splatana Replica", + }, + { + text: "스파이 가젯", + name: "Undercover Brella", + }, + { + text: "스파이 가젯 소렐라", + name: "Undercover Sorella Brella", + }, + { + text: "패턴 스파이 가젯", + name: "Patternz Undercover Brella", + }, + { + text: "24식 도돌이 우산 갑", + name: "Recycled Brella 24 Mk I", + }, + { + text: "24식 도돌이 우산 을", + name: "Recycled Brella 24 Mk II", + }, + { + text: "파라 셸터", + name: "Splat Brella", + }, + { + text: "파라 셸터 소렐라", + name: "Sorella Brella", + }, + { + text: "오더 셸터 레플리카", + name: "Order Brella Replica", + }, + { + text: "캠핑 셸터", + name: "Tenta Brella", + }, + { + text: "캠핑 셸터 소렐라", + name: "Tenta Sorella Brella", + }, + { + text: "캠핑 셸터 CREM", + name: "Tenta Brella CRE-M", + }, + { + text: "프로모델러 MG", + name: "Aerospray MG", + }, + { + text: "프로모델러 RG", + name: "Aerospray RG", + }, + { + text: "컬러 프로모델러", + name: "Colorz Aerospray", + }, + { + text: "프라임 슈터", + name: "Splattershot Pro", + }, + { + text: "프라임 슈터 컬래버", + name: "Forge Splattershot Pro", + }, + { + text: "프라임 슈터 FRZN", + name: "Splattershot Pro FRZ-N", + }, + { + text: "새싹 슈터", + name: "Splattershot Jr.", + }, + { + text: "단풍 슈터", + name: "Custom Splattershot Jr.", + }, + { + text: "보틀 가이저", + name: "Squeezer", + }, + { + text: "포일 보틀 가이저", + name: "Foil Squeezer", + }, + { + text: ".52 갤런", + name: ".52 Gal", + }, + { + text: ".52 갤런 데코", + name: ".52 Gal Deco", + }, + { + text: ".96 갤런", + name: ".96 Gal", + }, + { + text: ".96 갤런 데코", + name: ".96 Gal Deco", + }, + { + text: "클로 .96 갤런", + name: "Clawz .96 Gal", + }, + { + text: "제트 스위퍼", + name: "Jet Squelcher", + }, + { + text: "커스텀 제트 스위퍼", + name: "Custom Jet Squelcher", + }, + { + text: "제트 스위퍼 COBR", + name: "Jet Squelcher COB-R", + }, + { + text: "스플랫 슈터", + name: "Splattershot", + }, + { + text: "스플랫 슈터 컬래버", + name: "Tentatek Splattershot", + }, + { + text: "글램 스플랫 슈터", + name: "Glamorz Splattershot", + }, + { + text: "히어로 슈터 레플리카", + name: "Hero Shot Replica", + }, + { + text: "오더 슈터 레플리카", + name: "Order Shot Replica", + }, + { + text: "옥타 슈터 레플리카", + name: "Octo Shot Replica", + }, + { + text: "샤프 마커", + name: "Splash-o-matic", + }, + { + text: "네오 샤프 마커", + name: "Neo Splash-o-matic", + }, + { + text: "샤프 마커 GECK", + name: "Splash-o-matic GCK-O", + }, + { + text: "스페이스 슈터", + name: "Splattershot Nova", + }, + { + text: "스페이스 슈터 컬래버", + name: "Annaki Splattershot Nova", + }, + { + text: "N-ZAP85", + name: "N-ZAP '85", + }, + { + text: "N-ZAP89", + name: "N-ZAP '89", + }, + { + text: "볼드 마커", + name: "Sploosh-o-matic", + }, + { + text: "네오 볼드 마커", + name: "Neo Sploosh-o-matic", + }, + { + text: "H3 릴 건", + name: "H-3 Nozzlenose", + }, + { + text: "H3 릴 건 D", + name: "H-3 Nozzlenose D", + }, + { + text: "H3 릴 건 SNAK", + name: "H-3 Nozzlenose VIP-R", + }, + { + text: "L3 릴 건", + name: "L-3 Nozzlenose", + }, + { + text: "L3 릴 건 D", + name: "L-3 Nozzlenose D", + }, + { + text: "글리터 L3 릴 건", + name: "Glitterz L-3 Nozzlenose", + }, + { + text: "오버플로셔", + name: "Bloblobber", + }, + { + text: "오버플로셔 데코", + name: "Bloblobber Deco", + }, + { + text: "물통", + name: "Tri-Slosher", + }, + { + text: "물통 휴", + name: "Tri-Slosher Nouveau", + }, + { + text: "물통 ASH", + name: "Tri-Slosher ASH-N", + }, + { + text: "몹 링", + name: "Dread Wringer", + }, + { + text: "몹 링 D", + name: "Dread Wringer D", + }, + { + text: "혼 몹 링", + name: "Hornz Dread Wringer", + }, + { + text: "스크루 슬로셔", + name: "Sloshing Machine", + }, + { + text: "네오 스크루 슬로셔", + name: "Sloshing Machine Neo", + }, + { + text: "버킷 슬로셔", + name: "Slosher", + }, + { + text: "버킷 슬로셔 데코", + name: "Slosher Deco", + }, + { + text: "오더 슬로셔 레플리카", + name: "Order Slosher Replica", + }, + { + text: "익스플로셔", + name: "Explosher", + }, + { + text: "커스텀 익스플로셔", + name: "Custom Explosher", + }, + { + text: "쿠겔 슈라이버", + name: "Ballpoint Splatling", + }, + { + text: "쿠겔 슈라이버 휴", + name: "Ballpoint Splatling Nouveau", + }, + { + text: "이그재미너", + name: "Heavy Edit Splatling", + }, + { + text: "이그재미너 휴", + name: "Heavy Edit Splatling Nouveau", + }, + { + text: "하이드런트", + name: "Hydra Splatling", + }, + { + text: "커스텀 하이드런트", + name: "Custom Hydra Splatling", + }, + { + text: "토렌트 하이드런트", + name: "Torrentz Hydra Splatling", + }, + { + text: "스플랫 스피너", + name: "Mini Splatling", + }, + { + text: "스플랫 스피너 컬래버", + name: "Zink Mini Splatling", + }, + { + text: "스플랫 스피너 PYTN", + name: "Mini Splatling RTL-R", + }, + { + text: "노틸러스 47", + name: "Nautilus 47", + }, + { + text: "노틸러스 79", + name: "Nautilus 79", + }, + { + text: "배럴 스피너", + name: "Heavy Splatling", + }, + { + text: "배럴 스피너 데코", + name: "Heavy Splatling Deco", + }, + { + text: "오더 스피너 레플리카", + name: "Order Splatling Replica", + }, + { + text: "플루이드 V", + name: "Wellstring V", + }, + { + text: "커스텀 플루이드 V", + name: "Custom Wellstring V", + }, + { + text: "트라이 스트링거", + name: "Tri-Stringer", + }, + { + text: "트라이 스트링거 컬래버", + name: "Inkline Tri-Stringer", + }, + { + text: "벌브 트라이 스트링거", + name: "Bulbz Tri-Stringer", + }, + { + text: "오더 스트링거 레플리카", + name: "Order Stringer Replica", + }, + { + text: "LACT-450", + name: "REEF-LUX 450", + }, + { + text: "LACT-450 데코", + name: "REEF-LUX 450 Deco", + }, + { + text: "LACT-450 MILK", + name: "REEF-LUX 450 MIL-K", + }, + { + text: "점프 비컨", + name: "Squid Beakon", + }, + { + text: "컬링 밤", + name: "Curling Bomb", + }, + { + text: "탄산 밤", + name: "Fizzy Bomb", + }, + { + text: "퀵 밤", + name: "Burst Bomb", + }, + { + text: "로봇 밤", + name: "Autobomb", + }, + { + text: "스플래시 밤", + name: "Splat Bomb", + }, + { + text: "빨판 밤", + name: "Suction Bomb", + }, + { + text: "토피도", + name: "Torpedo", + }, + { + text: "라인 마커", + name: "Angle Shooter", + }, + { + text: "포인트 센서", + name: "Point Sensor", + }, + { + text: "포이즌 미스트", + name: "Toxic Mist", + }, + { + text: "스플래시 실드", + name: "Splash Wall", + }, + { + text: "스프링클러", + name: "Sprinkler", + }, + { + text: "트랩", + name: "Ink Mine", + }, + { + text: "흡입기", + name: "Ink Vac", + }, + { + text: "로열 크라켄", + name: "Kraken Royale", + }, + { + text: "크랩 탱크", + name: "Crab Tank", + }, + { + text: "스플래터컬러 스크린", + name: "Splattercolor Screen", + }, + { + text: "에너지 스탠드", + name: "Tacticooler", + }, + { + text: "디코이 캐넌", + name: "Super Chump", + }, + { + text: "그레이트 배리어", + name: "Big Bubbler", + }, + { + text: "먹구름", + name: "Ink Storm", + }, + { + text: "제트 팩", + name: "Inkjet", + }, + { + text: "메가폰 레이저 5.1ch", + name: "Killer Wail 5.1", + }, + { + text: "멀티 미사일", + name: "Tenta Missiles", + }, + { + text: "나이스옥", + name: "Booyah Bomb", + }, + { + text: "울트라 착지", + name: "Triple Splashdown", + }, + { + text: "홉 소나", + name: "Wave Breaker", + }, + { + text: "샤크 라이드", + name: "Reefslider", + }, + { + text: "쇼크 원더", + name: "Zipcaster", + }, + { + text: "트리플 토네이도", + name: "Triple Inkstrike", + }, + { + text: "울트라 샷", + name: "Trizooka", + }, + { + text: "울트라 스탬프", + name: "Ultra Stamp", + }, + ], + TWzh: [ + { + text: "快速爆破槍 精英", + name: "Rapid Blaster Pro", + }, + { + text: "快速爆破槍 精英裝飾", + name: "Rapid Blaster Pro Deco", + }, + { + text: "快速爆破槍 精英WNTR", + name: "Rapid Blaster Pro WNT-R", + }, + { + text: "衝塗爆破槍", + name: "Clash Blaster", + }, + { + text: "衝塗爆破槍 新型", + name: "Clash Blaster Neo", + }, + { + text: "快速爆破槍", + name: "Rapid Blaster", + }, + { + text: "快速爆破槍 裝飾", + name: "Rapid Blaster Deco", + }, + { + text: "遠距爆破槍", + name: "Range Blaster", + }, + { + text: "遠距爆破槍 改裝", + name: "Custom Range Blaster", + }, + { + text: "火熱爆破槍", + name: "Blaster", + }, + { + text: "火熱爆破槍 改裝", + name: "Custom Blaster", + }, + { + text: "火熱爆破槍 艷", + name: "Gleamz Blaster", + }, + { + text: "S-BLAST92", + name: "S-BLAST '92", + }, + { + text: "S-BLAST91", + name: "S-BLAST '91", + }, + { + text: "新星爆破槍", + name: "Luna Blaster", + }, + { + text: "新星爆破槍 新型", + name: "Luna Blaster Neo", + }, + { + text: "秩序爆破槍 複製", + name: "Order Blaster Replica", + }, + { + text: "文森", + name: "Painbrush", + }, + { + text: "文森‧新藝術", + name: "Painbrush Nouveau", + }, + { + text: "文森BRNZ", + name: "Painbrush BRN-Z", + }, + { + text: "巴勃羅", + name: "Inkbrush", + }, + { + text: "巴勃羅‧新藝術", + name: "Inkbrush Nouveau", + }, + { + text: "北齋", + name: "Octobrush", + }, + { + text: "北齋‧新藝術", + name: "Octobrush Nouveau", + }, + { + text: "北齋 彗", + name: "Cometz Octobrush", + }, + { + text: "秩序排刷 複製", + name: "Orderbrush Replica", + }, + { + text: "高壓油管槍", + name: "Goo Tuber", + }, + { + text: "高壓油管槍 改裝", + name: "Custom Goo Tuber", + }, + { + text: "14式竹筒槍‧甲", + name: "Bamboozler 14 Mk I", + }, + { + text: "14式竹筒槍‧乙", + name: "Bamboozler 14 Mk II", + }, + { + text: "4K準星槍", + name: "E-liter 4K Scope", + }, + { + text: "4K準星槍 改裝", + name: "Custom E-liter 4K Scope", + }, + { + text: "公升4K", + name: "E-liter 4K", + }, + { + text: "公升4K 改裝", + name: "Custom E-liter 4K", + }, + { + text: "斯普拉準星槍", + name: "Splatterscope", + }, + { + text: "斯普拉準星槍 聯名", + name: "Z+F Splatterscope", + }, + { + text: "斯普拉準星槍FRST", + name: "Splatterscope CAM-O", + }, + { + text: "斯普拉蓄力狙擊槍", + name: "Splat Charger", + }, + { + text: "斯普拉蓄力狙擊槍 聯名", + name: "Z+F Splat Charger", + }, + { + text: "斯普拉蓄力狙擊槍FRST", + name: "Splat Charger CAM-O", + }, + { + text: "秩序蓄力狙擊槍 複製", + name: "Order Charger Replica", + }, + { + text: "R-PEN/5H", + name: "Snipewriter 5H", + }, + { + text: "R-PEN/5B", + name: "Snipewriter 5B", + }, + { + text: "魷快潔α", + name: "Classic Squiffer", + }, + { + text: "魷快潔β", + name: "New Squiffer", + }, + { + text: "雙重清潔槍", + name: "Dualie Squelchers", + }, + { + text: "雙重清潔槍 改裝", + name: "Custom Dualie Squelchers", + }, + { + text: "雙重清潔槍 蹄", + name: "Hoofz Dualie Squelchers", + }, + { + text: "開爾文525", + name: "Glooga Dualies", + }, + { + text: "開爾文525 裝飾", + name: "Glooga Dualies Deco", + }, + { + text: "滅熄FF", + name: "Douser Dualies FF", + }, + { + text: "滅熄FF 改裝", + name: "Custom Douser Dualies FF", + }, + { + text: "斯普拉機動槍", + name: "Splat Dualies", + }, + { + text: "斯普拉機動槍 聯名", + name: "Enperry Splat Dualies", + }, + { + text: "斯普拉機動槍 耀", + name: "Twinklez Splat Dualies", + }, + { + text: "秩序機動槍 複製", + name: "Order Dualie Replicas", + }, + { + text: "濺鍍槍", + name: "Dapple Dualies", + }, + { + text: "濺鍍槍‧新藝術", + name: "Dapple Dualies Nouveau", + }, + { + text: "濺鍍槍OWL", + name: "Dapple Dualies NOC-T", + }, + { + text: "四重彈跳手槍 黑", + name: "Dark Tetra Dualies", + }, + { + text: "四重彈跳手槍 白", + name: "Light Tetra Dualies", + }, + { + text: "碳纖維滾筒", + name: "Carbon Roller", + }, + { + text: "碳纖維滾筒 裝飾", + name: "Carbon Roller Deco", + }, + { + text: "碳纖維滾筒ANGL", + name: "Carbon Roller ANG-L", + }, + { + text: "電動馬達滾筒", + name: "Dynamo Roller", + }, + { + text: "電動馬達滾筒 高磁波", + name: "Gold Dynamo Roller", + }, + { + text: "電動馬達滾筒 冥", + name: "Starz Dynamo Roller", + }, + { + text: "可變式滾筒", + name: "Flingza Roller", + }, + { + text: "可變式滾筒 金屬箔", + name: "Foil Flingza Roller", + }, + { + text: "斯普拉滾筒", + name: "Splat Roller", + }, + { + text: "斯普拉滾筒 聯名", + name: "Krak-On Splat Roller", + }, + { + text: "秩序滾筒 複製", + name: "Order Roller Replica", + }, + { + text: "寬滾筒", + name: "Big Swig Roller", + }, + { + text: "寬滾筒 聯名", + name: "Big Swig Roller Express", + }, + { + text: "寬滾筒 惑", + name: "Planetz Big Swig Roller", + }, + { + text: "巨齒刮水刀 薄荷", + name: "Mint Decavitator", + }, + { + text: "巨齒刮水刀 墨黑", + name: "Charcoal Decavitator", + }, + { + text: "雨刷刮水刀", + name: "Splatana Wiper", + }, + { + text: "雨刷刮水刀 裝飾", + name: "Splatana Wiper Deco", + }, + { + text: "雨刷刮水刀RUST", + name: "Splatana Wiper RUS-T", + }, + { + text: "工作刮水刀", + name: "Splatana Stamper", + }, + { + text: "工作刮水刀‧新藝術", + name: "Splatana Stamper Nouveau", + }, + { + text: "工作刮水刀 封", + name: "Stickerz Splatana Stamper", + }, + { + text: "秩序刮水刀 複製", + name: "Order Splatana Replica", + }, + { + text: "特務配件", + name: "Undercover Brella", + }, + { + text: "特務配件 姐妹", + name: "Undercover Sorella Brella", + }, + { + text: "特務配件 繚", + name: "Patternz Undercover Brella", + }, + { + text: "24式可替換傘‧甲", + name: "Recycled Brella 24 Mk I", + }, + { + text: "24式可替換傘‧乙", + name: "Recycled Brella 24 Mk II", + }, + { + text: "遮陽防空傘", + name: "Splat Brella", + }, + { + text: "遮陽防空傘 姐妹", + name: "Sorella Brella", + }, + { + text: "秩序防空傘 複製", + name: "Order Brella Replica", + }, + { + text: "露營防空傘", + name: "Tenta Brella", + }, + { + text: "露營防空傘 姐妹", + name: "Tenta Sorella Brella", + }, + { + text: "露營防空傘CREM", + name: "Tenta Brella CRE-M", + }, + { + text: "專業模型槍MG", + name: "Aerospray MG", + }, + { + text: "專業模型槍RG", + name: "Aerospray RG", + }, + { + text: "專業模型槍 彩", + name: "Colorz Aerospray", + }, + { + text: "頂尖射擊槍", + name: "Splattershot Pro", + }, + { + text: "頂尖射擊槍 聯名", + name: "Forge Splattershot Pro", + }, + { + text: "頂尖射擊槍FRZN", + name: "Splattershot Pro FRZ-N", + }, + { + text: "新葉射擊槍", + name: "Splattershot Jr.", + }, + { + text: "楓葉射擊槍", + name: "Custom Splattershot Jr.", + }, + { + text: "開瓶噴泉槍", + name: "Squeezer", + }, + { + text: "開瓶噴泉槍 金屬箔", + name: "Foil Squeezer", + }, + { + text: ".52加侖", + name: ".52 Gal", + }, + { + text: ".52加侖 裝飾", + name: ".52 Gal Deco", + }, + { + text: ".96加侖", + name: ".96 Gal", + }, + { + text: ".96加侖 裝飾", + name: ".96 Gal Deco", + }, + { + text: ".96加侖 爪", + name: "Clawz .96 Gal", + }, + { + text: "噴射清潔槍", + name: "Jet Squelcher", + }, + { + text: "噴射清潔槍 改裝", + name: "Custom Jet Squelcher", + }, + { + text: "噴射清潔槍COBR", + name: "Jet Squelcher COB-R", + }, + { + text: "斯普拉射擊槍", + name: "Splattershot", + }, + { + text: "斯普拉射擊槍 聯名", + name: "Tentatek Splattershot", + }, + { + text: "斯普拉射擊槍 煌", + name: "Glamorz Splattershot", + }, + { + text: "英雄射擊槍 複製", + name: "Hero Shot Replica", + }, + { + text: "秩序射擊槍 複製", + name: "Order Shot Replica", + }, + { + text: "章魚射擊槍 複製", + name: "Octo Shot Replica", + }, + { + text: "窄域標記槍", + name: "Splash-o-matic", + }, + { + text: "窄域標記槍 新型", + name: "Neo Splash-o-matic", + }, + { + text: "窄域標記槍GECK", + name: "Splash-o-matic GCK-O", + }, + { + text: "太空射擊槍", + name: "Splattershot Nova", + }, + { + text: "太空射擊槍 聯名", + name: "Annaki Splattershot Nova", + }, + { + text: "N-ZAP85", + name: "N-ZAP '85", + }, + { + text: "N-ZAP89", + name: "N-ZAP '89", + }, + { + text: "廣域標記槍", + name: "Sploosh-o-matic", + }, + { + text: "廣域標記槍 新型", + name: "Neo Sploosh-o-matic", + }, + { + text: "H3捲管槍", + name: "H-3 Nozzlenose", + }, + { + text: "H3捲管槍D", + name: "H-3 Nozzlenose D", + }, + { + text: "H3捲管槍SNAK", + name: "H-3 Nozzlenose VIP-R", + }, + { + text: "L3捲管槍", + name: "L-3 Nozzlenose", + }, + { + text: "L3捲管槍D", + name: "L-3 Nozzlenose D", + }, + { + text: "L3捲管槍 箔", + name: "Glitterz L-3 Nozzlenose", + }, + { + text: "滿溢泡澡潑桶", + name: "Bloblobber", + }, + { + text: "滿溢泡澡潑桶 裝飾", + name: "Bloblobber Deco", + }, + { + text: "洗筆桶", + name: "Tri-Slosher", + }, + { + text: "洗筆桶‧新藝術", + name: "Tri-Slosher Nouveau", + }, + { + text: "洗筆桶ASH", + name: "Tri-Slosher ASH-N", + }, + { + text: "墨瀑淋", + name: "Dread Wringer", + }, + { + text: "墨瀑淋D", + name: "Dread Wringer D", + }, + { + text: "墨瀑淋 角", + name: "Hornz Dread Wringer", + }, + { + text: "迴旋潑桶", + name: "Sloshing Machine", + }, + { + text: "迴旋潑桶 新型", + name: "Sloshing Machine Neo", + }, + { + text: "飛濺潑桶", + name: "Slosher", + }, + { + text: "飛濺潑桶 裝飾", + name: "Slosher Deco", + }, + { + text: "秩序潑桶 複製", + name: "Order Slosher Replica", + }, + { + text: "爆炸潑桶", + name: "Explosher", + }, + { + text: "爆炸潑桶 改裝", + name: "Custom Explosher", + }, + { + text: "圓珠筆", + name: "Ballpoint Splatling", + }, + { + text: "圓珠筆‧新藝術", + name: "Ballpoint Splatling Nouveau", + }, + { + text: "審查者", + name: "Heavy Edit Splatling", + }, + { + text: "審查者‧新藝術", + name: "Heavy Edit Splatling Nouveau", + }, + { + text: "消防栓旋轉槍", + name: "Hydra Splatling", + }, + { + text: "消防栓旋轉槍 改裝", + name: "Custom Hydra Splatling", + }, + { + text: "消防栓旋轉槍 壓", + name: "Torrentz Hydra Splatling", + }, + { + text: "斯普拉旋轉槍", + name: "Mini Splatling", + }, + { + text: "斯普拉旋轉槍 聯名", + name: "Zink Mini Splatling", + }, + { + text: "斯普拉旋轉槍PYTN", + name: "Mini Splatling RTL-R", + }, + { + text: "鸚鵡螺號47", + name: "Nautilus 47", + }, + { + text: "鸚鵡螺號79", + name: "Nautilus 79", + }, + { + text: "桶裝旋轉槍", + name: "Heavy Splatling", + }, + { + text: "桶裝旋轉槍 裝飾", + name: "Heavy Splatling Deco", + }, + { + text: "秩序旋轉槍 複製", + name: "Order Splatling Replica", + }, + { + text: "邦普V", + name: "Wellstring V", + }, + { + text: "邦普V 改裝", + name: "Custom Wellstring V", + }, + { + text: "三發獵魚弓", + name: "Tri-Stringer", + }, + { + text: "三發獵魚弓 聯名", + name: "Inkline Tri-Stringer", + }, + { + text: "三發獵魚弓 燈", + name: "Bulbz Tri-Stringer", + }, + { + text: "秩序獵魚弓 複製", + name: "Order Stringer Replica", + }, + { + text: "LACT-450", + name: "REEF-LUX 450", + }, + { + text: "LACT-450 裝飾", + name: "REEF-LUX 450 Deco", + }, + { + text: "LACT-450MILK", + name: "REEF-LUX 450 MIL-K", + }, + { + text: "跳躍信標", + name: "Squid Beakon", + }, + { + text: "冰壺炸彈", + name: "Curling Bomb", + }, + { + text: "碳酸炸彈", + name: "Fizzy Bomb", + }, + { + text: "快速炸彈", + name: "Burst Bomb", + }, + { + text: "機器人炸彈", + name: "Autobomb", + }, + { + text: "斯普拉炸彈", + name: "Splat Bomb", + }, + { + text: "吸盤炸彈", + name: "Suction Bomb", + }, + { + text: "魚雷", + name: "Torpedo", + }, + { + text: "標線器", + name: "Angle Shooter", + }, + { + text: "定點偵測器", + name: "Point Sensor", + }, + { + text: "毒霧", + name: "Toxic Mist", + }, + { + text: "斯普拉防護牆", + name: "Splash Wall", + }, + { + text: "灑墨器", + name: "Sprinkler", + }, + { + text: "墨汁陷阱", + name: "Ink Mine", + }, + { + text: "吸墨機", + name: "Ink Vac", + }, + { + text: "帝王魷魚", + name: "Kraken Royale", + }, + { + text: "螃蟹坦克", + name: "Crab Tank", + }, + { + text: "浮墨幕牆", + name: "Splattercolor Screen", + }, + { + text: "能量站", + name: "Tacticooler", + }, + { + text: "誘餌煙火", + name: "Super Chump", + }, + { + text: "巨大防護罩", + name: "Big Bubbler", + }, + { + text: "墨雨雲", + name: "Ink Storm", + }, + { + text: "噴射背包", + name: "Inkjet", + }, + { + text: "喇叭雷射5.1ch", + name: "Killer Wail 5.1", + }, + { + text: "多重導彈", + name: "Tenta Missiles", + }, + { + text: "讚氣彈", + name: "Booyah Bomb", + }, + { + text: "終極著陸", + name: "Triple Splashdown", + }, + { + text: "彈跳聲納", + name: "Wave Breaker", + }, + { + text: "鯊魚坐騎", + name: "Reefslider", + }, + { + text: "觸手噴射", + name: "Zipcaster", + }, + { + text: "三重龍捲風", + name: "Triple Inkstrike", + }, + { + text: "終極發射", + name: "Trizooka", + }, + { + text: "終極印章", + name: "Ultra Stamp", + }, + ], + USes: [ + { + text: "Turbolanzamotas Pro", + name: "Rapid Blaster Pro", + }, + { + text: "Turbolanzamotas Pro chic", + name: "Rapid Blaster Pro Deco", + }, + { + text: "Turbolanzamotas INV", + name: "Rapid Blaster Pro WNT-R", + }, + { + text: "Hiperlanzamotas", + name: "Clash Blaster", + }, + { + text: "Hiperlanzamotas neo", + name: "Clash Blaster Neo", + }, + { + text: "Turbolanzamotas", + name: "Rapid Blaster", + }, + { + text: "Turbolanzamotas chic", + name: "Rapid Blaster Deco", + }, + { + text: "Telelanzamotas", + name: "Range Blaster", + }, + { + text: "Telelanzamotas adaptado", + name: "Custom Range Blaster", + }, + { + text: "Lanzamotas", + name: "Blaster", + }, + { + text: "Lanzamotas adaptado", + name: "Custom Blaster", + }, + { + text: "Lanzamotas DSX", + name: "Gleamz Blaster", + }, + { + text: "L-MOT 92", + name: "S-BLAST '92", + }, + { + text: "L-MOT 91", + name: "S-BLAST '91", + }, + { + text: "Lanzamotas Nova", + name: "Luna Blaster", + }, + { + text: "Lanzamotas Nova neo", + name: "Luna Blaster Neo", + }, + { + text: "Lanzamotas orden réplica", + name: "Order Blaster Replica", + }, + { + text: "Pincel abanico", + name: "Painbrush", + }, + { + text: "Pincel abanico maestro", + name: "Painbrush Nouveau", + }, + { + text: "Pincel abanico BNC", + name: "Painbrush BRN-Z", + }, + { + text: "Pincel", + name: "Inkbrush", + }, + { + text: "Pincel maestro", + name: "Inkbrush Nouveau", + }, + { + text: "Pincel caligrafía", + name: "Octobrush", + }, + { + text: "Pincel caligrafía maestro", + name: "Octobrush Nouveau", + }, + { + text: "Pincel caligrafía CMX", + name: "Cometz Octobrush", + }, + { + text: "Pincel caligrafía orden réplica", + name: "Orderbrush Replica", + }, + { + text: "Entintubo", + name: "Goo Tuber", + }, + { + text: "Entintubo adaptado", + name: "Custom Goo Tuber", + }, + { + text: "Bamzuka 14-I", + name: "Bamboozler 14 Mk I", + }, + { + text: "Bamzuka 14-II", + name: "Bamboozler 14 Mk II", + }, + { + text: "Telentintador 4K", + name: "E-liter 4K Scope", + }, + { + text: "Telentintador 4K adaptado", + name: "Custom E-liter 4K Scope", + }, + { + text: "Entintador 4K", + name: "E-liter 4K", + }, + { + text: "Entintador 4K adaptado", + name: "Custom E-liter 4K", + }, + { + text: "Telecargatintas", + name: "Splatterscope", + }, + { + text: "Telecargatintas ZyS", + name: "Z+F Splatterscope", + }, + { + text: "Telecargatintas BSQ", + name: "Splatterscope CAM-O", + }, + { + text: "Cargatintas", + name: "Splat Charger", + }, + { + text: "Cargatintas ZyS", + name: "Z+F Splat Charger", + }, + { + text: "Cargatintas BSQ", + name: "Splat Charger CAM-O", + }, + { + text: "Cargatintas orden réplica", + name: "Order Charger Replica", + }, + { + text: "Cargaminas 5H", + name: "Snipewriter 5H", + }, + { + text: "Cargaminas 5B", + name: "Snipewriter 5B", + }, + { + text: "Delineador X", + name: "Classic Squiffer", + }, + { + text: "Delineador Y", + name: "New Squiffer", + }, + { + text: "Soplador dual", + name: "Dualie Squelchers", + }, + { + text: "Soplador dual adaptado", + name: "Custom Dualie Squelchers", + }, + { + text: "Soplador dual RYX", + name: "Hoofz Dualie Squelchers", + }, + { + text: "Fundidora 525 dual", + name: "Glooga Dualies", + }, + { + text: "Fundidora 525 dual chic", + name: "Glooga Dualies Deco", + }, + { + text: "Extintor dual FF", + name: "Douser Dualies FF", + }, + { + text: "Extintor dual FF adaptado", + name: "Custom Douser Dualies FF", + }, + { + text: "Difusor dual", + name: "Splat Dualies", + }, + { + text: "Difusor dual Emperi", + name: "Enperry Splat Dualies", + }, + { + text: "Difusor dual CHX", + name: "Twinklez Splat Dualies", + }, + { + text: "Difusor dual orden réplica", + name: "Order Dualie Replicas", + }, + { + text: "Atomizador dual", + name: "Dapple Dualies", + }, + { + text: "Atomizador dual maestro", + name: "Dapple Dualies Nouveau", + }, + { + text: "Atomizador dual NCT", + name: "Dapple Dualies NOC-T", + }, + { + text: "Tetra dual negro", + name: "Dark Tetra Dualies", + }, + { + text: "Tetra dual blanco", + name: "Light Tetra Dualies", + }, + { + text: "Rodillo de carbono", + name: "Carbon Roller", + }, + { + text: "Rodillo de carbono chic", + name: "Carbon Roller Deco", + }, + { + text: "Rodillo de carbono ABL", + name: "Carbon Roller ANG-L", + }, + { + text: "Rodillo Pro", + name: "Dynamo Roller", + }, + { + text: "Rodillo Pro dorado", + name: "Gold Dynamo Roller", + }, + { + text: "Rodillo Pro CSX", + name: "Starz Dynamo Roller", + }, + { + text: "Rodillo versátil", + name: "Flingza Roller", + }, + { + text: "Rodillo versátil Foil", + name: "Foil Flingza Roller", + }, + { + text: "Rodillo básico", + name: "Splat Roller", + }, + { + text: "Rodillo básico Meddux", + name: "Krak-On Splat Roller", + }, + { + text: "Rodillo orden réplica", + name: "Order Roller Replica", + }, + { + text: "Rodillo mágnum", + name: "Big Swig Roller", + }, + { + text: "Rodillo mágnum exprés", + name: "Big Swig Roller Express", + }, + { + text: "Rodillo mágnum PLX", + name: "Planetz Big Swig Roller", + }, + { + text: "Odontintador menta", + name: "Mint Decavitator", + }, + { + text: "Odontintador carbón", + name: "Charcoal Decavitator", + }, + { + text: "Azotintador brisa", + name: "Splatana Wiper", + }, + { + text: "Azotintador brisa chic", + name: "Splatana Wiper Deco", + }, + { + text: "Azotintador brisa OXD", + name: "Splatana Wiper RUS-T", + }, + { + text: "Azotintador Pro", + name: "Splatana Stamper", + }, + { + text: "Azotintador Pro maestro", + name: "Splatana Stamper Nouveau", + }, + { + text: "Azotintador Pro CLX", + name: "Stickerz Splatana Stamper", + }, + { + text: "Azotintador orden réplica", + name: "Order Splatana Replica", + }, + { + text: "Paratintas discreto", + name: "Undercover Brella", + }, + { + text: "Paratintas discreto Sorella", + name: "Undercover Sorella Brella", + }, + { + text: "Paratintas discreto PTX", + name: "Patternz Undercover Brella", + }, + { + text: "Prototintas 24-I", + name: "Recycled Brella 24 Mk I", + }, + { + text: "Prototintas 24-II", + name: "Recycled Brella 24 Mk II", + }, + { + text: "Paratintas", + name: "Splat Brella", + }, + { + text: "Paratintas Sorella", + name: "Sorella Brella", + }, + { + text: "Paratintas orden réplica", + name: "Order Brella Replica", + }, + { + text: "Paratintas forestal", + name: "Tenta Brella", + }, + { + text: "Paratintas forestal Sorella", + name: "Tenta Sorella Brella", + }, + { + text: "Paratintas forestal CRM", + name: "Tenta Brella CRE-M", + }, + { + text: "Aerógrafo GP", + name: "Aerospray MG", + }, + { + text: "Aerógrafo GN", + name: "Aerospray RG", + }, + { + text: "Aerógrafo ARX", + name: "Colorz Aerospray", + }, + { + text: "Rociador Pro", + name: "Splattershot Pro", + }, + { + text: "Rociador Forima", + name: "Forge Splattershot Pro", + }, + { + text: "Rociador Pro GLD", + name: "Splattershot Pro FRZ-N", + }, + { + text: "Rociador básico", + name: "Splattershot Jr.", + }, + { + text: "Rociador básico adaptado", + name: "Custom Splattershot Jr.", + }, + { + text: "Tintopresor", + name: "Squeezer", + }, + { + text: "Tintopresor Foil", + name: "Foil Squeezer", + }, + { + text: "Salpicadora 2000", + name: ".52 Gal", + }, + { + text: "Salpicadora 2000 chic", + name: ".52 Gal Deco", + }, + { + text: "Salpicadora 3000", + name: ".96 Gal", + }, + { + text: "Salpicadora 3000 chic", + name: ".96 Gal Deco", + }, + { + text: "Salpicadora 3000 GRX", + name: "Clawz .96 Gal", + }, + { + text: "Megasoplador", + name: "Jet Squelcher", + }, + { + text: "Megasoplador adaptado", + name: "Custom Jet Squelcher", + }, + { + text: "Megasoplador CBR", + name: "Jet Squelcher COB-R", + }, + { + text: "Rociador", + name: "Splattershot", + }, + { + text: "Rociador Tentáctica", + name: "Tentatek Splattershot", + }, + { + text: "Rociador GLX", + name: "Glamorz Splattershot", + }, + { + text: "Rociador de élite réplica", + name: "Hero Shot Replica", + }, + { + text: "Rociador orden réplica", + name: "Order Shot Replica", + }, + { + text: "Rociador octariano réplica", + name: "Octo Shot Replica", + }, + { + text: "Plumón", + name: "Splash-o-matic", + }, + { + text: "Plumón neo", + name: "Neo Splash-o-matic", + }, + { + text: "Plumón GCO", + name: "Splash-o-matic GCK-O", + }, + { + text: "Rociador espacial", + name: "Splattershot Nova", + }, + { + text: "Rociador espacial Annak", + name: "Annaki Splattershot Nova", + }, + { + text: "N-ZAP 85", + name: "N-ZAP '85", + }, + { + text: "N-ZAP 89", + name: "N-ZAP '89", + }, + { + text: "Marcador grueso", + name: "Sploosh-o-matic", + }, + { + text: "Marcador grueso neo", + name: "Neo Sploosh-o-matic", + }, + { + text: "Manguera pesada", + name: "H-3 Nozzlenose", + }, + { + text: "Manguera pesada D", + name: "H-3 Nozzlenose D", + }, + { + text: "Manguera pesada PTN", + name: "H-3 Nozzlenose VIP-R", + }, + { + text: "Manguera ligera", + name: "L-3 Nozzlenose", + }, + { + text: "Manguera ligera D", + name: "L-3 Nozzlenose D", + }, + { + text: "Manguera ligera BRX", + name: "Glitterz L-3 Nozzlenose", + }, + { + text: "Derramatic baño", + name: "Bloblobber", + }, + { + text: "Derramatic baño chic", + name: "Bloblobber Deco", + }, + { + text: "Derramatic triple", + name: "Tri-Slosher", + }, + { + text: "Derramatic triple maestro", + name: "Tri-Slosher Nouveau", + }, + { + text: "Derramatic triple CNZ", + name: "Tri-Slosher ASH-N", + }, + { + text: "Friegamatic", + name: "Dread Wringer", + }, + { + text: "Friegamatic D", + name: "Dread Wringer D", + }, + { + text: "Friegamatic ATX", + name: "Hornz Dread Wringer", + }, + { + text: "Derramatic centrífugo", + name: "Sloshing Machine", + }, + { + text: "Derramatic centrífugo neo", + name: "Sloshing Machine Neo", + }, + { + text: "Derramatic", + name: "Slosher", + }, + { + text: "Derramatic chic", + name: "Slosher Deco", + }, + { + text: "Derramatic orden réplica", + name: "Order Slosher Replica", + }, + { + text: "Derramatic XP", + name: "Explosher", + }, + { + text: "Derramatic XP adaptado", + name: "Custom Explosher", + }, + { + text: "Garabateador PLM", + name: "Ballpoint Splatling", + }, + { + text: "Garabateador PLM maestro", + name: "Ballpoint Splatling Nouveau", + }, + { + text: "Corrigetintas", + name: "Heavy Edit Splatling", + }, + { + text: "Corrigetintas maestro", + name: "Heavy Edit Splatling Nouveau", + }, + { + text: "Garabateador HD", + name: "Hydra Splatling", + }, + { + text: "Garabateador HD adaptado", + name: "Custom Hydra Splatling", + }, + { + text: "Garabateador HD TRX", + name: "Torrentz Hydra Splatling", + }, + { + text: "Garabateador", + name: "Mini Splatling", + }, + { + text: "Garabateador Tintaz", + name: "Zink Mini Splatling", + }, + { + text: "Garabateador CSB", + name: "Mini Splatling RTL-R", + }, + { + text: "Nautilo 47", + name: "Nautilus 47", + }, + { + text: "Nautilo 79", + name: "Nautilus 79", + }, + { + text: "Garabateador Pro", + name: "Heavy Splatling", + }, + { + text: "Garabateador Pro chic", + name: "Heavy Splatling Deco", + }, + { + text: "Garabateador orden réplica", + name: "Order Splatling Replica", + }, + { + text: "Arcrotensor V", + name: "Wellstring V", + }, + { + text: "Arcrotensor V adaptado", + name: "Custom Wellstring V", + }, + { + text: "Arcromatizador triple", + name: "Tri-Stringer", + }, + { + text: "Arcromatizador triple Moluskia", + name: "Inkline Tri-Stringer", + }, + { + text: "Arcromatizador triple FCX", + name: "Bulbz Tri-Stringer", + }, + { + text: "Arcromatizador orden réplica", + name: "Order Stringer Replica", + }, + { + text: "AR-CR0 450", + name: "REEF-LUX 450", + }, + { + text: "AR-CR0 450 chic", + name: "REEF-LUX 450 Deco", + }, + { + text: "AR-CR0 450 LCT", + name: "REEF-LUX 450 MIL-K", + }, + { + text: "Baliza", + name: "Squid Beakon", + }, + { + text: "Bomba deslizante", + name: "Curling Bomb", + }, + { + text: "Bomba carbónica", + name: "Fizzy Bomb", + }, + { + text: "Globo entintado", + name: "Burst Bomb", + }, + { + text: "Robobomba", + name: "Autobomb", + }, + { + text: "Plasbomba", + name: "Splat Bomb", + }, + { + text: "Bomba pegajosa", + name: "Suction Bomb", + }, + { + text: "Dardo resaltador", + name: "Angle Shooter", + }, + { + text: "Rastreador", + name: "Point Sensor", + }, + { + text: "Nebulizador", + name: "Toxic Mist", + }, + { + text: "Barricada", + name: "Splash Wall", + }, + { + text: "Aspersor", + name: "Sprinkler", + }, + { + text: "Mina de tinta", + name: "Ink Mine", + }, + { + text: "Aspiratinta", + name: "Ink Vac", + }, + { + text: "Calamar imperial", + name: "Kraken Royale", + }, + { + text: "Cangrejobot", + name: "Crab Tank", + }, + { + text: "Muro marmoleado", + name: "Splattercolor Screen", + }, + { + text: "Dispensabebidas", + name: "Tacticooler", + }, + { + text: "Multiseñuelos", + name: "Super Chump", + }, + { + text: "Megaburbuja", + name: "Big Bubbler", + }, + { + text: "Atormentador", + name: "Ink Storm", + }, + { + text: "Propulsor", + name: "Inkjet", + }, + { + text: "Berreón 5.1", + name: "Killer Wail 5.1", + }, + { + text: "Lanzamisiles", + name: "Tenta Missiles", + }, + { + text: "Bola genializante", + name: "Booyah Bomb", + }, + { + text: "Clavado triple", + name: "Triple Splashdown", + }, + { + text: "Emiteondas", + name: "Wave Breaker", + }, + { + text: "Flotiburón", + name: "Reefslider", + }, + { + text: "Gancho tentacular", + name: "Zipcaster", + }, + { + text: "Tornado triple", + name: "Triple Inkstrike", + }, + { + text: "Cañón triple", + name: "Trizooka", + }, + { + text: "Ultraselladora", + name: "Ultra Stamp", + }, + ], + USfr: [ + { + text: "Turboblasteur pro", + name: "Rapid Blaster Pro", + }, + { + text: "Turboblasteur pro chic", + name: "Rapid Blaster Pro Deco", + }, + { + text: "Turboblasteur pro POL-R", + name: "Rapid Blaster Pro WNT-R", + }, + { + text: "Rafalblasteur", + name: "Clash Blaster", + }, + { + text: "Rafalblasteur néo", + name: "Clash Blaster Neo", + }, + { + text: "Turboblasteur", + name: "Rapid Blaster", + }, + { + text: "Turboblasteur chic", + name: "Rapid Blaster Deco", + }, + { + text: "Éclablasteur LP", + name: "Range Blaster", + }, + { + text: "Éclablasteur LP modifié", + name: "Custom Range Blaster", + }, + { + text: "Éclablasteur", + name: "Blaster", + }, + { + text: "Éclablasteur modifié", + name: "Custom Blaster", + }, + { + text: "Éclablasteur miragius", + name: "Gleamz Blaster", + }, + { + text: "S-Blasteur '92", + name: "S-BLAST '92", + }, + { + text: "S-Blasteur '91", + name: "S-BLAST '91", + }, + { + text: "Proxiblasteur", + name: "Luna Blaster", + }, + { + text: "Proxiblasteur néo", + name: "Luna Blaster Neo", + }, + { + text: "Blasteur d'Ordre (réplique)", + name: "Order Blaster Replica", + }, + { + text: "Épinceau écaïe", + name: "Painbrush", + }, + { + text: "Épinceau écaïe nuancé", + name: "Painbrush Nouveau", + }, + { + text: "Épinceau écaïe BRN-Z", + name: "Painbrush BRN-Z", + }, + { + text: "Épinceau", + name: "Inkbrush", + }, + { + text: "Épinceau nuancé", + name: "Inkbrush Nouveau", + }, + { + text: "Épinceau brosse", + name: "Octobrush", + }, + { + text: "Épinceau brosse nuancé", + name: "Octobrush Nouveau", + }, + { + text: "Épinceau brosse stellius", + name: "Cometz Octobrush", + }, + { + text: "Pinceau d'Ordre (réplique)", + name: "Orderbrush Replica", + }, + { + text: "Détubeur", + name: "Goo Tuber", + }, + { + text: "Détubeur modifié", + name: "Custom Goo Tuber", + }, + { + text: "Bimbamboum Mk I", + name: "Bamboozler 14 Mk I", + }, + { + text: "Bimbamboum Mk II", + name: "Bamboozler 14 Mk II", + }, + { + text: "X-traceur + zoom", + name: "E-liter 4K Scope", + }, + { + text: "X-traceur + zoom modifié", + name: "Custom E-liter 4K Scope", + }, + { + text: "X-traceur +", + name: "E-liter 4K", + }, + { + text: "X-traceur + modifié", + name: "Custom E-liter 4K", + }, + { + text: "Concentraceur zoom", + name: "Splatterscope", + }, + { + text: "Concentraceur zoom E+A", + name: "Z+F Splatterscope", + }, + { + text: "Concentraceur zoom CAM-O", + name: "Splatterscope CAM-O", + }, + { + text: "Concentraceur", + name: "Splat Charger", + }, + { + text: "Concentraceur E+A", + name: "Z+F Splat Charger", + }, + { + text: "Concentraceur CAM-O", + name: "Splat Charger CAM-O", + }, + { + text: "Fusil d'Ordre (réplique)", + name: "Order Charger Replica", + }, + { + text: "Crayon X - 5H", + name: "Snipewriter 5H", + }, + { + text: "Crayon X - 5B", + name: "Snipewriter 5B", + }, + { + text: "Lignizateur alpha", + name: "Classic Squiffer", + }, + { + text: "Lignizateur bêta", + name: "New Squiffer", + }, + { + text: "Double nettoyeur", + name: "Dualie Squelchers", + }, + { + text: "Double nettoyeur modifié", + name: "Custom Dualie Squelchers", + }, + { + text: "Double nettoyeur zébranius", + name: "Hoofz Dualie Squelchers", + }, + { + text: "Double Kelvin 525", + name: "Glooga Dualies", + }, + { + text: "Double Kelvin 525 chic", + name: "Glooga Dualies Deco", + }, + { + text: "Double ignifugeur", + name: "Douser Dualies FF", + }, + { + text: "Double ignifugeur modifié", + name: "Custom Douser Dualies FF", + }, + { + text: "Double encreur", + name: "Splat Dualies", + }, + { + text: "Double encreur DUX", + name: "Enperry Splat Dualies", + }, + { + text: "Double encreur flambius", + name: "Twinklez Splat Dualies", + }, + { + text: "Arme double d'Ordre (réplique)", + name: "Order Dualie Replicas", + }, + { + text: "Double moucheteur", + name: "Dapple Dualies", + }, + { + text: "Double moucheteur nuancé", + name: "Dapple Dualies Nouveau", + }, + { + text: "Double moucheteur NOC-T", + name: "Dapple Dualies NOC-T", + }, + { + text: "Double voltigeur noir", + name: "Dark Tetra Dualies", + }, + { + text: "Double voltigeur blanc", + name: "Light Tetra Dualies", + }, + { + text: "Rouleau carbone", + name: "Carbon Roller", + }, + { + text: "Rouleau carbone chic", + name: "Carbon Roller Deco", + }, + { + text: "Rouleau carbone BOD-RWA", + name: "Carbon Roller ANG-L", + }, + { + text: "Dynamo-rouleau", + name: "Dynamo Roller", + }, + { + text: "Dynamo-rouleau pro", + name: "Gold Dynamo Roller", + }, + { + text: "Dynamo-rouleau galaxius", + name: "Starz Dynamo Roller", + }, + { + text: "Flexi-rouleau", + name: "Flingza Roller", + }, + { + text: "Flexi-rouleau aluminium", + name: "Foil Flingza Roller", + }, + { + text: "Rouleau", + name: "Splat Roller", + }, + { + text: "Rouleau Krakeurs", + name: "Krak-On Splat Roller", + }, + { + text: "Rouleau d'Ordre (réplique)", + name: "Order Roller Replica", + }, + { + text: "Rouleau large", + name: "Big Swig Roller", + }, + { + text: "Rouleau large Xpress", + name: "Big Swig Roller Express", + }, + { + text: "Rouleau large astralius", + name: "Planetz Big Swig Roller", + }, + { + text: "Sabrifrice menthe", + name: "Mint Decavitator", + }, + { + text: "Sabrifrice charbon", + name: "Charcoal Decavitator", + }, + { + text: "Éclatana Doto", + name: "Splatana Wiper", + }, + { + text: "Éclatana Doto chic", + name: "Splatana Wiper Deco", + }, + { + text: "Éclatana Doto OXY-D", + name: "Splatana Wiper RUS-T", + }, + { + text: "Éclatana d'estampe", + name: "Splatana Stamper", + }, + { + text: "Éclatana d'estampe nuancé", + name: "Splatana Stamper Nouveau", + }, + { + text: "Éclatana d'estampe décolius", + name: "Stickerz Splatana Stamper", + }, + { + text: "Éclatana d'Ordre (réplique)", + name: "Order Splatana Replica", + }, + { + text: "Para-encre espion", + name: "Undercover Brella", + }, + { + text: "Para-encre espion Sorella", + name: "Undercover Sorella Brella", + }, + { + text: "Para-encre espion mélangius", + name: "Patternz Undercover Brella", + }, + { + text: "Para-encre Ré-gen 24 Mk I", + name: "Recycled Brella 24 Mk I", + }, + { + text: "Para-encre Ré-gen 24 Mk II", + name: "Recycled Brella 24 Mk II", + }, + { + text: "Para-encre", + name: "Splat Brella", + }, + { + text: "Para-encre Sorella", + name: "Sorella Brella", + }, + { + text: "Para-encre d'Ordre (réplique)", + name: "Order Brella Replica", + }, + { + text: "Méga para-encre", + name: "Tenta Brella", + }, + { + text: "Méga para-encre Sorella", + name: "Tenta Sorella Brella", + }, + { + text: "Méga para-encre CRM-A", + name: "Tenta Brella CRE-M", + }, + { + text: "Pouche-pouche", + name: "Aerospray MG", + }, + { + text: "Pouche-pouche premium", + name: "Aerospray RG", + }, + { + text: "Pouche-pouche chromius", + name: "Colorz Aerospray", + }, + { + text: "Liquéficateur pro", + name: "Splattershot Pro", + }, + { + text: "Liquéficateur pro Fosia", + name: "Forge Splattershot Pro", + }, + { + text: "Liquéficateur pro GLA-C", + name: "Splattershot Pro FRZ-N", + }, + { + text: "Liquéficateur ultra", + name: "Splattershot Jr.", + }, + { + text: "Liquéficateur ultra modifié", + name: "Custom Splattershot Jr.", + }, + { + text: "Compresseur", + name: "Squeezer", + }, + { + text: "Compresseur aluminium", + name: "Foil Squeezer", + }, + { + text: "Calibre 2000", + name: ".52 Gal", + }, + { + text: "Calibre 2000 chic", + name: ".52 Gal Deco", + }, + { + text: "Calibre 3000", + name: ".96 Gal", + }, + { + text: "Calibre 3000 chic", + name: ".96 Gal Deco", + }, + { + text: "Calibre 3000 guépius", + name: "Clawz .96 Gal", + }, + { + text: "Méganettoyeur", + name: "Jet Squelcher", + }, + { + text: "Méganettoyeur modifié", + name: "Custom Jet Squelcher", + }, + { + text: "Méganettoyeur COB-R", + name: "Jet Squelcher COB-R", + }, + { + text: "Liquéficateur", + name: "Splattershot", + }, + { + text: "Liquéficateur Tentatek", + name: "Tentatek Splattershot", + }, + { + text: "Liquéficateur glamius", + name: "Glamorz Splattershot", + }, + { + text: "Liquéfi. héroïque (rép.)", + name: "Hero Shot Replica", + }, + { + text: "Lanceur d'Ordre (réplique)", + name: "Order Shot Replica", + }, + { + text: "Lanceur octaling (réplique)", + name: "Octo Shot Replica", + }, + { + text: "Marqueur léger", + name: "Splash-o-matic", + }, + { + text: "Marqueur léger néo", + name: "Neo Splash-o-matic", + }, + { + text: "Marqueur léger G-KO", + name: "Splash-o-matic GCK-O", + }, + { + text: "Lanceur spatial", + name: "Splattershot Nova", + }, + { + text: "Lanceur spatial Aroz", + name: "Annaki Splattershot Nova", + }, + { + text: "N-ZAP 85", + name: "N-ZAP '85", + }, + { + text: "N-ZAP 89", + name: "N-ZAP '89", + }, + { + text: "Marqueur lourd", + name: "Sploosh-o-matic", + }, + { + text: "Marqueur lourd néo", + name: "Neo Sploosh-o-matic", + }, + { + text: "Arroseur lourd", + name: "H-3 Nozzlenose", + }, + { + text: "Arroseur lourd requin", + name: "H-3 Nozzlenose D", + }, + { + text: "Arroseur lourd VIP-R", + name: "H-3 Nozzlenose VIP-R", + }, + { + text: "Arroseur léger", + name: "L-3 Nozzlenose", + }, + { + text: "Arroseur léger requin", + name: "L-3 Nozzlenose D", + }, + { + text: "Arroseur léger éclatius", + name: "Glitterz L-3 Nozzlenose", + }, + { + text: "Bassineur", + name: "Bloblobber", + }, + { + text: "Bassineur chic", + name: "Bloblobber Deco", + }, + { + text: "Seauceur trio", + name: "Tri-Slosher", + }, + { + text: "Seauceur nuancé", + name: "Tri-Slosher Nouveau", + }, + { + text: "Seauceur trio 100-DRÉ", + name: "Tri-Slosher ASH-N", + }, + { + text: "Seau S-Horreur", + name: "Dread Wringer", + }, + { + text: "Seau S-Horreur requin", + name: "Dread Wringer D", + }, + { + text: "Seau S-Horreur kératius", + name: "Hornz Dread Wringer", + }, + { + text: "Encrifugeur", + name: "Sloshing Machine", + }, + { + text: "Encrifugeur néo", + name: "Sloshing Machine Neo", + }, + { + text: "Seauceur", + name: "Slosher", + }, + { + text: "Seauceur chic", + name: "Slosher Deco", + }, + { + text: "Seau d'Ordre (réplique)", + name: "Order Slosher Replica", + }, + { + text: "Détoneur", + name: "Explosher", + }, + { + text: "Détoneur modifié", + name: "Custom Explosher", + }, + { + text: "Badigeonneur stylo", + name: "Ballpoint Splatling", + }, + { + text: "Badigeonneur stylo nuancé", + name: "Ballpoint Splatling Nouveau", + }, + { + text: "Correcteur", + name: "Heavy Edit Splatling", + }, + { + text: "Correcteur nuancé", + name: "Heavy Edit Splatling Nouveau", + }, + { + text: "Exteinteur", + name: "Hydra Splatling", + }, + { + text: "Exteinteur modifié", + name: "Custom Hydra Splatling", + }, + { + text: "Exteinteur cascadius", + name: "Torrentz Hydra Splatling", + }, + { + text: "Badigeonneur XP", + name: "Mini Splatling", + }, + { + text: "Badigeonneur XP Leviathus", + name: "Zink Mini Splatling", + }, + { + text: "Badigeonneur XP VN-1", + name: "Mini Splatling RTL-R", + }, + { + text: "Badigeonneur", + name: "Heavy Splatling", + }, + { + text: "Badigeonneur chic", + name: "Heavy Splatling Deco", + }, + { + text: "Badigeonneur d'Ordre (réplique)", + name: "Order Splatling Replica", + }, + { + text: "Multisperceur V", + name: "Wellstring V", + }, + { + text: "Multisperceur V modifié", + name: "Custom Wellstring V", + }, + { + text: "Trisperceur", + name: "Tri-Stringer", + }, + { + text: "Trisperceur Abysma", + name: "Inkline Tri-Stringer", + }, + { + text: "Trisperceur prismalius", + name: "Bulbz Tri-Stringer", + }, + { + text: "Transperceur d'Ordre (réplique)", + name: "Order Stringer Replica", + }, + { + text: "Coralux 450", + name: "REEF-LUX 450", + }, + { + text: "Coralux 450 chic", + name: "REEF-LUX 450 Deco", + }, + { + text: "Coralux 450 LACTO", + name: "REEF-LUX 450 MIL-K", + }, + { + text: "Balise de saut", + name: "Squid Beakon", + }, + { + text: "Bombe curling", + name: "Curling Bomb", + }, + { + text: "Bombe soda", + name: "Fizzy Bomb", + }, + { + text: "Bombe ballon", + name: "Burst Bomb", + }, + { + text: "Bombe robot", + name: "Autobomb", + }, + { + text: "Bombe splash", + name: "Splat Bomb", + }, + { + text: "Bombe gluante", + name: "Suction Bomb", + }, + { + text: "Bentorpille", + name: "Torpedo", + }, + { + text: "Ricocheur", + name: "Angle Shooter", + }, + { + text: "Détecteur", + name: "Point Sensor", + }, + { + text: "Brume toxique", + name: "Toxic Mist", + }, + { + text: "Mur d'encre", + name: "Splash Wall", + }, + { + text: "Gicleur", + name: "Sprinkler", + }, + { + text: "Mine d'encre", + name: "Ink Mine", + }, + { + text: "Aspirencre", + name: "Ink Vac", + }, + { + text: "Kraken royal", + name: "Kraken Royale", + }, + { + text: "Crabe d'assaut", + name: "Crab Tank", + }, + { + text: "Barrière barbouillée", + name: "Splattercolor Screen", + }, + { + text: "Districool", + name: "Tacticooler", + }, + { + text: "Multi-leurres", + name: "Super Chump", + }, + { + text: "Super bouclier", + name: "Big Bubbler", + }, + { + text: "Pluie d'encre", + name: "Ink Storm", + }, + { + text: "Chromo-jet", + name: "Inkjet", + }, + { + text: "Laser perçant 5.1", + name: "Killer Wail 5.1", + }, + { + text: "Multi-missile", + name: "Tenta Missiles", + }, + { + text: "Excellinator", + name: "Booyah Bomb", + }, + { + text: "Triple choc chromatique", + name: "Triple Splashdown", + }, + { + text: "Sonar paf", + name: "Wave Breaker", + }, + { + text: "Cavalsquale", + name: "Reefslider", + }, + { + text: "Super Mollusque", + name: "Zipcaster", + }, + { + text: "Trimissile tornade", + name: "Triple Inkstrike", + }, + { + text: "Lance-tornades", + name: "Trizooka", + }, + { + text: "Ultra-étampeur", + name: "Ultra Stamp", + }, + ], }; diff --git a/app/features/cv/core/detectors/death/rois.ts b/app/features/cv/core/detectors/death/rois.ts index 0ae2fc9af..9d90d2af4 100644 --- a/app/features/cv/core/detectors/death/rois.ts +++ b/app/features/cv/core/detectors/death/rois.ts @@ -68,14 +68,14 @@ const ABILITY_SUB_YS = [702, 797, 888] as const; * 56px sub box excludes the main-size templates. */ export function abilityMainRoi(row: number): Roi { - const cy = ABILITY_MAIN_YS[row]!; - return { x: ABILITY_MAIN_X - 38, y: cy - 38, w: 76, h: 76 }; + const cy = ABILITY_MAIN_YS[row]!; + return { x: ABILITY_MAIN_X - 38, y: cy - 38, w: 76, h: 76 }; } export function abilitySubRoi(row: number, slot: number): Roi { - const cx = ABILITY_SUB_XS[slot]!; - const cy = ABILITY_SUB_YS[row]!; - return { x: cx - 28, y: cy - 28, w: 56, h: 56 }; + const cx = ABILITY_SUB_XS[slot]!; + const cy = ABILITY_SUB_YS[row]!; + return { x: cx - 28, y: cy - 28, w: 56, h: 56 }; } /** Template heights (px at 1080p) per circle role. */ @@ -134,13 +134,13 @@ export const TAG_NAME_TEXT_HEIGHT = 46; * right of the sub circles. The white message text gives the bright anchor. */ export const GATE_BURST_PROBES: readonly Roi[] = [ - { x: 750, y: 376, w: 36, h: 22 }, - { x: 1134, y: 376, w: 36, h: 22 }, - { x: 930, y: 466, w: 60, h: 18 }, + { x: 750, y: 376, w: 36, h: 22 }, + { x: 1134, y: 376, w: 36, h: 22 }, + { x: 930, y: 466, w: 60, h: 18 }, ]; export const GATE_PANEL_PROBES: readonly Roi[] = [ - { x: 720, y: 686, w: 30, h: 20 }, - { x: 720, y: 785, w: 30, h: 20 }, + { x: 720, y: 686, w: 30, h: 20 }, + { x: 720, y: 785, w: 30, h: 20 }, ]; export const GATE_DARK_MAX_MEAN = 95; /** SPLAT_LINE1_ROI must contain near-white pixels... */ @@ -158,6 +158,11 @@ export const GATE_TEXT_MAX_FRACTION = 0.35; * max-channel while the closest non-death fixture row is 184. */ export function gateAbilityProbe(row: number): Roi { - return { x: ABILITY_MAIN_X - 14, y: ABILITY_MAIN_YS[row]! - 14, w: 28, h: 28 }; + return { + x: ABILITY_MAIN_X - 14, + y: ABILITY_MAIN_YS[row]! - 14, + w: 28, + h: 28, + }; } export const GATE_ICON_MIN_MAX = 200; diff --git a/app/features/cv/core/detectors/death/weapon-names.ts b/app/features/cv/core/detectors/death/weapon-names.ts index 3b71af88c..d94246951 100644 --- a/app/features/cv/core/detectors/death/weapon-names.ts +++ b/app/features/cv/core/detectors/death/weapon-names.ts @@ -10,233 +10,236 @@ export type WeaponType = "MAIN" | "SUB" | "SPECIAL"; export interface WeaponEntry { - id: string; - name: string; - type: WeaponType; + id: string; + name: string; + type: WeaponType; } -export const WEAPON_NAMES: ReadonlyMap = new Map([ - ["0", "Sploosh-o-matic"], - ["1", "Neo Sploosh-o-matic"], - ["10", "Splattershot Jr."], - ["11", "Custom Splattershot Jr."], - ["20", "Splash-o-matic"], - ["21", "Neo Splash-o-matic"], - ["22", "Splash-o-matic GCK-O"], - ["30", "Aerospray MG"], - ["31", "Aerospray RG"], - ["32", "Colorz Aerospray"], - ["40", "Splattershot"], - ["41", "Tentatek Splattershot"], - ["42", "Glamorz Splattershot"], - ["45", "Hero Shot Replica"], - ["46", "Octo Shot Replica"], - ["47", "Order Shot Replica"], - ["50", ".52 Gal"], - ["51", ".52 Gal Deco"], - ["60", "N-ZAP '85"], - ["61", "N-ZAP '89"], - ["70", "Splattershot Pro"], - ["71", "Forge Splattershot Pro"], - ["72", "Splattershot Pro FRZ-N"], - ["80", ".96 Gal"], - ["81", ".96 Gal Deco"], - ["82", "Clawz .96 Gal"], - ["90", "Jet Squelcher"], - ["91", "Custom Jet Squelcher"], - ["92", "Jet Squelcher COB-R"], - ["100", "Splattershot Nova"], - ["101", "Annaki Splattershot Nova"], - ["200", "Luna Blaster"], - ["201", "Luna Blaster Neo"], - ["205", "Order Blaster Replica"], - ["210", "Blaster"], - ["211", "Custom Blaster"], - ["212", "Gleamz Blaster"], - ["220", "Range Blaster"], - ["221", "Custom Range Blaster"], - ["230", "Clash Blaster"], - ["231", "Clash Blaster Neo"], - ["240", "Rapid Blaster"], - ["241", "Rapid Blaster Deco"], - ["250", "Rapid Blaster Pro"], - ["251", "Rapid Blaster Pro Deco"], - ["252", "Rapid Blaster Pro WNT-R"], - ["260", "S-BLAST '92"], - ["261", "S-BLAST '91"], - ["300", "L-3 Nozzlenose"], - ["301", "L-3 Nozzlenose D"], - ["302", "Glitterz L-3 Nozzlenose"], - ["310", "H-3 Nozzlenose"], - ["311", "H-3 Nozzlenose D"], - ["312", "H-3 Nozzlenose VIP-R"], - ["400", "Squeezer"], - ["401", "Foil Squeezer"], - ["1000", "Carbon Roller"], - ["1001", "Carbon Roller Deco"], - ["1002", "Carbon Roller ANG-L"], - ["1010", "Splat Roller"], - ["1011", "Krak-On Splat Roller"], - ["1015", "Order Roller Replica"], - ["1020", "Dynamo Roller"], - ["1021", "Gold Dynamo Roller"], - ["1022", "Starz Dynamo Roller"], - ["1030", "Flingza Roller"], - ["1031", "Foil Flingza Roller"], - ["1040", "Big Swig Roller"], - ["1041", "Big Swig Roller Express"], - ["1042", "Planetz Big Swig Roller"], - ["1100", "Inkbrush"], - ["1101", "Inkbrush Nouveau"], - ["1110", "Octobrush"], - ["1111", "Octobrush Nouveau"], - ["1112", "Cometz Octobrush"], - ["1115", "Orderbrush Replica"], - ["1120", "Painbrush"], - ["1121", "Painbrush Nouveau"], - ["1122", "Painbrush BRN-Z"], - ["2000", "Classic Squiffer"], - ["2001", "New Squiffer"], - ["2010", "Splat Charger"], - ["2011", "Z+F Splat Charger"], - ["2012", "Splat Charger CAM-O"], - ["2015", "Order Charger Replica"], - ["2020", "Splatterscope"], - ["2021", "Z+F Splatterscope"], - ["2022", "Splatterscope CAM-O"], - ["2030", "E-liter 4K"], - ["2031", "Custom E-liter 4K"], - ["2040", "E-liter 4K Scope"], - ["2041", "Custom E-liter 4K Scope"], - ["2050", "Bamboozler 14 Mk I"], - ["2051", "Bamboozler 14 Mk II"], - ["2060", "Goo Tuber"], - ["2061", "Custom Goo Tuber"], - ["2070", "Snipewriter 5H"], - ["2071", "Snipewriter 5B"], - ["3000", "Slosher"], - ["3001", "Slosher Deco"], - ["3005", "Order Slosher Replica"], - ["3010", "Tri-Slosher"], - ["3011", "Tri-Slosher Nouveau"], - ["3012", "Tri-Slosher ASH-N"], - ["3020", "Sloshing Machine"], - ["3021", "Sloshing Machine Neo"], - ["3030", "Bloblobber"], - ["3031", "Bloblobber Deco"], - ["3040", "Explosher"], - ["3041", "Custom Explosher"], - ["3050", "Dread Wringer"], - ["3051", "Dread Wringer D"], - ["3052", "Hornz Dread Wringer"], - ["4000", "Mini Splatling"], - ["4001", "Zink Mini Splatling"], - ["4002", "Mini Splatling RTL-R"], - ["4010", "Heavy Splatling"], - ["4011", "Heavy Splatling Deco"], - ["4015", "Order Splatling Replica"], - ["4020", "Hydra Splatling"], - ["4021", "Custom Hydra Splatling"], - ["4022", "Torrentz Hydra Splatling"], - ["4030", "Ballpoint Splatling"], - ["4031", "Ballpoint Splatling Nouveau"], - ["4040", "Nautilus 47"], - ["4041", "Nautilus 79"], - ["4050", "Heavy Edit Splatling"], - ["4051", "Heavy Edit Splatling Nouveau"], - ["5000", "Dapple Dualies"], - ["5001", "Dapple Dualies Nouveau"], - ["5002", "Dapple Dualies NOC-T"], - ["5010", "Splat Dualies"], - ["5011", "Enperry Splat Dualies"], - ["5012", "Twinklez Splat Dualies"], - ["5015", "Order Dualie Replicas"], - ["5020", "Glooga Dualies"], - ["5021", "Glooga Dualies Deco"], - ["5030", "Dualie Squelchers"], - ["5031", "Custom Dualie Squelchers"], - ["5032", "Hoofz Dualie Squelchers"], - ["5040", "Dark Tetra Dualies"], - ["5041", "Light Tetra Dualies"], - ["5050", "Douser Dualies FF"], - ["5051", "Custom Douser Dualies FF"], - ["6000", "Splat Brella"], - ["6001", "Sorella Brella"], - ["6005", "Order Brella Replica"], - ["6010", "Tenta Brella"], - ["6011", "Tenta Sorella Brella"], - ["6012", "Tenta Brella CRE-M"], - ["6020", "Undercover Brella"], - ["6021", "Undercover Sorella Brella"], - ["6022", "Patternz Undercover Brella"], - ["6030", "Recycled Brella 24 Mk I"], - ["6031", "Recycled Brella 24 Mk II"], - ["7010", "Tri-Stringer"], - ["7011", "Inkline Tri-Stringer"], - ["7012", "Bulbz Tri-Stringer"], - ["7015", "Order Stringer Replica"], - ["7020", "REEF-LUX 450"], - ["7021", "REEF-LUX 450 Deco"], - ["7022", "REEF-LUX 450 MIL-K"], - ["7030", "Wellstring V"], - ["7031", "Custom Wellstring V"], - ["8000", "Splatana Stamper"], - ["8001", "Splatana Stamper Nouveau"], - ["8002", "Stickerz Splatana Stamper"], - ["8005", "Order Splatana Replica"], - ["8010", "Splatana Wiper"], - ["8011", "Splatana Wiper Deco"], - ["8012", "Splatana Wiper RUS-T"], - ["8020", "Mint Decavitator"], - ["8021", "Charcoal Decavitator"], +const WEAPON_NAMES: ReadonlyMap = new Map([ + ["0", "Sploosh-o-matic"], + ["1", "Neo Sploosh-o-matic"], + ["10", "Splattershot Jr."], + ["11", "Custom Splattershot Jr."], + ["20", "Splash-o-matic"], + ["21", "Neo Splash-o-matic"], + ["22", "Splash-o-matic GCK-O"], + ["30", "Aerospray MG"], + ["31", "Aerospray RG"], + ["32", "Colorz Aerospray"], + ["40", "Splattershot"], + ["41", "Tentatek Splattershot"], + ["42", "Glamorz Splattershot"], + ["45", "Hero Shot Replica"], + ["46", "Octo Shot Replica"], + ["47", "Order Shot Replica"], + ["50", ".52 Gal"], + ["51", ".52 Gal Deco"], + ["60", "N-ZAP '85"], + ["61", "N-ZAP '89"], + ["70", "Splattershot Pro"], + ["71", "Forge Splattershot Pro"], + ["72", "Splattershot Pro FRZ-N"], + ["80", ".96 Gal"], + ["81", ".96 Gal Deco"], + ["82", "Clawz .96 Gal"], + ["90", "Jet Squelcher"], + ["91", "Custom Jet Squelcher"], + ["92", "Jet Squelcher COB-R"], + ["100", "Splattershot Nova"], + ["101", "Annaki Splattershot Nova"], + ["200", "Luna Blaster"], + ["201", "Luna Blaster Neo"], + ["205", "Order Blaster Replica"], + ["210", "Blaster"], + ["211", "Custom Blaster"], + ["212", "Gleamz Blaster"], + ["220", "Range Blaster"], + ["221", "Custom Range Blaster"], + ["230", "Clash Blaster"], + ["231", "Clash Blaster Neo"], + ["240", "Rapid Blaster"], + ["241", "Rapid Blaster Deco"], + ["250", "Rapid Blaster Pro"], + ["251", "Rapid Blaster Pro Deco"], + ["252", "Rapid Blaster Pro WNT-R"], + ["260", "S-BLAST '92"], + ["261", "S-BLAST '91"], + ["300", "L-3 Nozzlenose"], + ["301", "L-3 Nozzlenose D"], + ["302", "Glitterz L-3 Nozzlenose"], + ["310", "H-3 Nozzlenose"], + ["311", "H-3 Nozzlenose D"], + ["312", "H-3 Nozzlenose VIP-R"], + ["400", "Squeezer"], + ["401", "Foil Squeezer"], + ["1000", "Carbon Roller"], + ["1001", "Carbon Roller Deco"], + ["1002", "Carbon Roller ANG-L"], + ["1010", "Splat Roller"], + ["1011", "Krak-On Splat Roller"], + ["1015", "Order Roller Replica"], + ["1020", "Dynamo Roller"], + ["1021", "Gold Dynamo Roller"], + ["1022", "Starz Dynamo Roller"], + ["1030", "Flingza Roller"], + ["1031", "Foil Flingza Roller"], + ["1040", "Big Swig Roller"], + ["1041", "Big Swig Roller Express"], + ["1042", "Planetz Big Swig Roller"], + ["1100", "Inkbrush"], + ["1101", "Inkbrush Nouveau"], + ["1110", "Octobrush"], + ["1111", "Octobrush Nouveau"], + ["1112", "Cometz Octobrush"], + ["1115", "Orderbrush Replica"], + ["1120", "Painbrush"], + ["1121", "Painbrush Nouveau"], + ["1122", "Painbrush BRN-Z"], + ["2000", "Classic Squiffer"], + ["2001", "New Squiffer"], + ["2010", "Splat Charger"], + ["2011", "Z+F Splat Charger"], + ["2012", "Splat Charger CAM-O"], + ["2015", "Order Charger Replica"], + ["2020", "Splatterscope"], + ["2021", "Z+F Splatterscope"], + ["2022", "Splatterscope CAM-O"], + ["2030", "E-liter 4K"], + ["2031", "Custom E-liter 4K"], + ["2040", "E-liter 4K Scope"], + ["2041", "Custom E-liter 4K Scope"], + ["2050", "Bamboozler 14 Mk I"], + ["2051", "Bamboozler 14 Mk II"], + ["2060", "Goo Tuber"], + ["2061", "Custom Goo Tuber"], + ["2070", "Snipewriter 5H"], + ["2071", "Snipewriter 5B"], + ["3000", "Slosher"], + ["3001", "Slosher Deco"], + ["3005", "Order Slosher Replica"], + ["3010", "Tri-Slosher"], + ["3011", "Tri-Slosher Nouveau"], + ["3012", "Tri-Slosher ASH-N"], + ["3020", "Sloshing Machine"], + ["3021", "Sloshing Machine Neo"], + ["3030", "Bloblobber"], + ["3031", "Bloblobber Deco"], + ["3040", "Explosher"], + ["3041", "Custom Explosher"], + ["3050", "Dread Wringer"], + ["3051", "Dread Wringer D"], + ["3052", "Hornz Dread Wringer"], + ["4000", "Mini Splatling"], + ["4001", "Zink Mini Splatling"], + ["4002", "Mini Splatling RTL-R"], + ["4010", "Heavy Splatling"], + ["4011", "Heavy Splatling Deco"], + ["4015", "Order Splatling Replica"], + ["4020", "Hydra Splatling"], + ["4021", "Custom Hydra Splatling"], + ["4022", "Torrentz Hydra Splatling"], + ["4030", "Ballpoint Splatling"], + ["4031", "Ballpoint Splatling Nouveau"], + ["4040", "Nautilus 47"], + ["4041", "Nautilus 79"], + ["4050", "Heavy Edit Splatling"], + ["4051", "Heavy Edit Splatling Nouveau"], + ["5000", "Dapple Dualies"], + ["5001", "Dapple Dualies Nouveau"], + ["5002", "Dapple Dualies NOC-T"], + ["5010", "Splat Dualies"], + ["5011", "Enperry Splat Dualies"], + ["5012", "Twinklez Splat Dualies"], + ["5015", "Order Dualie Replicas"], + ["5020", "Glooga Dualies"], + ["5021", "Glooga Dualies Deco"], + ["5030", "Dualie Squelchers"], + ["5031", "Custom Dualie Squelchers"], + ["5032", "Hoofz Dualie Squelchers"], + ["5040", "Dark Tetra Dualies"], + ["5041", "Light Tetra Dualies"], + ["5050", "Douser Dualies FF"], + ["5051", "Custom Douser Dualies FF"], + ["6000", "Splat Brella"], + ["6001", "Sorella Brella"], + ["6005", "Order Brella Replica"], + ["6010", "Tenta Brella"], + ["6011", "Tenta Sorella Brella"], + ["6012", "Tenta Brella CRE-M"], + ["6020", "Undercover Brella"], + ["6021", "Undercover Sorella Brella"], + ["6022", "Patternz Undercover Brella"], + ["6030", "Recycled Brella 24 Mk I"], + ["6031", "Recycled Brella 24 Mk II"], + ["7010", "Tri-Stringer"], + ["7011", "Inkline Tri-Stringer"], + ["7012", "Bulbz Tri-Stringer"], + ["7015", "Order Stringer Replica"], + ["7020", "REEF-LUX 450"], + ["7021", "REEF-LUX 450 Deco"], + ["7022", "REEF-LUX 450 MIL-K"], + ["7030", "Wellstring V"], + ["7031", "Custom Wellstring V"], + ["8000", "Splatana Stamper"], + ["8001", "Splatana Stamper Nouveau"], + ["8002", "Stickerz Splatana Stamper"], + ["8005", "Order Splatana Replica"], + ["8010", "Splatana Wiper"], + ["8011", "Splatana Wiper Deco"], + ["8012", "Splatana Wiper RUS-T"], + ["8020", "Mint Decavitator"], + ["8021", "Charcoal Decavitator"], ]); const SUB_WEAPON_NAMES: ReadonlyMap = new Map([ - ["0", "Splat Bomb"], - ["1", "Suction Bomb"], - ["2", "Burst Bomb"], - ["3", "Sprinkler"], - ["4", "Splash Wall"], - ["5", "Fizzy Bomb"], - ["6", "Curling Bomb"], - ["7", "Autobomb"], - ["8", "Squid Beakon"], - ["9", "Point Sensor"], - ["10", "Ink Mine"], - ["11", "Toxic Mist"], - ["12", "Angle Shooter"], - ["13", "Torpedo"], + ["0", "Splat Bomb"], + ["1", "Suction Bomb"], + ["2", "Burst Bomb"], + ["3", "Sprinkler"], + ["4", "Splash Wall"], + ["5", "Fizzy Bomb"], + ["6", "Curling Bomb"], + ["7", "Autobomb"], + ["8", "Squid Beakon"], + ["9", "Point Sensor"], + ["10", "Ink Mine"], + ["11", "Toxic Mist"], + ["12", "Angle Shooter"], + ["13", "Torpedo"], ]); const SPECIAL_WEAPON_NAMES: ReadonlyMap = new Map([ - ["1", "Trizooka"], - ["2", "Big Bubbler"], - ["3", "Zipcaster"], - ["4", "Tenta Missiles"], - ["5", "Ink Storm"], - ["6", "Booyah Bomb"], - ["7", "Wave Breaker"], - ["8", "Ink Vac"], - ["9", "Killer Wail 5.1"], - ["10", "Inkjet"], - ["11", "Ultra Stamp"], - ["12", "Crab Tank"], - ["13", "Reefslider"], - ["14", "Triple Inkstrike"], - ["15", "Tacticooler"], - ["16", "Super Chump"], - ["17", "Kraken Royale"], - ["18", "Triple Splashdown"], - ["19", "Splattercolor Screen"], + ["1", "Trizooka"], + ["2", "Big Bubbler"], + ["3", "Zipcaster"], + ["4", "Tenta Missiles"], + ["5", "Ink Storm"], + ["6", "Booyah Bomb"], + ["7", "Wave Breaker"], + ["8", "Ink Vac"], + ["9", "Killer Wail 5.1"], + ["10", "Inkjet"], + ["11", "Ultra Stamp"], + ["12", "Crab Tank"], + ["13", "Reefslider"], + ["14", "Triple Inkstrike"], + ["15", "Tacticooler"], + ["16", "Super Chump"], + ["17", "Kraken Royale"], + ["18", "Triple Splashdown"], + ["19", "Splattercolor Screen"], ]); -function entriesOf(map: ReadonlyMap, type: WeaponType): WeaponEntry[] { - return [...map.entries()].map(([id, name]) => ({ id, name, type })); +function entriesOf( + map: ReadonlyMap, + type: WeaponType, +): WeaponEntry[] { + return [...map.entries()].map(([id, name]) => ({ id, name, type })); } /** Every weapon that can appear in the death message, across all kinds. */ export const ALL_WEAPON_ENTRIES: readonly WeaponEntry[] = [ - ...entriesOf(WEAPON_NAMES, "MAIN"), - ...entriesOf(SUB_WEAPON_NAMES, "SUB"), - ...entriesOf(SPECIAL_WEAPON_NAMES, "SPECIAL"), + ...entriesOf(WEAPON_NAMES, "MAIN"), + ...entriesOf(SUB_WEAPON_NAMES, "SUB"), + ...entriesOf(SPECIAL_WEAPON_NAMES, "SPECIAL"), ]; diff --git a/app/features/cv/core/detectors/map-start/index.ts b/app/features/cv/core/detectors/map-start/index.ts index cb22262a1..d0d036935 100644 --- a/app/features/cv/core/detectors/map-start/index.ts +++ b/app/features/cv/core/detectors/map-start/index.ts @@ -21,42 +21,51 @@ */ import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { getCV, type Mat, minMaxLoc } from "../../cv"; -import { type GlyphSet, type RecognizedText, recognizeText, scaleGlyphSet } from "../../glyphs"; +import { + type GlyphSet, + type RecognizedText, + recognizeText, + scaleGlyphSet, +} from "../../glyphs"; import { copyRoi, meanBrightness, minChannel } from "../../image"; -import { ALL_MODE_ENTRIES, ALL_MODE_LABELS, ALL_STAGE_ENTRIES } from "../../localized"; +import { + ALL_MODE_ENTRIES, + ALL_MODE_LABELS, + ALL_STAGE_ENTRIES, +} from "../../localized"; import { closestBy } from "../../text"; import type { ScoreboardResources } from "../scoreboard/index"; import type { DetectedEvent, Detector, GateResult } from "../types"; import { - BLOCK_MASK_RADIUS, - GATE_DARK_MAX_MEAN, - GATE_DARK_PROBES, - GATE_INK_BAND, - GATE_INK_BAND_MAX_BRIGHT, - GATE_INK_BAND_MIN_DARK, - GATE_TEXT_MAX_FRACTION, - GATE_TEXT_MIN_FRACTION, - GATE_TEXT_MIN_MAX, - LINE_GAP_TOLERANCE, - LINE_MIN_HEIGHT, - LINE_MIN_ROW_PIXELS, - LINE_ROW_FRACTION, - MASK_DARK_MAX, - MODE_BLOCK_ROI, - MODE_LABEL_ROI, - MODE_LABEL_TEXT_HEIGHT, - MODE_TEXT_HEIGHT, - STAGE_BIN_THRESHOLD, - STAGE_MASK_RADIUS, - STAGE_RAW_BIN_THRESHOLDS, - STAGE_ROI, - STAGE_TEXT_HEIGHT, - TEXT_BIN_THRESHOLD, + BLOCK_MASK_RADIUS, + GATE_DARK_MAX_MEAN, + GATE_DARK_PROBES, + GATE_INK_BAND, + GATE_INK_BAND_MAX_BRIGHT, + GATE_INK_BAND_MIN_DARK, + GATE_TEXT_MAX_FRACTION, + GATE_TEXT_MIN_FRACTION, + GATE_TEXT_MIN_MAX, + LINE_GAP_TOLERANCE, + LINE_MIN_HEIGHT, + LINE_MIN_ROW_PIXELS, + LINE_ROW_FRACTION, + MASK_DARK_MAX, + MODE_BLOCK_ROI, + MODE_LABEL_ROI, + MODE_LABEL_TEXT_HEIGHT, + MODE_TEXT_HEIGHT, + STAGE_BIN_THRESHOLD, + STAGE_MASK_RADIUS, + STAGE_RAW_BIN_THRESHOLDS, + STAGE_ROI, + STAGE_TEXT_HEIGHT, + TEXT_BIN_THRESHOLD, } from "./rois"; export interface MapStartData { - mode: ModeShort | null; - stage: StageId | null; + mode: ModeShort | null; + stage: StageId | null; } export const MAP_START_EVENT_TYPE = "MapStart"; @@ -67,267 +76,294 @@ const LABEL_MIN_SCORE = 0.5; const MIN_MATCH_SCORE = 0.62; interface LineBand { - y0: number; - y1: number; + y0: number; + y1: number; } /** Zero every pixel with no near-black pixel within `radius` of it. */ function maskNearDark(gray: Mat, radius: number): Mat { - const cv = getCV(); - const dark = new cv.Mat(); - cv.threshold(gray, dark, MASK_DARK_MAX, 255, cv.THRESH_BINARY_INV); - const kernel = cv.getStructuringElement( - cv.MORPH_ELLIPSE, - new cv.Size(2 * radius + 1, 2 * radius + 1), - ); - const near = new cv.Mat(); - cv.dilate(dark, near, kernel); - kernel.delete(); - dark.delete(); - const out = new cv.Mat(gray.rows, gray.cols, cv.CV_8UC1, new cv.Scalar(0)); - gray.copyTo(out, near); - near.delete(); - return out; + const cv = getCV(); + const dark = new cv.Mat(); + cv.threshold(gray, dark, MASK_DARK_MAX, 255, cv.THRESH_BINARY_INV); + const kernel = cv.getStructuringElement( + cv.MORPH_ELLIPSE, + new cv.Size(2 * radius + 1, 2 * radius + 1), + ); + const near = new cv.Mat(); + cv.dilate(dark, near, kernel); + kernel.delete(); + dark.delete(); + const out = new cv.Mat(gray.rows, gray.cols, cv.CV_8UC1, new cv.Scalar(0)); + gray.copyTo(out, near); + near.delete(); + return out; } /** Find text line bands in a binarized block by row projection. */ function findLineBands(binary: Mat): LineBand[] { - const { rows, cols, data } = binary; - const counts = new Array(rows).fill(0); - for (let y = 0; y < rows; y++) { - for (let x = 0; x < cols; x++) { - if (data[y * cols + x]! > 0) counts[y]!++; - } - } - // Dual threshold: a band must contain rows above a cutoff scaled to the - // strongest row — the block is sized for the widest localized titles, - // and background leaking past the splat's rim puts a scene-dependent - // noise floor under every row that the fixed floor alone can't sit - // above — but then extends over the fixed floor, so the antialiased - // glyph tops/bottoms stay inside the band. - const core = Math.max(LINE_MIN_ROW_PIXELS, LINE_ROW_FRACTION * Math.max(...counts)); - const bands: LineBand[] = []; - let start = -1; - let gap = 0; - for (let y = 0; y < rows; y++) { - if (counts[y]! >= core) { - if (start < 0) start = y; - gap = 0; - } else if (start >= 0 && ++gap > LINE_GAP_TOLERANCE) { - bands.push({ y0: start, y1: y - gap + 1 }); - start = -1; - } - } - if (start >= 0) bands.push({ y0: start, y1: rows - gap }); + const { rows, cols, data } = binary; + const counts = new Array(rows).fill(0); + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + if (data[y * cols + x]! > 0) counts[y]!++; + } + } + // Dual threshold: a band must contain rows above a cutoff scaled to the + // strongest row — the block is sized for the widest localized titles, + // and background leaking past the splat's rim puts a scene-dependent + // noise floor under every row that the fixed floor alone can't sit + // above — but then extends over the fixed floor, so the antialiased + // glyph tops/bottoms stay inside the band. + const core = Math.max( + LINE_MIN_ROW_PIXELS, + LINE_ROW_FRACTION * Math.max(...counts), + ); + const bands: LineBand[] = []; + let start = -1; + let gap = 0; + for (let y = 0; y < rows; y++) { + if (counts[y]! >= core) { + if (start < 0) start = y; + gap = 0; + } else if (start >= 0 && ++gap > LINE_GAP_TOLERANCE) { + bands.push({ y0: start, y1: y - gap + 1 }); + start = -1; + } + } + if (start >= 0) bands.push({ y0: start, y1: rows - gap }); - for (const band of bands) { - while (band.y0 > 0 && counts[band.y0 - 1]! >= LINE_MIN_ROW_PIXELS) band.y0--; - while (band.y1 < rows && counts[band.y1]! >= LINE_MIN_ROW_PIXELS) band.y1++; - } - const merged: LineBand[] = []; - for (const band of bands) { - const last = merged[merged.length - 1]; - if (last && band.y0 <= last.y1) last.y1 = Math.max(last.y1, band.y1); - else merged.push(band); - } - return merged.filter((b) => b.y1 - b.y0 >= LINE_MIN_HEIGHT); + for (const band of bands) { + while (band.y0 > 0 && counts[band.y0 - 1]! >= LINE_MIN_ROW_PIXELS) + band.y0--; + while (band.y1 < rows && counts[band.y1]! >= LINE_MIN_ROW_PIXELS) band.y1++; + } + const merged: LineBand[] = []; + for (const band of bands) { + const last = merged[merged.length - 1]; + if (last && band.y0 <= last.y1) last.y1 = Math.max(last.y1, band.y1); + else merged.push(band); + } + return merged.filter((b) => b.y1 - b.y0 >= LINE_MIN_HEIGHT); } /** * Trim a line band to its text columns (on the masked binary), so the raw * OCR crop excludes the background the wide block picks up at its edges. */ -function bandExtent(binary: Mat, band: LineBand): { x0: number; x1: number } | null { - const { cols, data } = binary; - let x0 = -1; - let x1 = -1; - for (let x = 0; x < cols; x++) { - let count = 0; - for (let y = band.y0; y < band.y1; y++) { - if (data[y * cols + x]! > 0) count++; - } - if (count >= 2) { - if (x0 < 0) x0 = x; - x1 = x; - } - } - return x0 < 0 ? null : { x0, x1 }; +function bandExtent( + binary: Mat, + band: LineBand, +): { x0: number; x1: number } | null { + const { cols, data } = binary; + let x0 = -1; + let x1 = -1; + for (let x = 0; x < cols; x++) { + let count = 0; + for (let y = band.y0; y < band.y1; y++) { + if (data[y * cols + x]! > 0) count++; + } + if (count >= 2) { + if (x0 < 0) x0 = x; + x1 = x; + } + } + return x0 < 0 ? null : { x0, x1 }; } -export function createMapStartDetector(resources: ScoreboardResources): Detector { - const cv = getCV(); +export function createMapStartDetector( + resources: ScoreboardResources, +): Detector { + const cv = getCV(); - const scaled = (set: GlyphSet | null | undefined, height: number): GlyphSet | null => - set ? scaleGlyphSet(set, height / set.height) : null; + const scaled = ( + set: GlyphSet | null | undefined, + height: number, + ): GlyphSet | null => (set ? scaleGlyphSet(set, height / set.height) : null); - const modeGlyphs = scaled(resources.mapStartModeGlyphs, MODE_TEXT_HEIGHT); - const stageGlyphs = scaled(resources.mapStartStageGlyphs, STAGE_TEXT_HEIGHT); - const labelGlyphs = scaled(resources.mapStartStageGlyphs, MODE_LABEL_TEXT_HEIGHT); + const modeGlyphs = scaled(resources.mapStartModeGlyphs, MODE_TEXT_HEIGHT); + const stageGlyphs = scaled(resources.mapStartStageGlyphs, STAGE_TEXT_HEIGHT); + const labelGlyphs = scaled( + resources.mapStartStageGlyphs, + MODE_LABEL_TEXT_HEIGHT, + ); - function gate(frame: Mat): GateResult { - let darkOk = 0; - for (const roi of GATE_DARK_PROBES) { - if (meanBrightness(frame, roi) < GATE_DARK_MAX_MEAN) darkOk++; - } + function gate(frame: Mat): GateResult { + let darkOk = 0; + for (const roi of GATE_DARK_PROBES) { + if (meanBrightness(frame, roi) < GATE_DARK_MAX_MEAN) darkOk++; + } - const gray = new cv.Mat(); - cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); + const gray = new cv.Mat(); + cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); - const label = copyRoi(gray, MODE_LABEL_ROI); - const { maxVal } = minMaxLoc(label); - const bin = new cv.Mat(); - cv.threshold(label, bin, TEXT_BIN_THRESHOLD, 255, cv.THRESH_BINARY); - label.delete(); - const whiteFraction = cv.countNonZero(bin) / (bin.rows * bin.cols); - bin.delete(); - const textOk = - maxVal > GATE_TEXT_MIN_MAX && - whiteFraction > GATE_TEXT_MIN_FRACTION && - whiteFraction < GATE_TEXT_MAX_FRACTION; + const label = copyRoi(gray, MODE_LABEL_ROI); + const { maxVal } = minMaxLoc(label); + const bin = new cv.Mat(); + cv.threshold(label, bin, TEXT_BIN_THRESHOLD, 255, cv.THRESH_BINARY); + label.delete(); + const whiteFraction = cv.countNonZero(bin) / (bin.rows * bin.cols); + bin.delete(); + const textOk = + maxVal > GATE_TEXT_MIN_MAX && + whiteFraction > GATE_TEXT_MIN_FRACTION && + whiteFraction < GATE_TEXT_MAX_FRACTION; - // the label-to-title gap core is solid ink on this screen: it must be - // near-totally dark (the scoreboards' pills aren't) and carry no bright - // pixels (the death burst's "Splatted by" line crosses it) - const band = copyRoi(gray, GATE_INK_BAND); - const bandPixels = band.rows * band.cols; - const bandBright = new cv.Mat(); - cv.threshold(band, bandBright, TEXT_BIN_THRESHOLD, 255, cv.THRESH_BINARY); - const brightFraction = cv.countNonZero(bandBright) / bandPixels; - bandBright.delete(); - const bandDark = new cv.Mat(); - cv.threshold(band, bandDark, MASK_DARK_MAX, 255, cv.THRESH_BINARY_INV); - const darkFraction = cv.countNonZero(bandDark) / bandPixels; - bandDark.delete(); - band.delete(); - const inkOk = - brightFraction <= GATE_INK_BAND_MAX_BRIGHT && darkFraction >= GATE_INK_BAND_MIN_DARK; - gray.delete(); + // the label-to-title gap core is solid ink on this screen: it must be + // near-totally dark (the scoreboards' pills aren't) and carry no bright + // pixels (the death burst's "Splatted by" line crosses it) + const band = copyRoi(gray, GATE_INK_BAND); + const bandPixels = band.rows * band.cols; + const bandBright = new cv.Mat(); + cv.threshold(band, bandBright, TEXT_BIN_THRESHOLD, 255, cv.THRESH_BINARY); + const brightFraction = cv.countNonZero(bandBright) / bandPixels; + bandBright.delete(); + const bandDark = new cv.Mat(); + cv.threshold(band, bandDark, MASK_DARK_MAX, 255, cv.THRESH_BINARY_INV); + const darkFraction = cv.countNonZero(bandDark) / bandPixels; + bandDark.delete(); + band.delete(); + const inkOk = + brightFraction <= GATE_INK_BAND_MAX_BRIGHT && + darkFraction >= GATE_INK_BAND_MIN_DARK; + gray.delete(); - const score = (darkOk / GATE_DARK_PROBES.length + (textOk ? 1 : 0) + (inkOk ? 1 : 0)) / 3; - return { pass: darkOk === GATE_DARK_PROBES.length && textOk && inkOk, score }; - } + const score = + (darkOk / GATE_DARK_PROBES.length + (textOk ? 1 : 0) + (inkOk ? 1 : 0)) / + 3; + return { + pass: darkOk === GATE_DARK_PROBES.length && textOk && inkOk, + score, + }; + } - function parse(frame: Mat, t: number): DetectedEvent[] { - const gray = new cv.Mat(); - cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); + function parse(frame: Mat, t: number): DetectedEvent[] { + const gray = new cv.Mat(); + cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); - // 1. confirm the constant label — a gate hit without it is a lookalike - let label: RecognizedText | null = null; - let labelScore = 0; - if (labelGlyphs) { - const crop = copyRoi(gray, MODE_LABEL_ROI); - label = recognizeText(crop, labelGlyphs, { - binThreshold: TEXT_BIN_THRESHOLD, - minCharScore: 0.3, - }); - crop.delete(); - labelScore = closestBy(label.text, ALL_MODE_LABELS, (l) => l)?.score ?? 0; - if (labelScore < LABEL_MIN_SCORE) { - gray.delete(); - return []; - } - } + // 1. confirm the constant label — a gate hit without it is a lookalike + let label: RecognizedText | null = null; + let labelScore = 0; + if (labelGlyphs) { + const crop = copyRoi(gray, MODE_LABEL_ROI); + label = recognizeText(crop, labelGlyphs, { + binThreshold: TEXT_BIN_THRESHOLD, + minCharScore: 0.3, + }); + crop.delete(); + labelScore = closestBy(label.text, ALL_MODE_LABELS, (l) => l)?.score ?? 0; + if (labelScore < LABEL_MIN_SCORE) { + gray.delete(); + return []; + } + } - // 2. mode title: find the 1-2 text lines, OCR each, snap the joined text - let mode: ModeShort | null = null; - let modeScore = 0; - let modeReading = ""; - if (modeGlyphs) { - const block = copyRoi(gray, MODE_BLOCK_ROI); - // find the line bands on the masked block so bright background rows - // don't merge/invent bands, but OCR the raw crop: the mask radius - // clips the widest title strokes and costs recognition accuracy. - // Each band is trimmed to its text columns (also from the mask) — - // the block is wide enough for the longest localized titles, and at - // full width the raw OCR picks up background junk at the edges. - const masked = maskNearDark(block, BLOCK_MASK_RADIUS); - const binary = new cv.Mat(); - cv.threshold(masked, binary, TEXT_BIN_THRESHOLD, 255, cv.THRESH_BINARY); - masked.delete(); - const bands = findLineBands(binary); - const lines: string[] = []; - for (const band of bands) { - const extent = bandExtent(binary, band); - if (!extent) continue; - const pad = 3; - const y0 = Math.max(0, band.y0 - pad); - const x0 = Math.max(0, extent.x0 - pad); - const line = copyRoi(block, { - x: x0, - y: y0, - w: Math.min(block.cols, extent.x1 + 1 + pad) - x0, - h: Math.min(block.rows, band.y1 + pad) - y0, - }); - const read = recognizeText(line, modeGlyphs, { - binThreshold: TEXT_BIN_THRESHOLD, - minCharScore: 0.3, - }); - line.delete(); - if (read.text.trim()) lines.push(read.text.trim()); - } - binary.delete(); - block.delete(); - modeReading = lines.join(" "); - const match = modeReading ? closestBy(modeReading, ALL_MODE_ENTRIES, (e) => e.text) : null; - if (match) { - modeScore = match.score; - if (match.score >= MIN_MATCH_SCORE) mode = match.entry.mode; - } - } + // 2. mode title: find the 1-2 text lines, OCR each, snap the joined text + let mode: ModeShort | null = null; + let modeScore = 0; + let modeReading = ""; + if (modeGlyphs) { + const block = copyRoi(gray, MODE_BLOCK_ROI); + // find the line bands on the masked block so bright background rows + // don't merge/invent bands, but OCR the raw crop: the mask radius + // clips the widest title strokes and costs recognition accuracy. + // Each band is trimmed to its text columns (also from the mask) — + // the block is wide enough for the longest localized titles, and at + // full width the raw OCR picks up background junk at the edges. + const masked = maskNearDark(block, BLOCK_MASK_RADIUS); + const binary = new cv.Mat(); + cv.threshold(masked, binary, TEXT_BIN_THRESHOLD, 255, cv.THRESH_BINARY); + masked.delete(); + const bands = findLineBands(binary); + const lines: string[] = []; + for (const band of bands) { + const extent = bandExtent(binary, band); + if (!extent) continue; + const pad = 3; + const y0 = Math.max(0, band.y0 - pad); + const x0 = Math.max(0, extent.x0 - pad); + const line = copyRoi(block, { + x: x0, + y: y0, + w: Math.min(block.cols, extent.x1 + 1 + pad) - x0, + h: Math.min(block.rows, band.y1 + pad) - y0, + }); + const read = recognizeText(line, modeGlyphs, { + binThreshold: TEXT_BIN_THRESHOLD, + minCharScore: 0.3, + }); + line.delete(); + if (read.text.trim()) lines.push(read.text.trim()); + } + binary.delete(); + block.delete(); + modeReading = lines.join(" "); + const match = modeReading + ? closestBy(modeReading, ALL_MODE_ENTRIES, (e) => e.text) + : null; + if (match) { + modeScore = match.score; + if (match.score >= MIN_MATCH_SCORE) mode = match.entry.mode; + } + } - // 3. stage name. The backdrop is live gameplay — dark water on one - // stage, a white mall floor brighter than the text's antialiased edges - // on another — so no single binarization works everywhere: read the - // near-dark-masked crop (wins on dark scenes) and the raw crop at a few - // rising thresholds (a high one isolates the text's saturated-white - // core from an only-nearly-white floor), and keep whichever snaps best. - let stage: StageId | null = null; - let stageScore = 0; - let stageReading = ""; - if (stageGlyphs) { - const rgbaCrop = copyRoi(frame, STAGE_ROI); - const bright = minChannel(rgbaCrop); - rgbaCrop.delete(); - const masked = maskNearDark(bright, STAGE_MASK_RADIUS); - const attempts: [Mat, number][] = [ - [masked, STAGE_BIN_THRESHOLD], - ...STAGE_RAW_BIN_THRESHOLDS.map((thr): [Mat, number] => [bright, thr]), - ]; - for (const [input, binThreshold] of attempts) { - const read = recognizeText(input, stageGlyphs, { binThreshold, minCharScore: 0.3 }); - const match = read.text ? closestBy(read.text, ALL_STAGE_ENTRIES, (e) => e.text) : null; - if (match && match.score > stageScore) { - stageScore = match.score; - stageReading = read.text; - if (match.score >= MIN_MATCH_SCORE) stage = match.entry.stageId; - } - } - masked.delete(); - bright.delete(); - } + // 3. stage name. The backdrop is live gameplay — dark water on one + // stage, a white mall floor brighter than the text's antialiased edges + // on another — so no single binarization works everywhere: read the + // near-dark-masked crop (wins on dark scenes) and the raw crop at a few + // rising thresholds (a high one isolates the text's saturated-white + // core from an only-nearly-white floor), and keep whichever snaps best. + let stage: StageId | null = null; + let stageScore = 0; + let stageReading = ""; + if (stageGlyphs) { + const rgbaCrop = copyRoi(frame, STAGE_ROI); + const bright = minChannel(rgbaCrop); + rgbaCrop.delete(); + const masked = maskNearDark(bright, STAGE_MASK_RADIUS); + const attempts: [Mat, number][] = [ + [masked, STAGE_BIN_THRESHOLD], + ...STAGE_RAW_BIN_THRESHOLDS.map((thr): [Mat, number] => [bright, thr]), + ]; + for (const [input, binThreshold] of attempts) { + const read = recognizeText(input, stageGlyphs, { + binThreshold, + minCharScore: 0.3, + }); + const match = read.text + ? closestBy(read.text, ALL_STAGE_ENTRIES, (e) => e.text) + : null; + if (match && match.score > stageScore) { + stageScore = match.score; + stageReading = read.text; + if (match.score >= MIN_MATCH_SCORE) stage = match.entry.stageId; + } + } + masked.delete(); + bright.delete(); + } - gray.delete(); + gray.delete(); - return [ - { - type: MAP_START_EVENT_TYPE, - t, - // mean like every other detector: a clean mode read must survive an - // unreadable stage (map-start is the only mode source for VoD matches), - // not be zeroed by it - confidence: (modeScore + stageScore) / 2, - data: { mode, stage }, - debug: { - label: label?.text, - labelScore, - modeReading, - modeScore, - stageReading, - stageScore, - }, - }, - ]; - } + return [ + { + type: MAP_START_EVENT_TYPE, + t, + // mean like every other detector: a clean mode read must survive an + // unreadable stage (map-start is the only mode source for VoD matches), + // not be zeroed by it + confidence: (modeScore + stageScore) / 2, + data: { mode, stage }, + debug: { + label: label?.text, + labelScore, + modeReading, + modeScore, + stageReading, + stageScore, + }, + }, + ]; + } - return { id: "map-start", gate, parse }; + return { id: "map-start", gate, parse }; } diff --git a/app/features/cv/core/detectors/map-start/rois.ts b/app/features/cv/core/detectors/map-start/rois.ts index 92235d437..df06b5c02 100644 --- a/app/features/cv/core/detectors/map-start/rois.ts +++ b/app/features/cv/core/detectors/map-start/rois.ts @@ -90,10 +90,10 @@ export const STAGE_RAW_BIN_THRESHOLDS: readonly number[] = [225, 235, 245]; * own white strokes blow the probe means. */ export const GATE_DARK_PROBES: readonly Roi[] = [ - { x: 780, y: 280, w: 30, h: 20 }, - { x: 1105, y: 280, w: 30, h: 20 }, - { x: 850, y: 355, w: 30, h: 20 }, - { x: 1030, y: 355, w: 30, h: 20 }, + { x: 780, y: 280, w: 30, h: 20 }, + { x: 1105, y: 280, w: 30, h: 20 }, + { x: 850, y: 355, w: 30, h: 20 }, + { x: 1030, y: 355, w: 30, h: 20 }, ]; export const GATE_DARK_MAX_MEAN = 45; diff --git a/app/features/cv/core/detectors/minimap/abilities.ts b/app/features/cv/core/detectors/minimap/abilities.ts index 20d38f9e7..c4b27d5ae 100644 --- a/app/features/cv/core/detectors/minimap/abilities.ts +++ b/app/features/cv/core/detectors/minimap/abilities.ts @@ -6,15 +6,19 @@ import type { FrameData } from "../../image"; import { buildAbilityRole } from "../death/abilities"; import type { WeaponTemplate } from "../scoreboard/weapons"; -import { BADGE_ART_RATIO, BADGE_TEMPLATE_SIZES, MINIMAP_ABILITY_INK_THRESHOLD } from "./rois"; +import { + BADGE_ART_RATIO, + BADGE_TEMPLATE_SIZES, + MINIMAP_ABILITY_INK_THRESHOLD, +} from "./rois"; export function prepareMinimapAbilityTemplates( - icons: { id: string; image: FrameData }[], + icons: { id: string; image: FrameData }[], ): WeaponTemplate[] { - return buildAbilityRole( - icons, - BADGE_TEMPLATE_SIZES, - BADGE_ART_RATIO, - MINIMAP_ABILITY_INK_THRESHOLD, - ); + return buildAbilityRole( + icons, + BADGE_TEMPLATE_SIZES, + BADGE_ART_RATIO, + MINIMAP_ABILITY_INK_THRESHOLD, + ); } diff --git a/app/features/cv/core/detectors/minimap/index.ts b/app/features/cv/core/detectors/minimap/index.ts index 7e862b7e2..bc521d1c7 100644 --- a/app/features/cv/core/detectors/minimap/index.ts +++ b/app/features/cv/core/detectors/minimap/index.ts @@ -20,107 +20,108 @@ * templates anti-correlate there) — so a corner-brightness probe picks * the template set, ink threshold, and score floor per card. */ + +import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types"; +import { type CvAbility, toCvAbility, toMainWeaponId } from "../../../cv-types"; import { getCV, type Mat } from "../../cv"; import { type GlyphSet, scaleGlyphSet } from "../../glyphs"; import { - copyRoi, - cropRoi, - laplacianAbs, - maxBrightness, - meanBrightness, - type Roi, + copyRoi, + cropRoi, + laplacianAbs, + maxBrightness, + meanBrightness, + type Roi, } from "../../image"; -import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types"; -import { type CvAbility, toCvAbility, toMainWeaponId } from "../../../cv-types"; import type { ScoreboardResources } from "../scoreboard/index"; import { type ParsedName, parseName } from "../scoreboard/names"; import { - disambiguateWeaponBySub, - matchSpecial, - tiedWeaponsWithDistinctSubs, + disambiguateWeaponBySub, + matchSpecial, + tiedWeaponsWithDistinctSubs, } from "../scoreboard/specials"; import { matchWeapon, type WeaponMatch } from "../scoreboard/weapons"; import type { DetectedEvent, Detector, GateResult } from "../types"; import { - badgeRoi, - CARD_LAYOUTS, - type CardSlot, - CROSS_MIN_FRACTION, - CROSS_SATURATION_MIN, - CROSS_VALUE_MIN, - ENEMY_BADGE_XS, - ENEMY_ROW_CYS, - enemyCrossRoi, - enemySubTileRoi, - enemyWeaponRoi, - GATE_BRIGHT_MIN_MAX, - GATE_CLOSE_BRIGHT, - GATE_CLOSE_DARK_PROBES, - GATE_DARK_MAX_MEAN, - GATE_SPAWN_BRIGHT, - GATE_SPAWN_DARK_PROBES, - GATE_SPECTATOR_X_BRIGHT, - GATE_SPECTATOR_X_DARK, - MINIMAP_ABILITY_INK_THRESHOLD, - MINIMAP_WEAPON_INK_THRESHOLD, - NAME_BIN_THRESHOLD, - NAME_TEXT_HEIGHT, - PRESENCE_MIN_LAPLACIAN, - SPECIAL_READY_INK_THRESHOLD, - SPECIAL_READY_MIN_CORNER_MEAN, - SPECIAL_READY_WEAPON_MIN_SCORE, - SPECTATOR_ENEMY_DX, - SPECTATOR_NAME_TEXT_HEIGHTS, - SPECTATOR_SLOTS, - spectatorCardLayout, - WEAPON_MIN_SCORE, + badgeRoi, + CARD_LAYOUTS, + type CardSlot, + CROSS_MIN_FRACTION, + CROSS_SATURATION_MIN, + CROSS_VALUE_MIN, + ENEMY_BADGE_XS, + ENEMY_ROW_CYS, + enemyCrossRoi, + enemySubTileRoi, + enemyWeaponRoi, + GATE_BRIGHT_MIN_MAX, + GATE_CLOSE_BRIGHT, + GATE_CLOSE_DARK_PROBES, + GATE_DARK_MAX_MEAN, + GATE_SPAWN_BRIGHT, + GATE_SPAWN_DARK_PROBES, + GATE_SPECTATOR_X_BRIGHT, + GATE_SPECTATOR_X_DARK, + MINIMAP_ABILITY_INK_THRESHOLD, + MINIMAP_WEAPON_INK_THRESHOLD, + NAME_BIN_THRESHOLD, + NAME_TEXT_HEIGHT, + PRESENCE_MIN_LAPLACIAN, + SPECIAL_READY_INK_THRESHOLD, + SPECIAL_READY_MIN_CORNER_MEAN, + SPECIAL_READY_WEAPON_MIN_SCORE, + SPECTATOR_ENEMY_DX, + SPECTATOR_NAME_TEXT_HEIGHTS, + SPECTATOR_SLOTS, + spectatorCardLayout, + WEAPON_MIN_SCORE, } from "./rois"; import { matchStage, plannerSignature, type StageMatch } from "./stage"; export interface MinimapTeammate { - /** which callout card: super-jump slot, or the POV player's own card */ - slot: CardSlot; - /** card name; null when covered by a respawn cross-out or unreadable */ - name: string | null; - /** sendou main-weapon id; null when unreadable/covered */ - weaponId: MainWeaponId | null; - /** - * the card's three main abilities, [head, clothes, shoes] (null per - * unreadable badge); empty when a respawn cross-out sits over the badges - */ - abilities: (CvAbility | null)[]; + /** which callout card: super-jump slot, or the POV player's own card */ + slot: CardSlot; + /** card name; null when covered by a respawn cross-out or unreadable */ + name: string | null; + /** sendou main-weapon id; null when unreadable/covered */ + weaponId: MainWeaponId | null; + /** + * the card's three main abilities, [head, clothes, shoes] (null per + * unreadable badge); empty when a respawn cross-out sits over the badges + */ + abilities: (CvAbility | null)[]; } export interface MinimapEnemy { - /** - * the POV overlay shows no enemy names (always null there); the - * spectator screen does, so spectator rows carry them - */ - name: string | null; - /** readable even on struck rows: the cross-out spares the weapon icon */ - weaponId: MainWeaponId | null; - abilities: (CvAbility | null)[]; + /** + * the POV overlay shows no enemy names (always null there); the + * spectator screen does, so spectator rows carry them + */ + name: string | null; + /** readable even on struck rows: the cross-out spares the weapon icon */ + weaponId: MainWeaponId | null; + abilities: (CvAbility | null)[]; } export interface MinimapData { - /** - * sendou stage id, matched from the drawn map against the planner - * renders (stage.ts); null when no stage matched confidently or the - * planner signatures were not loaded. The mode is not identifiable this - * way (see stage.ts) and is left to the mode-bearing detectors. - */ - stage: StageId | null; - /** - * true when the frame is a casted stream's 8-player spectator map screen - * rather than the POV overlay: the alpha (left) column is reported as - * teammates (d-pad slots up/right/down/left) and the bravo (right) - * column as enemy rows — with names, which this screen shows - */ - spectator: boolean; - /** own-team callout cards; a slot missing from the frame is omitted */ - teammates: MinimapTeammate[]; - /** enemy panel rows, top to bottom */ - enemies: MinimapEnemy[]; + /** + * sendou stage id, matched from the drawn map against the planner + * renders (stage.ts); null when no stage matched confidently or the + * planner signatures were not loaded. The mode is not identifiable this + * way (see stage.ts) and is left to the mode-bearing detectors. + */ + stage: StageId | null; + /** + * true when the frame is a casted stream's 8-player spectator map screen + * rather than the POV overlay: the alpha (left) column is reported as + * teammates (d-pad slots up/right/down/left) and the bravo (right) + * column as enemy rows — with names, which this screen shows + */ + spectator: boolean; + /** own-team callout cards; a slot missing from the frame is omitted */ + teammates: MinimapTeammate[]; + /** enemy panel rows, top to bottom */ + enemies: MinimapEnemy[]; } export const MINIMAP_EVENT_TYPE = "Minimap"; @@ -135,451 +136,497 @@ const ABILITY_MIN_SCORE = 0.45; * cross-out stroke lights up the other. */ function minTopCornerMean(gray: Mat, roi: Roi): number { - const corners: Roi[] = [ - { x: roi.x, y: roi.y, w: 8, h: 8 }, - { x: roi.x + roi.w - 8, y: roi.y, w: 8, h: 8 }, - ]; - return Math.min(...corners.map((c) => meanBrightness(gray, c))); + const corners: Roi[] = [ + { x: roi.x, y: roi.y, w: 8, h: 8 }, + { x: roi.x + roi.w - 8, y: roi.y, w: 8, h: 8 }, + ]; + return Math.min(...corners.map((c) => meanBrightness(gray, c))); } /** fraction of the probe that is saturated-and-bright (cross-out strokes) */ function saturatedFraction(hsv: Mat, roi: Roi): number { - const m = copyRoi(hsv, roi); - const n = m.rows * m.cols; - const md = m.data; - let hit = 0; - for (let i = 0; i < n; i++) { - if (md[i * 3 + 1]! >= CROSS_SATURATION_MIN && md[i * 3 + 2]! >= CROSS_VALUE_MIN) { - hit++; - } - } - m.delete(); - return hit / n; + const m = copyRoi(hsv, roi); + const n = m.rows * m.cols; + const md = m.data; + let hit = 0; + for (let i = 0; i < n; i++) { + if ( + md[i * 3 + 1]! >= CROSS_SATURATION_MIN && + md[i * 3 + 2]! >= CROSS_VALUE_MIN + ) { + hit++; + } + } + m.delete(); + return hit / n; } -export function createMinimapDetector(resources: ScoreboardResources): Detector { - const cv = getCV(); +export function createMinimapDetector( + resources: ScoreboardResources, +): Detector { + const cv = getCV(); - const nameGlyphs: GlyphSet | null = resources.nameGlyphs - ? scaleGlyphSet(resources.nameGlyphs, NAME_TEXT_HEIGHT / resources.nameGlyphs.height) - : null; - const spectatorNameGlyphs: GlyphSet[] = resources.nameGlyphs - ? SPECTATOR_NAME_TEXT_HEIGHTS.map((h) => - h === NAME_TEXT_HEIGHT - ? nameGlyphs! - : scaleGlyphSet(resources.nameGlyphs!, h / resources.nameGlyphs!.height), - ) - : []; - const cardWeapons = resources.minimapCardWeapons ?? null; - const lightWeapons = resources.minimapLightWeapons ?? null; - const badges = resources.minimapAbilities ?? null; - const subWeapons = resources.minimapSubWeapons ?? null; - const plannerStages = resources.plannerStages ?? null; + const nameGlyphs: GlyphSet | null = resources.nameGlyphs + ? scaleGlyphSet( + resources.nameGlyphs, + NAME_TEXT_HEIGHT / resources.nameGlyphs.height, + ) + : null; + const spectatorNameGlyphs: GlyphSet[] = resources.nameGlyphs + ? SPECTATOR_NAME_TEXT_HEIGHTS.map((h) => + h === NAME_TEXT_HEIGHT + ? nameGlyphs! + : scaleGlyphSet( + resources.nameGlyphs!, + h / resources.nameGlyphs!.height, + ), + ) + : []; + const cardWeapons = resources.minimapCardWeapons ?? null; + const lightWeapons = resources.minimapLightWeapons ?? null; + const badges = resources.minimapAbilities ?? null; + const subWeapons = resources.minimapSubWeapons ?? null; + const plannerStages = resources.plannerStages ?? null; - /** Identify the stage from the drawn map; contributes to confidence. */ - function detectStage(frame: Mat, confidences: number[]): StageMatch | null { - if (!plannerStages?.length) return null; - const sig = plannerSignature(frame); - const match = matchStage(sig, plannerStages); - if (match) confidences.push(match.score); - return match; - } + /** Identify the stage from the drawn map; contributes to confidence. */ + function detectStage(frame: Mat, confidences: number[]): StageMatch | null { + if (!plannerStages?.length) return null; + const sig = plannerSignature(frame); + const match = matchStage(sig, plannerStages); + if (match) confidences.push(match.score); + return match; + } - function probeGate( - gray: Mat, - darkProbes: readonly Roi[], - brightProbes: readonly Roi[], - ): GateResult { - let darkOk = 0; - for (const roi of darkProbes) { - if (meanBrightness(gray, roi) <= GATE_DARK_MAX_MEAN) darkOk++; - } - let brightOk = 0; - for (const roi of brightProbes) { - if (maxBrightness(gray, roi) >= GATE_BRIGHT_MIN_MAX) brightOk++; - } - return { - pass: darkOk === darkProbes.length && brightOk === brightProbes.length, - score: (darkOk / darkProbes.length + brightOk / brightProbes.length) / 2, - }; - } + function probeGate( + gray: Mat, + darkProbes: readonly Roi[], + brightProbes: readonly Roi[], + ): GateResult { + let darkOk = 0; + for (const roi of darkProbes) { + if (meanBrightness(gray, roi) <= GATE_DARK_MAX_MEAN) darkOk++; + } + let brightOk = 0; + for (const roi of brightProbes) { + if (maxBrightness(gray, roi) >= GATE_BRIGHT_MIN_MAX) brightOk++; + } + return { + pass: darkOk === darkProbes.length && brightOk === brightProbes.length, + score: (darkOk / darkProbes.length + brightOk / brightProbes.length) / 2, + }; + } - /** POV overlay chrome: close-button disc + Spawn Point pill. */ - function overlayGate(gray: Mat): GateResult { - return probeGate( - gray, - [...GATE_CLOSE_DARK_PROBES, ...GATE_SPAWN_DARK_PROBES], - [GATE_CLOSE_BRIGHT, GATE_SPAWN_BRIGHT], - ); - } + /** POV overlay chrome: close-button disc + Spawn Point pill. */ + function overlayGate(gray: Mat): GateResult { + return probeGate( + gray, + [...GATE_CLOSE_DARK_PROBES, ...GATE_SPAWN_DARK_PROBES], + [GATE_CLOSE_BRIGHT, GATE_SPAWN_BRIGHT], + ); + } - /** Spectator screen: the X jump-button disc beside the 8th player card. */ - function spectatorGate(gray: Mat): GateResult { - return probeGate(gray, GATE_SPECTATOR_X_DARK, GATE_SPECTATOR_X_BRIGHT); - } + /** Spectator screen: the X jump-button disc beside the 8th player card. */ + function spectatorGate(gray: Mat): GateResult { + return probeGate(gray, GATE_SPECTATOR_X_DARK, GATE_SPECTATOR_X_BRIGHT); + } - function gate(frame: Mat): GateResult { - const gray = new cv.Mat(); - cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); - const overlay = overlayGate(gray); - const spectator = spectatorGate(gray); - gray.delete(); - return { - pass: overlay.pass || spectator.pass, - score: Math.max(overlay.score, spectator.score), - variant: spectator.pass ? "spectator" : "overlay", - }; - } + function gate(frame: Mat): GateResult { + const gray = new cv.Mat(); + cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); + const overlay = overlayGate(gray); + const spectator = spectatorGate(gray); + gray.delete(); + return { + pass: overlay.pass || spectator.pass, + score: Math.max(overlay.score, spectator.score), + variant: spectator.pass ? "spectator" : "overlay", + }; + } - function matchBadges( - rgb: Mat, - centers: readonly (readonly [number, number])[], - inkThreshold: number, - confidences: number[], - debugRow: (WeaponMatch | null)[], - ): (CvAbility | null)[] { - if (!badges) return [null, null, null]; - return centers.map(([cx, cy]) => { - const crop = cropRoi(rgb, badgeRoi(cx, cy)); - const match = matchWeapon(crop, badges, { inkThreshold }); - crop.delete(); - debugRow.push(match); - confidences.push(Math.max(0, match.score)); - return match.score >= ABILITY_MIN_SCORE ? toCvAbility(match.id) : null; - }); - } + function matchBadges( + rgb: Mat, + centers: readonly (readonly [number, number])[], + inkThreshold: number, + confidences: number[], + debugRow: (WeaponMatch | null)[], + ): (CvAbility | null)[] { + if (!badges) return [null, null, null]; + return centers.map(([cx, cy]) => { + const crop = cropRoi(rgb, badgeRoi(cx, cy)); + const match = matchWeapon(crop, badges, { inkThreshold }); + crop.delete(); + debugRow.push(match); + confidences.push(Math.max(0, match.score)); + return match.score >= ABILITY_MIN_SCORE ? toCvAbility(match.id) : null; + }); + } - /** - * Near-tied weapon icons whose kits differ by sub (plain vs Custom - * Dualie Squelchers): let the card/row's team-tinted sub tile break the - * tie. Shape-only matching survives the tint, the camo surface, and a - * cross-out stroke clipping the tile. - */ - function resolveTieBySubTile(rgb: Mat, weapon: WeaponMatch, tile: Roi): WeaponMatch { - if (!subWeapons?.length || !tiedWeaponsWithDistinctSubs(weapon)) return weapon; - const crop = cropRoi(rgb, tile); - const sub = matchSpecial(crop, subWeapons); - crop.delete(); - return disambiguateWeaponBySub(weapon, sub); - } + /** + * Near-tied weapon icons whose kits differ by sub (plain vs Custom + * Dualie Squelchers): let the card/row's team-tinted sub tile break the + * tie. Shape-only matching survives the tint, the camo surface, and a + * cross-out stroke clipping the tile. + */ + function resolveTieBySubTile( + rgb: Mat, + weapon: WeaponMatch, + tile: Roi, + ): WeaponMatch { + if (!subWeapons?.length || !tiedWeaponsWithDistinctSubs(weapon)) + return weapon; + const crop = cropRoi(rgb, tile); + const sub = matchSpecial(crop, subWeapons); + crop.delete(); + return disambiguateWeaponBySub(weapon, sub); + } - /** Try the name band at each spectator glyph height; best read wins. */ - function bestNameRead(gray: Mat, roi: Roi): ParsedName | null { - let best: ParsedName | null = null; - for (const set of spectatorNameGlyphs) { - const band = copyRoi(gray, roi); - const parsed = parseName(band, set, { binThreshold: NAME_BIN_THRESHOLD }); - band.delete(); - if (!best || parsed.confidence > best.confidence) best = parsed; - } - return best; - } + /** Try the name band at each spectator glyph height; best read wins. */ + function bestNameRead(gray: Mat, roi: Roi): ParsedName | null { + let best: ParsedName | null = null; + for (const set of spectatorNameGlyphs) { + const band = copyRoi(gray, roi); + const parsed = parseName(band, set, { binThreshold: NAME_BIN_THRESHOLD }); + band.delete(); + if (!best || parsed.confidence > best.confidence) best = parsed; + } + return best; + } - /** - * The spectator screen's 8-card grid doesn't share the overlay's ROIs - * (running the overlay parse against it reads phantom cards), so it gets - * its own card loop: same fields per card, both columns carry names. - */ - function parseSpectator(frame: Mat, gray: Mat, t: number): DetectedEvent[] { - const rgb = new cv.Mat(); - cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB); - const hsv = new cv.Mat(); - cv.cvtColor(rgb, hsv, cv.COLOR_RGB2HSV); - const lap = laplacianAbs(gray); + /** + * The spectator screen's 8-card grid doesn't share the overlay's ROIs + * (running the overlay parse against it reads phantom cards), so it gets + * its own card loop: same fields per card, both columns carry names. + */ + function parseSpectator( + frame: Mat, + gray: Mat, + t: number, + ): DetectedEvent[] { + const rgb = new cv.Mat(); + cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB); + const hsv = new cv.Mat(); + cv.cvtColor(rgb, hsv, cv.COLOR_RGB2HSV); + const lap = laplacianAbs(gray); - const confidences: number[] = []; - const debug: Record = { spectator: true }; + const confidences: number[] = []; + const debug: Record = { spectator: true }; - const teammates: MinimapTeammate[] = []; - const enemies: MinimapEnemy[] = []; - const cardDebug: Record[] = []; - for (const dx of [0, SPECTATOR_ENEMY_DX]) { - for (let row = 0; row < 4; row++) { - const layout = spectatorCardLayout(row, dx); - const presence = meanBrightness(lap, layout.name); - if (presence < PRESENCE_MIN_LAPLACIAN) { - cardDebug.push({ dx, row, presence, skipped: true }); - continue; - } - const crossFraction = saturatedFraction(hsv, layout.cross); - const occluded = crossFraction >= CROSS_MIN_FRACTION; - const cornerMin = minTopCornerMean(gray, layout.weapon); - const lightSurface = cornerMin >= SPECIAL_READY_MIN_CORNER_MEAN; + const teammates: MinimapTeammate[] = []; + const enemies: MinimapEnemy[] = []; + const cardDebug: Record[] = []; + for (const dx of [0, SPECTATOR_ENEMY_DX]) { + for (let row = 0; row < 4; row++) { + const layout = spectatorCardLayout(row, dx); + const presence = meanBrightness(lap, layout.name); + if (presence < PRESENCE_MIN_LAPLACIAN) { + cardDebug.push({ dx, row, presence, skipped: true }); + continue; + } + const crossFraction = saturatedFraction(hsv, layout.cross); + const occluded = crossFraction >= CROSS_MIN_FRACTION; + const cornerMin = minTopCornerMean(gray, layout.weapon); + const lightSurface = cornerMin >= SPECIAL_READY_MIN_CORNER_MEAN; - let name: string | null = null; - let nameRaw = ""; - let weapon: WeaponMatch | null = null; - const badgeDebug: (WeaponMatch | null)[] = []; - let abilities: (CvAbility | null)[] = []; - if (!occluded) { - const parsed = bestNameRead(gray, layout.name); - if (parsed) { - nameRaw = parsed.raw.text; - if (parsed.name.length > 0) name = parsed.name; - confidences.push(parsed.confidence); - } - const templates = lightSurface ? lightWeapons : cardWeapons; - if (templates) { - const crop = cropRoi(rgb, layout.weapon); - weapon = matchWeapon(crop, templates, { - inkThreshold: lightSurface - ? SPECIAL_READY_INK_THRESHOLD - : Math.max(MINIMAP_WEAPON_INK_THRESHOLD, Math.round(cornerMin) + 50), - }); - crop.delete(); - weapon = resolveTieBySubTile(rgb, weapon, layout.subTile); - confidences.push(Math.max(0, weapon.score)); - } - abilities = matchBadges( - rgb, - layout.badges, - Math.max(MINIMAP_ABILITY_INK_THRESHOLD, Math.round(cornerMin) + 50), - confidences, - badgeDebug, - ); - } - cardDebug.push({ - dx, - row, - presence, - crossFraction, - occluded, - cornerMin, - lightSurface, - nameRaw, - weapon, - badges: badgeDebug, - }); + let name: string | null = null; + let nameRaw = ""; + let weapon: WeaponMatch | null = null; + const badgeDebug: (WeaponMatch | null)[] = []; + let abilities: (CvAbility | null)[] = []; + if (!occluded) { + const parsed = bestNameRead(gray, layout.name); + if (parsed) { + nameRaw = parsed.raw.text; + if (parsed.name.length > 0) name = parsed.name; + confidences.push(parsed.confidence); + } + const templates = lightSurface ? lightWeapons : cardWeapons; + if (templates) { + const crop = cropRoi(rgb, layout.weapon); + weapon = matchWeapon(crop, templates, { + inkThreshold: lightSurface + ? SPECIAL_READY_INK_THRESHOLD + : Math.max( + MINIMAP_WEAPON_INK_THRESHOLD, + Math.round(cornerMin) + 50, + ), + }); + crop.delete(); + weapon = resolveTieBySubTile(rgb, weapon, layout.subTile); + confidences.push(Math.max(0, weapon.score)); + } + abilities = matchBadges( + rgb, + layout.badges, + Math.max(MINIMAP_ABILITY_INK_THRESHOLD, Math.round(cornerMin) + 50), + confidences, + badgeDebug, + ); + } + cardDebug.push({ + dx, + row, + presence, + crossFraction, + occluded, + cornerMin, + lightSurface, + nameRaw, + weapon, + badges: badgeDebug, + }); - const floor = lightSurface ? SPECIAL_READY_WEAPON_MIN_SCORE : WEAPON_MIN_SCORE; - const matched = weapon !== null && weapon.score >= floor ? weapon : null; - const fields = { - name, - weaponId: matched ? toMainWeaponId(matched.id) : null, - abilities, - }; - if (dx === 0) { - teammates.push({ slot: SPECTATOR_SLOTS[row]!, ...fields }); - } else { - enemies.push(fields); - } - } - } - debug.cards = cardDebug; + const floor = lightSurface + ? SPECIAL_READY_WEAPON_MIN_SCORE + : WEAPON_MIN_SCORE; + const matched = + weapon !== null && weapon.score >= floor ? weapon : null; + const fields = { + name, + weaponId: matched ? toMainWeaponId(matched.id) : null, + abilities, + }; + if (dx === 0) { + teammates.push({ slot: SPECTATOR_SLOTS[row]!, ...fields }); + } else { + enemies.push(fields); + } + } + } + debug.cards = cardDebug; - const stageMatch = detectStage(frame, confidences); - debug.stage = stageMatch; + const stageMatch = detectStage(frame, confidences); + debug.stage = stageMatch; - rgb.delete(); - hsv.delete(); - lap.delete(); + rgb.delete(); + hsv.delete(); + lap.delete(); - const confidence = - confidences.length > 0 ? confidences.reduce((a, b) => a + b, 0) / confidences.length : 0; + const confidence = + confidences.length > 0 + ? confidences.reduce((a, b) => a + b, 0) / confidences.length + : 0; - return [ - { - type: MINIMAP_EVENT_TYPE, - t, - confidence, - data: { - stage: stageMatch?.stageId ?? null, - spectator: true, - teammates, - enemies, - }, - debug, - }, - ]; - } + return [ + { + type: MINIMAP_EVENT_TYPE, + t, + confidence, + data: { + stage: stageMatch?.stageId ?? null, + spectator: true, + teammates, + enemies, + }, + debug, + }, + ]; + } - function parse(frame: Mat, t: number, gateResult?: GateResult): DetectedEvent[] { - const gray = new cv.Mat(); - cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); + function parse( + frame: Mat, + t: number, + gateResult?: GateResult, + ): DetectedEvent[] { + const gray = new cv.Mat(); + cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); - const isSpectator = gateResult?.variant - ? gateResult.variant === "spectator" - : spectatorGate(gray).pass; - if (isSpectator) { - const events = parseSpectator(frame, gray, t); - gray.delete(); - return events; - } + const isSpectator = gateResult?.variant + ? gateResult.variant === "spectator" + : spectatorGate(gray).pass; + if (isSpectator) { + const events = parseSpectator(frame, gray, t); + gray.delete(); + return events; + } - const rgb = new cv.Mat(); - cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB); - const hsv = new cv.Mat(); - cv.cvtColor(rgb, hsv, cv.COLOR_RGB2HSV); - const lap = laplacianAbs(gray); + const rgb = new cv.Mat(); + cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB); + const hsv = new cv.Mat(); + cv.cvtColor(rgb, hsv, cv.COLOR_RGB2HSV); + const lap = laplacianAbs(gray); - const confidences: number[] = []; - const debug: Record = {}; + const confidences: number[] = []; + const debug: Record = {}; - // 1. own-team callout cards - const teammates: MinimapTeammate[] = []; - const cardDebug: Record[] = []; - for (const layout of CARD_LAYOUTS) { - // presence: the card is crisp UI, absent slots show blurred scene - const presence = meanBrightness(lap, layout.name); - if (presence < PRESENCE_MIN_LAPLACIAN) { - cardDebug.push({ slot: layout.slot, presence, skipped: true }); - continue; - } - const crossFraction = saturatedFraction(hsv, layout.cross); - const occluded = crossFraction >= CROSS_MIN_FRACTION; - const cornerMin = minTopCornerMean(gray, layout.weapon); - const lightSurface = cornerMin >= SPECIAL_READY_MIN_CORNER_MEAN; + // 1. own-team callout cards + const teammates: MinimapTeammate[] = []; + const cardDebug: Record[] = []; + for (const layout of CARD_LAYOUTS) { + // presence: the card is crisp UI, absent slots show blurred scene + const presence = meanBrightness(lap, layout.name); + if (presence < PRESENCE_MIN_LAPLACIAN) { + cardDebug.push({ slot: layout.slot, presence, skipped: true }); + continue; + } + const crossFraction = saturatedFraction(hsv, layout.cross); + const occluded = crossFraction >= CROSS_MIN_FRACTION; + const cornerMin = minTopCornerMean(gray, layout.weapon); + const lightSurface = cornerMin >= SPECIAL_READY_MIN_CORNER_MEAN; - let name: string | null = null; - let nameRaw = ""; - let weapon: WeaponMatch | null = null; - const badgeDebug: (WeaponMatch | null)[] = []; - let abilities: (CvAbility | null)[] = []; - if (!occluded) { - if (nameGlyphs) { - const band = copyRoi(gray, layout.name); - const parsed = parseName(band, nameGlyphs, { - binThreshold: NAME_BIN_THRESHOLD, - }); - band.delete(); - nameRaw = parsed.raw.text; - if (parsed.name.length > 0) name = parsed.name; - confidences.push(parsed.confidence); - } - const templates = lightSurface ? lightWeapons : cardWeapons; - if (templates) { - const crop = cropRoi(rgb, layout.weapon); - weapon = matchWeapon(crop, templates, { - inkThreshold: lightSurface ? SPECIAL_READY_INK_THRESHOLD : MINIMAP_WEAPON_INK_THRESHOLD, - }); - crop.delete(); - weapon = resolveTieBySubTile(rgb, weapon, layout.subTile); - confidences.push(Math.max(0, weapon.score)); - } - abilities = matchBadges( - rgb, - layout.badges, - lightSurface - ? Math.max(MINIMAP_ABILITY_INK_THRESHOLD, Math.round(cornerMin) + 50) - : MINIMAP_ABILITY_INK_THRESHOLD, - confidences, - badgeDebug, - ); - } - cardDebug.push({ - slot: layout.slot, - presence, - crossFraction, - occluded, - cornerMin, - lightSurface, - nameRaw, - weapon, - badges: badgeDebug, - }); + let name: string | null = null; + let nameRaw = ""; + let weapon: WeaponMatch | null = null; + const badgeDebug: (WeaponMatch | null)[] = []; + let abilities: (CvAbility | null)[] = []; + if (!occluded) { + if (nameGlyphs) { + const band = copyRoi(gray, layout.name); + const parsed = parseName(band, nameGlyphs, { + binThreshold: NAME_BIN_THRESHOLD, + }); + band.delete(); + nameRaw = parsed.raw.text; + if (parsed.name.length > 0) name = parsed.name; + confidences.push(parsed.confidence); + } + const templates = lightSurface ? lightWeapons : cardWeapons; + if (templates) { + const crop = cropRoi(rgb, layout.weapon); + weapon = matchWeapon(crop, templates, { + inkThreshold: lightSurface + ? SPECIAL_READY_INK_THRESHOLD + : MINIMAP_WEAPON_INK_THRESHOLD, + }); + crop.delete(); + weapon = resolveTieBySubTile(rgb, weapon, layout.subTile); + confidences.push(Math.max(0, weapon.score)); + } + abilities = matchBadges( + rgb, + layout.badges, + lightSurface + ? Math.max( + MINIMAP_ABILITY_INK_THRESHOLD, + Math.round(cornerMin) + 50, + ) + : MINIMAP_ABILITY_INK_THRESHOLD, + confidences, + badgeDebug, + ); + } + cardDebug.push({ + slot: layout.slot, + presence, + crossFraction, + occluded, + cornerMin, + lightSurface, + nameRaw, + weapon, + badges: badgeDebug, + }); - const floor = lightSurface ? SPECIAL_READY_WEAPON_MIN_SCORE : WEAPON_MIN_SCORE; - const matched = weapon !== null && weapon.score >= floor ? weapon : null; - // an occluding cross-out is itself proof the card is drawn - const hasEvidence = - occluded || name !== null || matched !== null || abilities.some((a) => a !== null); - if (!hasEvidence) continue; - teammates.push({ - slot: layout.slot, - name, - weaponId: matched ? toMainWeaponId(matched.id) : null, - abilities, - }); - } - debug.cards = cardDebug; + const floor = lightSurface + ? SPECIAL_READY_WEAPON_MIN_SCORE + : WEAPON_MIN_SCORE; + const matched = weapon !== null && weapon.score >= floor ? weapon : null; + // an occluding cross-out is itself proof the card is drawn + const hasEvidence = + occluded || + name !== null || + matched !== null || + abilities.some((a) => a !== null); + if (!hasEvidence) continue; + teammates.push({ + slot: layout.slot, + name, + weaponId: matched ? toMainWeaponId(matched.id) : null, + abilities, + }); + } + debug.cards = cardDebug; - // 2. enemy panel rows - const enemies: MinimapEnemy[] = []; - const enemyDebug: Record[] = []; - for (const cy of ENEMY_ROW_CYS) { - const weaponRoi = enemyWeaponRoi(cy); - const presence = meanBrightness(lap, weaponRoi); - if (presence < PRESENCE_MIN_LAPLACIAN) { - enemyDebug.push({ cy, presence, skipped: true }); - continue; - } - const crossFraction = saturatedFraction(hsv, enemyCrossRoi(cy)); - const occluded = crossFraction >= CROSS_MIN_FRACTION; + // 2. enemy panel rows + const enemies: MinimapEnemy[] = []; + const enemyDebug: Record[] = []; + for (const cy of ENEMY_ROW_CYS) { + const weaponRoi = enemyWeaponRoi(cy); + const presence = meanBrightness(lap, weaponRoi); + if (presence < PRESENCE_MIN_LAPLACIAN) { + enemyDebug.push({ cy, presence, skipped: true }); + continue; + } + const crossFraction = saturatedFraction(hsv, enemyCrossRoi(cy)); + const occluded = crossFraction >= CROSS_MIN_FRACTION; - // light camo rows: pick the template variant by the weapon box's - // corner brightness and raise the ink threshold past that background - const cornerMin = minTopCornerMean(gray, weaponRoi); - const lightSurface = cornerMin >= SPECIAL_READY_MIN_CORNER_MEAN; - const templates = lightSurface ? lightWeapons : cardWeapons; - const inkThreshold = lightSurface - ? SPECIAL_READY_INK_THRESHOLD - : Math.max(MINIMAP_WEAPON_INK_THRESHOLD, Math.round(cornerMin) + 50); + // light camo rows: pick the template variant by the weapon box's + // corner brightness and raise the ink threshold past that background + const cornerMin = minTopCornerMean(gray, weaponRoi); + const lightSurface = cornerMin >= SPECIAL_READY_MIN_CORNER_MEAN; + const templates = lightSurface ? lightWeapons : cardWeapons; + const inkThreshold = lightSurface + ? SPECIAL_READY_INK_THRESHOLD + : Math.max(MINIMAP_WEAPON_INK_THRESHOLD, Math.round(cornerMin) + 50); - let weapon: WeaponMatch | null = null; - if (templates) { - const crop = cropRoi(rgb, weaponRoi); - weapon = matchWeapon(crop, templates, { inkThreshold }); - crop.delete(); - weapon = resolveTieBySubTile(rgb, weapon, enemySubTileRoi(cy)); - confidences.push(Math.max(0, weapon.score)); - } - const badgeDebug: (WeaponMatch | null)[] = []; - const abilities: (CvAbility | null)[] = occluded - ? [] - : matchBadges( - rgb, - ENEMY_BADGE_XS.map((cx) => [cx, cy] as const), - Math.max(MINIMAP_ABILITY_INK_THRESHOLD, Math.round(cornerMin) + 50), - confidences, - badgeDebug, - ); - enemyDebug.push({ - cy, - presence, - crossFraction, - occluded, - lightSurface, - cornerMin, - weapon, - badges: badgeDebug, - }); + let weapon: WeaponMatch | null = null; + if (templates) { + const crop = cropRoi(rgb, weaponRoi); + weapon = matchWeapon(crop, templates, { inkThreshold }); + crop.delete(); + weapon = resolveTieBySubTile(rgb, weapon, enemySubTileRoi(cy)); + confidences.push(Math.max(0, weapon.score)); + } + const badgeDebug: (WeaponMatch | null)[] = []; + const abilities: (CvAbility | null)[] = occluded + ? [] + : matchBadges( + rgb, + ENEMY_BADGE_XS.map((cx) => [cx, cy] as const), + Math.max(MINIMAP_ABILITY_INK_THRESHOLD, Math.round(cornerMin) + 50), + confidences, + badgeDebug, + ); + enemyDebug.push({ + cy, + presence, + crossFraction, + occluded, + lightSurface, + cornerMin, + weapon, + badges: badgeDebug, + }); - const floor = lightSurface ? SPECIAL_READY_WEAPON_MIN_SCORE : WEAPON_MIN_SCORE; - const matched = weapon !== null && weapon.score >= floor ? weapon : null; - enemies.push({ - name: null, - weaponId: matched ? toMainWeaponId(matched.id) : null, - abilities, - }); - } - debug.enemies = enemyDebug; + const floor = lightSurface + ? SPECIAL_READY_WEAPON_MIN_SCORE + : WEAPON_MIN_SCORE; + const matched = weapon !== null && weapon.score >= floor ? weapon : null; + enemies.push({ + name: null, + weaponId: matched ? toMainWeaponId(matched.id) : null, + abilities, + }); + } + debug.enemies = enemyDebug; - const stageMatch = detectStage(frame, confidences); - debug.stage = stageMatch; + const stageMatch = detectStage(frame, confidences); + debug.stage = stageMatch; - gray.delete(); - rgb.delete(); - hsv.delete(); - lap.delete(); + gray.delete(); + rgb.delete(); + hsv.delete(); + lap.delete(); - const confidence = - confidences.length > 0 ? confidences.reduce((a, b) => a + b, 0) / confidences.length : 0; + const confidence = + confidences.length > 0 + ? confidences.reduce((a, b) => a + b, 0) / confidences.length + : 0; - return [ - { - type: MINIMAP_EVENT_TYPE, - t, - confidence, - data: { - stage: stageMatch?.stageId ?? null, - spectator: false, - teammates, - enemies, - }, - debug, - }, - ]; - } + return [ + { + type: MINIMAP_EVENT_TYPE, + t, + confidence, + data: { + stage: stageMatch?.stageId ?? null, + spectator: false, + teammates, + enemies, + }, + debug, + }, + ]; + } - return { id: "minimap", gate, parse }; + return { id: "minimap", gate, parse }; } diff --git a/app/features/cv/core/detectors/minimap/rois.ts b/app/features/cv/core/detectors/minimap/rois.ts index 782368567..54eef557e 100644 --- a/app/features/cv/core/detectors/minimap/rois.ts +++ b/app/features/cv/core/detectors/minimap/rois.ts @@ -32,20 +32,20 @@ import type { Roi } from "../../canonical"; export type CardSlot = "up" | "left" | "right" | "self" | "down"; export interface CardLayout { - slot: CardSlot; - /** name text band (BlitzMain caps ~29px plus outline/descender margin) */ - name: Roi; - /** main-weapon silhouette box (icons render ~31-48px tall) */ - weapon: Roi; - /** sub-weapon tile: saturated team-color art, the ink-color anchor */ - subTile: Roi; - /** the three main-ability badge centers, 48px pitch */ - badges: readonly (readonly [number, number])[]; - /** - * cross-out probe at the card center (where the X's arms meet): white - * name glyphs and the dark pill are unsaturated, the X core is not - */ - cross: Roi; + slot: CardSlot; + /** name text band (BlitzMain caps ~29px plus outline/descender margin) */ + name: Roi; + /** main-weapon silhouette box (icons render ~31-48px tall) */ + weapon: Roi; + /** sub-weapon tile: saturated team-color art, the ink-color anchor */ + subTile: Roi; + /** the three main-ability badge centers, 48px pitch */ + badges: readonly (readonly [number, number])[]; + /** + * cross-out probe at the card center (where the X's arms meet): white + * name glyphs and the dark pill are unsaturated, the X core is not + */ + cross: Roi; } /** @@ -55,64 +55,64 @@ export interface CardLayout { * left inset for the name, no d-pad. */ export const CARD_LAYOUTS: readonly CardLayout[] = [ - { - slot: "up", - name: { x: 872, y: 46, w: 300, h: 44 }, - weapon: { x: 860, y: 83, w: 84, h: 54 }, - subTile: { x: 932, y: 98, w: 38, h: 41 }, - badges: [ - [1066, 114], - [1114, 114], - [1162, 114], - ], - cross: { x: 925, y: 78, w: 60, h: 32 }, - }, - { - slot: "left", - name: { x: 198, y: 492, w: 300, h: 44 }, - weapon: { x: 193, y: 529, w: 84, h: 54 }, - subTile: { x: 265, y: 544, w: 38, h: 41 }, - badges: [ - [392, 564], - [440, 564], - [488, 564], - ], - cross: { x: 258, y: 524, w: 60, h: 32 }, - }, - { - slot: "right", - name: { x: 1550, y: 492, w: 300, h: 44 }, - weapon: { x: 1545, y: 529, w: 84, h: 54 }, - subTile: { x: 1617, y: 544, w: 38, h: 41 }, - badges: [ - [1744, 564], - [1792, 564], - [1840, 564], - ], - cross: { x: 1610, y: 524, w: 60, h: 32 }, - }, - { - slot: "self", - name: { x: 126, y: 942, w: 300, h: 46 }, - weapon: { x: 118, y: 985, w: 94, h: 55 }, - subTile: { x: 193, y: 995, w: 38, h: 36 }, - badges: [ - [320, 1014], - [368, 1014], - [416, 1014], - ], - cross: { x: 255, y: 968, w: 60, h: 32 }, - }, + { + slot: "up", + name: { x: 872, y: 46, w: 300, h: 44 }, + weapon: { x: 860, y: 83, w: 84, h: 54 }, + subTile: { x: 932, y: 98, w: 38, h: 41 }, + badges: [ + [1066, 114], + [1114, 114], + [1162, 114], + ], + cross: { x: 925, y: 78, w: 60, h: 32 }, + }, + { + slot: "left", + name: { x: 198, y: 492, w: 300, h: 44 }, + weapon: { x: 193, y: 529, w: 84, h: 54 }, + subTile: { x: 265, y: 544, w: 38, h: 41 }, + badges: [ + [392, 564], + [440, 564], + [488, 564], + ], + cross: { x: 258, y: 524, w: 60, h: 32 }, + }, + { + slot: "right", + name: { x: 1550, y: 492, w: 300, h: 44 }, + weapon: { x: 1545, y: 529, w: 84, h: 54 }, + subTile: { x: 1617, y: 544, w: 38, h: 41 }, + badges: [ + [1744, 564], + [1792, 564], + [1840, 564], + ], + cross: { x: 1610, y: 524, w: 60, h: 32 }, + }, + { + slot: "self", + name: { x: 126, y: 942, w: 300, h: 46 }, + weapon: { x: 118, y: 985, w: 94, h: 55 }, + subTile: { x: 193, y: 995, w: 38, h: 36 }, + badges: [ + [320, 1014], + [368, 1014], + [416, 1014], + ], + cross: { x: 255, y: 968, w: 60, h: 32 }, + }, ]; /** Enemy panel row centers (65px pitch) and per-row element boxes. */ export const ENEMY_ROW_CYS = [82, 147, 213, 278] as const; export function enemyWeaponRoi(cy: number): Roi { - return { x: 1541, y: cy - 26, w: 58, h: 52 }; + return { x: 1541, y: cy - 26, w: 58, h: 52 }; } export function enemySubTileRoi(cy: number): Roi { - return { x: 1602, y: cy - 20, w: 39, h: 40 }; + return { x: 1602, y: cy - 20, w: 39, h: 40 }; } export const ENEMY_BADGE_XS = [1730, 1778, 1826] as const; /** @@ -120,12 +120,12 @@ export const ENEMY_BADGE_XS = [1730, 1778, 1826] as const; * first badge; on a clean row the gap stays unsaturated. */ export function enemyCrossRoi(cy: number): Roi { - return { x: 1685, y: cy - 12, w: 28, h: 24 }; + return { x: 1685, y: cy - 12, w: 28, h: 24 }; } /** Badge search box (badges ⌀~44; the box height keeps larger sets out). */ export function badgeRoi(cx: number, cy: number): Roi { - return { x: cx - 26, y: cy - 26, w: 52, h: 52 }; + return { x: cx - 26, y: cy - 26, w: 52, h: 52 }; } export const BADGE_TEMPLATE_SIZES = [38, 42, 46] as const; /** Badge art fills the circle like the death panel's mains. */ @@ -172,7 +172,9 @@ export const SUB_TILE_TEMPLATE_SIZES = [24, 27, 30, 33, 36] as const; export const CARD_WEAPON_BACKGROUND = 40; export const SPECIAL_READY_BACKGROUND = 150; -export const MINIMAP_WEAPON_TEMPLATE_SIZES = [40, 44, 48, 52, 56, 60, 64] as const; +export const MINIMAP_WEAPON_TEMPLATE_SIZES = [ + 40, 44, 48, 52, 56, 60, 64, +] as const; export const MINIMAP_WEAPON_INK_THRESHOLD = CARD_WEAPON_BACKGROUND + 50; export const SPECIAL_READY_INK_THRESHOLD = SPECIAL_READY_BACKGROUND + 50; /** @@ -201,16 +203,16 @@ export const PRESENCE_MIN_LAPLACIAN = 8; /** Gate probes (overlay variant): close-button disc + Spawn Point pill shapes. */ export const GATE_CLOSE_BRIGHT: Roi = { x: 90, y: 90, w: 16, h: 16 }; export const GATE_CLOSE_DARK_PROBES: readonly Roi[] = [ - { x: 88, y: 50, w: 20, h: 14 }, - { x: 88, y: 132, w: 20, h: 14 }, - { x: 58, y: 88, w: 14, h: 20 }, - { x: 132, y: 88, w: 14, h: 20 }, + { x: 88, y: 50, w: 20, h: 14 }, + { x: 88, y: 132, w: 20, h: 14 }, + { x: 58, y: 88, w: 14, h: 20 }, + { x: 132, y: 88, w: 14, h: 20 }, ]; /** The white jump-arrows icon left of the (localized) pill label. */ export const GATE_SPAWN_BRIGHT: Roi = { x: 888, y: 963, w: 60, h: 60 }; export const GATE_SPAWN_DARK_PROBES: readonly Roi[] = [ - { x: 812, y: 958, w: 26, h: 14 }, - { x: 950, y: 1024, w: 60, h: 12 }, + { x: 812, y: 958, w: 26, h: 14 }, + { x: 950, y: 1024, w: 60, h: 12 }, ]; export const GATE_DARK_MAX_MEAN = 85; export const GATE_BRIGHT_MIN_MAX = 210; @@ -231,17 +233,17 @@ export const GATE_BRIGHT_MIN_MAX = 210; * bright>=249 / dark<=65 against the shared 210/85 thresholds. */ export const GATE_SPECTATOR_X_BRIGHT: readonly Roi[] = [ - { x: 1418, y: 706, w: 12, h: 12 }, - { x: 1411, y: 692, w: 12, h: 12 }, - { x: 1425, y: 692, w: 12, h: 12 }, - { x: 1411, y: 720, w: 12, h: 12 }, - { x: 1425, y: 720, w: 12, h: 12 }, + { x: 1418, y: 706, w: 12, h: 12 }, + { x: 1411, y: 692, w: 12, h: 12 }, + { x: 1425, y: 692, w: 12, h: 12 }, + { x: 1411, y: 720, w: 12, h: 12 }, + { x: 1425, y: 720, w: 12, h: 12 }, ]; export const GATE_SPECTATOR_X_DARK: readonly Roi[] = [ - { x: 1399, y: 708, w: 8, h: 8 }, - { x: 1441, y: 708, w: 8, h: 8 }, - { x: 1420, y: 682, w: 8, h: 8 }, - { x: 1420, y: 734, w: 8, h: 8 }, + { x: 1399, y: 708, w: 8, h: 8 }, + { x: 1441, y: 708, w: 8, h: 8 }, + { x: 1420, y: 682, w: 8, h: 8 }, + { x: 1420, y: 734, w: 8, h: 8 }, ]; /** @@ -259,23 +261,31 @@ export const GATE_SPECTATOR_X_DARK: readonly Roi[] = [ * struck-through or special-ready spectator fixture is attested yet, so * those probes reuse the overlay thresholds untuned. */ -export const SPECTATOR_SLOTS: readonly CardSlot[] = ["up", "right", "down", "left"]; +export const SPECTATOR_SLOTS: readonly CardSlot[] = [ + "up", + "right", + "down", + "left", +]; export const SPECTATOR_ENEMY_DX = 1348; const SPECTATOR_ROW_PITCH = 120; -export function spectatorCardLayout(row: number, dx: number): Omit { - const dy = SPECTATOR_ROW_PITCH * row; - return { - name: { x: 198 + dx, y: 306 + dy, w: 310, h: 44 }, - weapon: { x: 196 + dx, y: 350 + dy, w: 66, h: 54 }, - subTile: { x: 264 + dx, y: 354 + dy, w: 38, h: 42 }, - badges: [ - [390 + dx, 374 + dy], - [438 + dx, 374 + dy], - [486 + dx, 374 + dy], - ], - cross: { x: 344 + dx, y: 362 + dy, w: 20, h: 24 }, - }; +export function spectatorCardLayout( + row: number, + dx: number, +): Omit { + const dy = SPECTATOR_ROW_PITCH * row; + return { + name: { x: 198 + dx, y: 306 + dy, w: 310, h: 44 }, + weapon: { x: 196 + dx, y: 350 + dy, w: 66, h: 54 }, + subTile: { x: 264 + dx, y: 354 + dy, w: 38, h: 42 }, + badges: [ + [390 + dx, 374 + dy], + [438 + dx, 374 + dy], + [486 + dx, 374 + dy], + ], + cross: { x: 344 + dx, y: 362 + dy, w: 20, h: 24 }, + }; } /** diff --git a/app/features/cv/core/detectors/minimap/stage.ts b/app/features/cv/core/detectors/minimap/stage.ts index b3612c661..95c60fa76 100644 --- a/app/features/cv/core/detectors/minimap/stage.ts +++ b/app/features/cv/core/detectors/minimap/stage.ts @@ -38,20 +38,20 @@ const MIN_SCORE = 0.5; const MIN_MARGIN = 0.05; export interface PlannerStage { - /** "-", e.g. "6-SZ" */ - key: string; - /** sendou stage id */ - stageId: StageId; - /** unit-L2-normalized structural signature, row-major PLANNER_SIG_W x _H */ - sig: Float32Array; + /** "-", e.g. "6-SZ" */ + key: string; + /** sendou stage id */ + stageId: StageId; + /** unit-L2-normalized structural signature, row-major PLANNER_SIG_W x _H */ + sig: Float32Array; } export interface StageMatch { - stageId: StageId; - /** best NCC of the winning stage */ - score: number; - /** lead over the best-scoring other stage */ - margin: number; + stageId: StageId; + /** best NCC of the winning stage */ + score: number; + /** lead over the best-scoring other stage */ + margin: number; } /** @@ -61,81 +61,93 @@ export interface StageMatch { * the live frame are computed identically. */ export function plannerSignature(frame: Mat): Float32Array { - const cv = getCV(); - const gray = new cv.Mat(); - cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); - const rgb = new cv.Mat(); - cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB); - const hsv = new cv.Mat(); - cv.cvtColor(rgb, hsv, cv.COLOR_RGB2HSV); - rgb.delete(); + const cv = getCV(); + const gray = new cv.Mat(); + cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); + const rgb = new cv.Mat(); + cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB); + const hsv = new cv.Mat(); + cv.cvtColor(rgb, hsv, cv.COLOR_RGB2HSV); + rgb.delete(); - const lap = new cv.Mat(); - cv.Laplacian(gray, lap, cv.CV_16S, 3); - gray.delete(); - const edges = new cv.Mat(); - cv.convertScaleAbs(lap, edges); - lap.delete(); - const mask = new cv.Mat(); - cv.threshold(edges, mask, EDGE_MIN, 255, cv.THRESH_BINARY); - edges.delete(); + const lap = new cv.Mat(); + cv.Laplacian(gray, lap, cv.CV_16S, 3); + gray.delete(); + const edges = new cv.Mat(); + cv.convertScaleAbs(lap, edges); + lap.delete(); + const mask = new cv.Mat(); + cv.threshold(edges, mask, EDGE_MIN, 255, cv.THRESH_BINARY); + edges.delete(); - // drop saturated (ink) edges, keeping only the structural skeleton - const n = mask.rows * mask.cols; - const md = mask.data; - const hd = hsv.data; - for (let i = 0; i < n; i++) { - if (hd[i * 3 + 1]! >= INK_SATURATION_MIN) md[i] = 0; - } - hsv.delete(); + // drop saturated (ink) edges, keeping only the structural skeleton + const n = mask.rows * mask.cols; + const md = mask.data; + const hd = hsv.data; + for (let i = 0; i < n; i++) { + if (hd[i * 3 + 1]! >= INK_SATURATION_MIN) md[i] = 0; + } + hsv.delete(); - const down = new cv.Mat(); - cv.resize(mask, down, new cv.Size(PLANNER_SIG_W, PLANNER_SIG_H), 0, 0, cv.INTER_AREA); - mask.delete(); - const blur = new cv.Mat(); - cv.GaussianBlur(down, blur, new cv.Size(5, 5), 0); - down.delete(); + const down = new cv.Mat(); + cv.resize( + mask, + down, + new cv.Size(PLANNER_SIG_W, PLANNER_SIG_H), + 0, + 0, + cv.INTER_AREA, + ); + mask.delete(); + const blur = new cv.Mat(); + cv.GaussianBlur(down, blur, new cv.Size(5, 5), 0); + down.delete(); - const out = new Float32Array(PLANNER_SIG_W * PLANNER_SIG_H); - const bd = blur.data; - let sumSq = 0; - for (let i = 0; i < out.length; i++) { - out[i] = bd[i]!; - sumSq += out[i]! * out[i]!; - } - blur.delete(); - const norm = Math.sqrt(sumSq) || 1; - for (let i = 0; i < out.length; i++) out[i]! /= norm; - return out; + const out = new Float32Array(PLANNER_SIG_W * PLANNER_SIG_H); + const bd = blur.data; + let sumSq = 0; + for (let i = 0; i < out.length; i++) { + out[i] = bd[i]!; + sumSq += out[i]! * out[i]!; + } + blur.delete(); + const norm = Math.sqrt(sumSq) || 1; + for (let i = 0; i < out.length; i++) out[i]! /= norm; + return out; } /** Dot product of `a` against `b` shifted by (dx, dy) over their overlap. */ -function shiftedDot(a: Float32Array, b: Float32Array, dx: number, dy: number): number { - let dot = 0; - for (let y = 0; y < PLANNER_SIG_H; y++) { - const sy = y + dy; - if (sy < 0 || sy >= PLANNER_SIG_H) continue; - const ar = y * PLANNER_SIG_W; - const br = sy * PLANNER_SIG_W; - for (let x = 0; x < PLANNER_SIG_W; x++) { - const sx = x + dx; - if (sx < 0 || sx >= PLANNER_SIG_W) continue; - dot += a[ar + x]! * b[br + sx]!; - } - } - return dot; +function shiftedDot( + a: Float32Array, + b: Float32Array, + dx: number, + dy: number, +): number { + let dot = 0; + for (let y = 0; y < PLANNER_SIG_H; y++) { + const sy = y + dy; + if (sy < 0 || sy >= PLANNER_SIG_H) continue; + const ar = y * PLANNER_SIG_W; + const br = sy * PLANNER_SIG_W; + for (let x = 0; x < PLANNER_SIG_W; x++) { + const sx = x + dx; + if (sx < 0 || sx >= PLANNER_SIG_W) continue; + dot += a[ar + x]! * b[br + sx]!; + } + } + return dot; } /** Best NCC of two unit signatures over a small translation search. */ function bestNcc(a: Float32Array, b: Float32Array): number { - let best = -1; - for (let dy = -MATCH_RANGE; dy <= MATCH_RANGE; dy += 2) { - for (let dx = -MATCH_RANGE; dx <= MATCH_RANGE; dx += 2) { - const v = shiftedDot(a, b, dx, dy); - if (v > best) best = v; - } - } - return best; + let best = -1; + for (let dy = -MATCH_RANGE; dy <= MATCH_RANGE; dy += 2) { + for (let dx = -MATCH_RANGE; dx <= MATCH_RANGE; dx += 2) { + const v = shiftedDot(a, b, dx, dy); + if (v > best) best = v; + } + } + return best; } /** @@ -144,44 +156,44 @@ function bestNcc(a: Float32Array, b: Float32Array): number { * stages are too close to call (margin floor) — e.g. a stage not in the set. */ export function matchStage( - sig: Float32Array, - planners: readonly PlannerStage[], + sig: Float32Array, + planners: readonly PlannerStage[], ): StageMatch | null { - if (planners.length === 0) return null; - const byStage = new Map(); - for (const p of planners) { - const score = bestNcc(sig, p.sig); - const prev = byStage.get(p.stageId); - if (prev === undefined || score > prev) byStage.set(p.stageId, score); - } - let bestId: StageId | null = null; - let best = -1; - let second = -1; - for (const [id, score] of byStage) { - if (score > best) { - second = best; - best = score; - bestId = id; - } else if (score > second) { - second = score; - } - } - const margin = second < 0 ? best : best - second; - if (bestId === null || best < MIN_SCORE || margin < MIN_MARGIN) return null; - return { - stageId: bestId, - score: Math.round(best * 1000) / 1000, - margin: Math.round(margin * 1000) / 1000, - }; + if (planners.length === 0) return null; + const byStage = new Map(); + for (const p of planners) { + const score = bestNcc(sig, p.sig); + const prev = byStage.get(p.stageId); + if (prev === undefined || score > prev) byStage.set(p.stageId, score); + } + let bestId: StageId | null = null; + let best = -1; + let second = -1; + for (const [id, score] of byStage) { + if (score > best) { + second = best; + best = score; + bestId = id; + } else if (score > second) { + second = score; + } + } + const margin = second < 0 ? best : best - second; + if (bestId === null || best < MIN_SCORE || margin < MIN_MARGIN) return null; + return { + stageId: bestId, + score: Math.round(best * 1000) / 1000, + margin: Math.round(margin * 1000) / 1000, + }; } export interface PlannerManifest { - width: number; - height: number; - /** tiles packed left-to-right, top-to-bottom, this many per row */ - cols: number; - /** tile keys in packing order */ - keys: string[]; + width: number; + height: number; + /** tiles packed left-to-right, top-to-bottom, this many per row */ + cols: number; + /** tile keys in packing order */ + keys: string[]; } /** @@ -189,26 +201,29 @@ export interface PlannerManifest { * unit-normalized PlannerStage signatures. Mirrors loadGlyphSet's atlas * convention; the build tool writes the atlas + manifest. */ -export function loadPlannerStages(atlas: FrameData, manifest: PlannerManifest): PlannerStage[] { - const { width, height, cols, keys } = manifest; - const aw = atlas.width; - const data = atlas.data; - return keys.map((key, i) => { - const tx = (i % cols) * width; - const ty = Math.floor(i / cols) * height; - const sig = new Float32Array(width * height); - let sumSq = 0; - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - // atlas is RGBA; the tiles are grayscale, so read the red channel - const v = data[((ty + y) * aw + (tx + x)) * 4]!; - sig[y * width + x] = v; - sumSq += v * v; - } - } - const norm = Math.sqrt(sumSq) || 1; - for (let j = 0; j < sig.length; j++) sig[j]! /= norm; - const stageId = Number(key.split("-")[0]) as StageId; - return { key, stageId, sig }; - }); +export function loadPlannerStages( + atlas: FrameData, + manifest: PlannerManifest, +): PlannerStage[] { + const { width, height, cols, keys } = manifest; + const aw = atlas.width; + const data = atlas.data; + return keys.map((key, i) => { + const tx = (i % cols) * width; + const ty = Math.floor(i / cols) * height; + const sig = new Float32Array(width * height); + let sumSq = 0; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + // atlas is RGBA; the tiles are grayscale, so read the red channel + const v = data[((ty + y) * aw + (tx + x)) * 4]!; + sig[y * width + x] = v; + sumSq += v * v; + } + } + const norm = Math.sqrt(sumSq) || 1; + for (let j = 0; j < sig.length; j++) sig[j]! /= norm; + const stageId = Number(key.split("-")[0]) as StageId; + return { key, stageId, sig }; + }); } diff --git a/app/features/cv/core/detectors/registry.ts b/app/features/cv/core/detectors/registry.ts index 7acf91ca4..cd6d6be97 100644 --- a/app/features/cv/core/detectors/registry.ts +++ b/app/features/cv/core/detectors/registry.ts @@ -8,14 +8,14 @@ import { createDeathDetector } from "./death/index"; import { createMapStartDetector } from "./map-start/index"; import { createMinimapDetector } from "./minimap/index"; import { - createScoreboardDetector, - SCOREBOARD_EVENT_TYPE, - type ScoreboardResources, + createScoreboardDetector, + SCOREBOARD_EVENT_TYPE, + type ScoreboardResources, } from "./scoreboard/index"; import { createScoreboardOwnDetector } from "./scoreboard-own/index"; import { - createScoreboardReplayDetector, - SCOREBOARD_REPLAY_EVENT_TYPE, + createScoreboardReplayDetector, + SCOREBOARD_REPLAY_EVENT_TYPE, } from "./scoreboard-replay/index"; import type { Detector } from "./types"; @@ -24,17 +24,19 @@ import type { Detector } from "./types"; * (ScoreboardData): the results screen and the replay-browser detail. */ export const SCOREBOARD_EVENT_TYPES: readonly string[] = [ - SCOREBOARD_EVENT_TYPE, - SCOREBOARD_REPLAY_EVENT_TYPE, + SCOREBOARD_EVENT_TYPE, + SCOREBOARD_REPLAY_EVENT_TYPE, ]; -export function createAllDetectors(resources: ScoreboardResources): Detector[] { - return [ - createScoreboardDetector(resources) as Detector, - createScoreboardReplayDetector(resources) as Detector, - createScoreboardOwnDetector(resources) as Detector, - createDeathDetector(resources) as Detector, - createMapStartDetector(resources) as Detector, - createMinimapDetector(resources) as Detector, - ]; +export function createAllDetectors( + resources: ScoreboardResources, +): Detector[] { + return [ + createScoreboardDetector(resources) as Detector, + createScoreboardReplayDetector(resources) as Detector, + createScoreboardOwnDetector(resources) as Detector, + createDeathDetector(resources) as Detector, + createMapStartDetector(resources) as Detector, + createMinimapDetector(resources) as Detector, + ]; } diff --git a/app/features/cv/core/detectors/scoreboard-own/abilities.ts b/app/features/cv/core/detectors/scoreboard-own/abilities.ts index a2ead1e27..8de287a3e 100644 --- a/app/features/cv/core/detectors/scoreboard-own/abilities.ts +++ b/app/features/cv/core/detectors/scoreboard-own/abilities.ts @@ -6,27 +6,27 @@ import type { FrameData } from "../../image"; import { type AbilityTemplates, buildAbilityRole } from "../death/abilities"; import { - OWN_ABILITY_ART_RATIO, - OWN_ABILITY_INK_THRESHOLD, - OWN_ABILITY_MAIN_SIZES, - OWN_ABILITY_SUB_SIZES, + OWN_ABILITY_ART_RATIO, + OWN_ABILITY_INK_THRESHOLD, + OWN_ABILITY_MAIN_SIZES, + OWN_ABILITY_SUB_SIZES, } from "./rois"; export function prepareOwnAbilityTemplates( - icons: { id: string; image: FrameData }[], + icons: { id: string; image: FrameData }[], ): AbilityTemplates { - return { - mains: buildAbilityRole( - icons, - OWN_ABILITY_MAIN_SIZES, - OWN_ABILITY_ART_RATIO, - OWN_ABILITY_INK_THRESHOLD, - ), - subs: buildAbilityRole( - icons, - OWN_ABILITY_SUB_SIZES, - OWN_ABILITY_ART_RATIO, - OWN_ABILITY_INK_THRESHOLD, - ), - }; + return { + mains: buildAbilityRole( + icons, + OWN_ABILITY_MAIN_SIZES, + OWN_ABILITY_ART_RATIO, + OWN_ABILITY_INK_THRESHOLD, + ), + subs: buildAbilityRole( + icons, + OWN_ABILITY_SUB_SIZES, + OWN_ABILITY_ART_RATIO, + OWN_ABILITY_INK_THRESHOLD, + ), + }; } diff --git a/app/features/cv/core/detectors/scoreboard-own/index.ts b/app/features/cv/core/detectors/scoreboard-own/index.ts index 4cfbed9ec..c6d20979d 100644 --- a/app/features/cv/core/detectors/scoreboard-own/index.ts +++ b/app/features/cv/core/detectors/scoreboard-own/index.ts @@ -10,47 +10,57 @@ * screen's title size, then snapped against every language's main-weapon * names at once and reported under its canonical English name. */ + +import type { + MainWeaponId, + ModeShort, + StageId, +} from "~/modules/in-game-lists/types"; +import { + type CvAbility, + type CvLobby, + toCvAbility, + toMainWeaponId, +} from "../../../cv-types"; import { getCV, type Mat } from "../../cv"; import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs"; import { copyRoi, cropRoi, maxBrightness, meanBrightness } from "../../image"; import { closestBy, matchKey } from "../../text"; import { LOCALIZED_WEAPON_NAMES } from "../death/localized-messages"; -import type { MainWeaponId, ModeShort, StageId } from "~/modules/in-game-lists/types"; -import { type CvAbility, type CvLobby, toCvAbility, toMainWeaponId } from "../../../cv-types"; import { ALL_WEAPON_ENTRIES, type WeaponEntry } from "../death/weapon-names"; import { type ParsedHeader, parseHeader } from "../scoreboard/header"; import type { ScoreboardResources } from "../scoreboard/index"; import { matchWeapon, type WeaponMatch } from "../scoreboard/weapons"; import type { DetectedEvent, Detector, GateResult } from "../types"; import { - GATE_PANEL_MAX_MEAN, - GATE_PANEL_PROBES, - GATE_STRIP_MAX_MEAN, - GATE_STRIP_MIN_MEAN, - GATE_TEXT_MIN_MAX, - GATE_TITLE_TEXT_PROBES, - GEAR_ROWS, - gateStripProbe, - gearMainRoi, - gearSubRoi, - OWN_ABILITY_INK_THRESHOLD, - WEAPON_TITLE_BAND, - WEAPON_TITLE_BIN_THRESHOLD, - WEAPON_TITLE_TEXT_HEIGHT, + GATE_PANEL_MAX_MEAN, + GATE_PANEL_PROBES, + GATE_STRIP_MAX_MEAN, + GATE_STRIP_MIN_MEAN, + GATE_TEXT_MIN_MAX, + GATE_TITLE_TEXT_PROBES, + GEAR_ROWS, + gateStripProbe, + gearMainRoi, + gearSubRoi, + OWN_ABILITY_INK_THRESHOLD, + WEAPON_TITLE_BAND, + WEAPON_TITLE_BIN_THRESHOLD, + WEAPON_TITLE_TEXT_HEIGHT, } from "./rois"; export interface ScoreboardOwnData { - /** from the header tag; null when unreadable */ - lobby: CvLobby | null; - mode: ModeShort | null; - stage: StageId | null; - /** the player's main weapon; null if unreadable */ - weaponId: MainWeaponId | null; - /** - * own gear abilities, [head, clothes, shoes] rows of - * [main, sub, sub, sub] ability ids - */ - abilities: CvAbility[][]; + /** from the header tag; null when unreadable */ + lobby: CvLobby | null; + mode: ModeShort | null; + stage: StageId | null; + /** the player's main weapon; null if unreadable */ + weaponId: MainWeaponId | null; + /** + * own gear abilities, [head, clothes, shoes] rows of + * [main, sub, sub, sub] ability ids + */ + abilities: CvAbility[][]; } export const SCOREBOARD_OWN_EVENT_TYPE = "ScoreboardOwn"; @@ -59,8 +69,8 @@ export const SCOREBOARD_OWN_EVENT_TYPE = "ScoreboardOwn"; const WEAPON_MIN_SCORE = 0.55; interface WeaponCandidate { - text: string; - entry: WeaponEntry; + text: string; + entry: WeaponEntry; } /** @@ -70,175 +80,181 @@ interface WeaponCandidate { */ let weaponCandidates: WeaponCandidate[] | null = null; function mainWeaponCandidates(): WeaponCandidate[] { - if (weaponCandidates) return weaponCandidates; - const mains = ALL_WEAPON_ENTRIES.filter((e) => e.type === "MAIN"); - const byName = new Map(mains.map((e) => [e.name, e])); - const seen = new Set(); - weaponCandidates = []; - const push = (text: string, entry: WeaponEntry | undefined) => { - const k = matchKey(text); - if (!entry || seen.has(k)) return; - seen.add(k); - weaponCandidates!.push({ text, entry }); - }; - for (const entry of mains) push(entry.name, entry); - for (const names of Object.values(LOCALIZED_WEAPON_NAMES)) { - for (const { text, name } of names) push(text, byName.get(name)); - } - return weaponCandidates; + if (weaponCandidates) return weaponCandidates; + const mains = ALL_WEAPON_ENTRIES.filter((e) => e.type === "MAIN"); + const byName = new Map(mains.map((e) => [e.name, e])); + const seen = new Set(); + weaponCandidates = []; + const push = (text: string, entry: WeaponEntry | undefined) => { + const k = matchKey(text); + if (!entry || seen.has(k)) return; + seen.add(k); + weaponCandidates!.push({ text, entry }); + }; + for (const entry of mains) push(entry.name, entry); + for (const names of Object.values(LOCALIZED_WEAPON_NAMES)) { + for (const { text, name } of names) push(text, byName.get(name)); + } + return weaponCandidates; } export function createScoreboardOwnDetector( - resources: ScoreboardResources, + resources: ScoreboardResources, ): Detector { - const cv = getCV(); + const cv = getCV(); - const titleGlyphs: GlyphSet | null = resources.deathWeaponGlyphs - ? scaleGlyphSet( - resources.deathWeaponGlyphs, - WEAPON_TITLE_TEXT_HEIGHT / resources.deathWeaponGlyphs.height, - ) - : null; - const abilities = resources.ownAbilities ?? null; + const titleGlyphs: GlyphSet | null = resources.deathWeaponGlyphs + ? scaleGlyphSet( + resources.deathWeaponGlyphs, + WEAPON_TITLE_TEXT_HEIGHT / resources.deathWeaponGlyphs.height, + ) + : null; + const abilities = resources.ownAbilities ?? null; - function gate(frame: Mat): GateResult { - let panelOk = 0; - for (const roi of GATE_PANEL_PROBES) { - if (meanBrightness(frame, roi) < GATE_PANEL_MAX_MEAN) panelOk++; - } + function gate(frame: Mat): GateResult { + let panelOk = 0; + for (const roi of GATE_PANEL_PROBES) { + if (meanBrightness(frame, roi) < GATE_PANEL_MAX_MEAN) panelOk++; + } - const gray = new cv.Mat(); - cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); - let textOk = 0; - for (const roi of GATE_TITLE_TEXT_PROBES) { - if (maxBrightness(gray, roi) > GATE_TEXT_MIN_MAX) textOk++; - } - gray.delete(); + const gray = new cv.Mat(); + cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); + let textOk = 0; + for (const roi of GATE_TITLE_TEXT_PROBES) { + if (maxBrightness(gray, roi) > GATE_TEXT_MIN_MAX) textOk++; + } + gray.delete(); - let stripOk = 0; - for (let row = 0; row < GEAR_ROWS; row++) { - const mean = meanBrightness(frame, gateStripProbe(row)); - if (mean >= GATE_STRIP_MIN_MEAN && mean <= GATE_STRIP_MAX_MEAN) stripOk++; - } + let stripOk = 0; + for (let row = 0; row < GEAR_ROWS; row++) { + const mean = meanBrightness(frame, gateStripProbe(row)); + if (mean >= GATE_STRIP_MIN_MEAN && mean <= GATE_STRIP_MAX_MEAN) stripOk++; + } - const score = - (panelOk / GATE_PANEL_PROBES.length + - textOk / GATE_TITLE_TEXT_PROBES.length + - stripOk / GEAR_ROWS) / - 3; - const pass = - panelOk === GATE_PANEL_PROBES.length && - textOk === GATE_TITLE_TEXT_PROBES.length && - stripOk === GEAR_ROWS; - return { pass, score }; - } + const score = + (panelOk / GATE_PANEL_PROBES.length + + textOk / GATE_TITLE_TEXT_PROBES.length + + stripOk / GEAR_ROWS) / + 3; + const pass = + panelOk === GATE_PANEL_PROBES.length && + textOk === GATE_TITLE_TEXT_PROBES.length && + stripOk === GEAR_ROWS; + return { pass, score }; + } - function parse(frame: Mat, t: number): DetectedEvent[] { - const gray = new cv.Mat(); - cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); - const rgb = new cv.Mat(); - cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB); + function parse(frame: Mat, t: number): DetectedEvent[] { + const gray = new cv.Mat(); + cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); + const rgb = new cv.Mat(); + cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB); - const confidences: number[] = []; + const confidences: number[] = []; - // header tags sit at the live scoreboard's positions — shared parser - let header: ParsedHeader | null = null; - if (resources.headerLobbyGlyphs && resources.headerLineGlyphs) { - header = parseHeader(gray, resources.headerLobbyGlyphs, resources.headerLineGlyphs); - confidences.push(header.confidence); - } + // header tags sit at the live scoreboard's positions — shared parser + let header: ParsedHeader | null = null; + if (resources.headerLobbyGlyphs && resources.headerLineGlyphs) { + header = parseHeader( + gray, + resources.headerLobbyGlyphs, + resources.headerLineGlyphs, + ); + confidences.push(header.confidence); + } - // Weapon card title, snapped to the main-weapon closed set. The band is - // recognized whole, NOT via readTagBand: the tag is fixed-width (the - // band lies entirely inside it, there is nothing to trim away), and the - // extent trim actively harms it — long names render horizontally - // condensed, whose dense antialiased columns fail the dark-or-bright - // tag-column test and truncate the read mid-name. - let weapon: string | null = null; - let weaponId: MainWeaponId | null = null; - let weaponScore = 0; - let weaponReading = ""; - if (titleGlyphs) { - const band = copyRoi(gray, WEAPON_TITLE_BAND); - weaponReading = recognizeText(band, titleGlyphs, { - binThreshold: WEAPON_TITLE_BIN_THRESHOLD, - spaceGap: 9, - minCharScore: 0.3, - }).text.trim(); - band.delete(); - const match = weaponReading - ? closestBy(weaponReading, mainWeaponCandidates(), (c) => c.text) - : null; - if (match) { - weaponScore = match.score; - if (match.score >= WEAPON_MIN_SCORE) { - weapon = match.entry.entry.name; - weaponId = toMainWeaponId(match.entry.entry.id); - } - } - confidences.push(weaponScore); - } + // Weapon card title, snapped to the main-weapon closed set. The band is + // recognized whole, NOT via readTagBand: the tag is fixed-width (the + // band lies entirely inside it, there is nothing to trim away), and the + // extent trim actively harms it — long names render horizontally + // condensed, whose dense antialiased columns fail the dark-or-bright + // tag-column test and truncate the read mid-name. + let weapon: string | null = null; + let weaponId: MainWeaponId | null = null; + let weaponScore = 0; + let weaponReading = ""; + if (titleGlyphs) { + const band = copyRoi(gray, WEAPON_TITLE_BAND); + weaponReading = recognizeText(band, titleGlyphs, { + binThreshold: WEAPON_TITLE_BIN_THRESHOLD, + spaceGap: 9, + minCharScore: 0.3, + }).text.trim(); + band.delete(); + const match = weaponReading + ? closestBy(weaponReading, mainWeaponCandidates(), (c) => c.text) + : null; + if (match) { + weaponScore = match.score; + if (match.score >= WEAPON_MIN_SCORE) { + weapon = match.entry.entry.name; + weaponId = toMainWeaponId(match.entry.entry.id); + } + } + confidences.push(weaponScore); + } - // gear-card ability strips: [head, clothes, shoes] x [main, sub, sub, sub] - const abilityRows: CvAbility[][] = []; - const abilityDebug: (WeaponMatch | null)[][] = []; - if (abilities) { - for (let row = 0; row < GEAR_ROWS; row++) { - const ids: CvAbility[] = []; - const debug: (WeaponMatch | null)[] = []; - const mainCrop = cropRoi(rgb, gearMainRoi(row)); - const main = matchWeapon(mainCrop, abilities.mains, { - inkThreshold: OWN_ABILITY_INK_THRESHOLD, - }); - mainCrop.delete(); - ids.push(toCvAbility(main.id) ?? "UNKNOWN"); - debug.push(main); - confidences.push(Math.max(0, main.score)); - for (let slot = 0; slot < 3; slot++) { - const crop = cropRoi(rgb, gearSubRoi(row, slot)); - const sub = matchWeapon(crop, abilities.subs, { - inkThreshold: OWN_ABILITY_INK_THRESHOLD, - }); - crop.delete(); - ids.push(toCvAbility(sub.id) ?? "UNKNOWN"); - debug.push(sub); - confidences.push(Math.max(0, sub.score)); - } - abilityRows.push(ids); - abilityDebug.push(debug); - } - } + // gear-card ability strips: [head, clothes, shoes] x [main, sub, sub, sub] + const abilityRows: CvAbility[][] = []; + const abilityDebug: (WeaponMatch | null)[][] = []; + if (abilities) { + for (let row = 0; row < GEAR_ROWS; row++) { + const ids: CvAbility[] = []; + const debug: (WeaponMatch | null)[] = []; + const mainCrop = cropRoi(rgb, gearMainRoi(row)); + const main = matchWeapon(mainCrop, abilities.mains, { + inkThreshold: OWN_ABILITY_INK_THRESHOLD, + }); + mainCrop.delete(); + ids.push(toCvAbility(main.id) ?? "UNKNOWN"); + debug.push(main); + confidences.push(Math.max(0, main.score)); + for (let slot = 0; slot < 3; slot++) { + const crop = cropRoi(rgb, gearSubRoi(row, slot)); + const sub = matchWeapon(crop, abilities.subs, { + inkThreshold: OWN_ABILITY_INK_THRESHOLD, + }); + crop.delete(); + ids.push(toCvAbility(sub.id) ?? "UNKNOWN"); + debug.push(sub); + confidences.push(Math.max(0, sub.score)); + } + abilityRows.push(ids); + abilityDebug.push(debug); + } + } - gray.delete(); - rgb.delete(); + gray.delete(); + rgb.delete(); - const confidence = - confidences.length > 0 ? confidences.reduce((a, b) => a + b, 0) / confidences.length : 0; + const confidence = + confidences.length > 0 + ? confidences.reduce((a, b) => a + b, 0) / confidences.length + : 0; - return [ - { - type: SCOREBOARD_OWN_EVENT_TYPE, - t, - confidence, - data: { - lobby: header?.lobby ?? null, - mode: header?.mode ?? null, - stage: header?.stage ?? null, - weaponId, - abilities: abilityRows, - }, - debug: { - header: header?.debug, - weaponName: weapon, - weaponReading, - weaponScore, - abilityRows: abilityDebug.map((row) => - row.map((m) => m && { top: m.top, score: m.score }), - ), - }, - }, - ]; - } + return [ + { + type: SCOREBOARD_OWN_EVENT_TYPE, + t, + confidence, + data: { + lobby: header?.lobby ?? null, + mode: header?.mode ?? null, + stage: header?.stage ?? null, + weaponId, + abilities: abilityRows, + }, + debug: { + header: header?.debug, + weaponName: weapon, + weaponReading, + weaponScore, + abilityRows: abilityDebug.map((row) => + row.map((m) => m && { top: m.top, score: m.score }), + ), + }, + }, + ]; + } - return { id: "scoreboard-own", gate, parse }; + return { id: "scoreboard-own", gate, parse }; } diff --git a/app/features/cv/core/detectors/scoreboard-own/rois.ts b/app/features/cv/core/detectors/scoreboard-own/rois.ts index 2b91f949e..e454ed934 100644 --- a/app/features/cv/core/detectors/scoreboard-own/rois.ts +++ b/app/features/cv/core/detectors/scoreboard-own/rois.ts @@ -39,13 +39,13 @@ export const GEAR_ROWS = 3; * sub box excludes the main-size templates within a shared template list. */ export function gearMainRoi(row: number): Roi { - const cx = GEAR_MAIN_CXS[row]!; - return { x: cx - 32, y: GEAR_BADGE_CY - 32, w: 64, h: 64 }; + const cx = GEAR_MAIN_CXS[row]!; + return { x: cx - 32, y: GEAR_BADGE_CY - 32, w: 64, h: 64 }; } export function gearSubRoi(row: number, slot: number): Roi { - const cx = GEAR_MAIN_CXS[row]! + GEAR_SUB_DXS[slot]!; - return { x: cx - 26, y: GEAR_BADGE_CY - 26, w: 52, h: 52 }; + const cx = GEAR_MAIN_CXS[row]! + GEAR_SUB_DXS[slot]!; + return { x: cx - 26, y: GEAR_BADGE_CY - 26, w: 52, h: 52 }; } /** Template heights (px at 1080p) per badge role (main ⌀~49, sub ⌀~38). */ @@ -71,26 +71,26 @@ export const OWN_ABILITY_INK_THRESHOLD = 170; * strip's gray shows in the constant gap after the third sub badge. */ export const GATE_PANEL_PROBES: readonly Roi[] = [ - { x: 860, y: 245, w: 30, h: 20 }, - { x: 1690, y: 395, w: 30, h: 20 }, - { x: 875, y: 695, w: 30, h: 20 }, - { x: 1400, y: 985, w: 30, h: 20 }, + { x: 860, y: 245, w: 30, h: 20 }, + { x: 1690, y: 395, w: 30, h: 20 }, + { x: 875, y: 695, w: 30, h: 20 }, + { x: 1400, y: 985, w: 30, h: 20 }, ]; export const GATE_PANEL_MAX_MEAN = 65; /** Left edge of each card's title text (weapon card first). */ export const GATE_TITLE_TEXT_PROBES: readonly Roi[] = [ - { x: 880, y: 768, w: 70, h: 26 }, - { x: 1090, y: 768, w: 70, h: 26 }, - { x: 1319, y: 768, w: 70, h: 26 }, - { x: 1550, y: 768, w: 70, h: 26 }, + { x: 880, y: 768, w: 70, h: 26 }, + { x: 1090, y: 768, w: 70, h: 26 }, + { x: 1319, y: 768, w: 70, h: 26 }, + { x: 1550, y: 768, w: 70, h: 26 }, ]; /** Title text regions must contain bright (white) pixels. */ export const GATE_TEXT_MIN_MAX = 180; /** Gray ability-strip gap after the third sub badge, one per gear card. */ export function gateStripProbe(row: number): Roi { - return { x: GEAR_MAIN_CXS[row]! + 148, y: 917, w: 10, h: 18 }; + return { x: GEAR_MAIN_CXS[row]! + 148, y: 917, w: 10, h: 18 }; } export const GATE_STRIP_MIN_MEAN = 110; export const GATE_STRIP_MAX_MEAN = 200; diff --git a/app/features/cv/core/detectors/scoreboard-replay/code.ts b/app/features/cv/core/detectors/scoreboard-replay/code.ts index 98a238c88..05215f80a 100644 --- a/app/features/cv/core/detectors/scoreboard-replay/code.ts +++ b/app/features/cv/core/detectors/scoreboard-replay/code.ts @@ -5,7 +5,11 @@ * channel, where the glyphs are near-white. */ import { getCV, type Mat } from "../../cv"; -import { type GlyphSet, type RecognizedText, recognizeText } from "../../glyphs"; +import { + type GlyphSet, + type RecognizedText, + recognizeText, +} from "../../glyphs"; import { cropRoi } from "../../image"; import { REPLAY_CODE_ROI } from "./rois"; @@ -21,15 +25,17 @@ const Q_TWINS = new Set(["0", "O"]); const Q_DESCENT_MIN_PX = 4; function resolveQsByDescent(raw: RecognizedText): string { - const anchors = raw.chars - .filter((c) => !Q_TWINS.has(c.char) && c.char !== "-") - .map((c) => c.y1) - .sort((a, b) => a - b); - if (anchors.length === 0) return raw.text; - const baseline = anchors[Math.floor(anchors.length / 2)]!; - return raw.chars - .map((c) => (Q_TWINS.has(c.char) && c.y1 - baseline >= Q_DESCENT_MIN_PX ? "Q" : c.char)) - .join(""); + const anchors = raw.chars + .filter((c) => !Q_TWINS.has(c.char) && c.char !== "-") + .map((c) => c.y1) + .sort((a, b) => a - b); + if (anchors.length === 0) return raw.text; + const baseline = anchors[Math.floor(anchors.length / 2)]!; + return raw.chars + .map((c) => + Q_TWINS.has(c.char) && c.y1 - baseline >= Q_DESCENT_MIN_PX ? "Q" : c.char, + ) + .join(""); } /** @@ -46,33 +52,38 @@ const LU_SCORE_MARGIN = 0.12; const LU_INK_THRESHOLD = 150; const LU_TOP_RIGHT_MIN_FRACTION = 0.2; -function resolveUsByTopRightInk(raw: RecognizedText, green: Mat): RecognizedText { - const { cols, data } = green; - const chars = raw.chars.map((c) => { - if (c.char !== "L") return c; - const u = c.candidates?.find((k) => k.char === "U"); - if (!u || c.score - u.score > LU_SCORE_MARGIN) return c; - const xMid = Math.ceil((c.x0 + c.x1) / 2); - const yMid = Math.floor((c.y0 + c.y1) / 2); - let ink = 0; - let total = 0; - for (let y = c.y0; y < yMid; y++) { - for (let x = xMid; x < c.x1; x++) { - total++; - if (data[y * cols + x]! > LU_INK_THRESHOLD) ink++; - } - } - return total > 0 && ink / total >= LU_TOP_RIGHT_MIN_FRACTION ? { ...c, char: "U" } : c; - }); - return { ...raw, chars, text: chars.map((c) => c.char).join("") }; +function resolveUsByTopRightInk( + raw: RecognizedText, + green: Mat, +): RecognizedText { + const { cols, data } = green; + const chars = raw.chars.map((c) => { + if (c.char !== "L") return c; + const u = c.candidates?.find((k) => k.char === "U"); + if (!u || c.score - u.score > LU_SCORE_MARGIN) return c; + const xMid = Math.ceil((c.x0 + c.x1) / 2); + const yMid = Math.floor((c.y0 + c.y1) / 2); + let ink = 0; + let total = 0; + for (let y = c.y0; y < yMid; y++) { + for (let x = xMid; x < c.x1; x++) { + total++; + if (data[y * cols + x]! > LU_INK_THRESHOLD) ink++; + } + } + return total > 0 && ink / total >= LU_TOP_RIGHT_MIN_FRACTION + ? { ...c, char: "U" } + : c; + }); + return { ...raw, chars, text: chars.map((c) => c.char).join("") }; } export interface ParsedReplayCode { - /** normalized "XXXX-XXXX-XXXX-XXXX", or null when the shape is wrong */ - code: string | null; - /** min glyph score across recognized characters */ - confidence: number; - raw: RecognizedText; + /** normalized "XXXX-XXXX-XXXX-XXXX", or null when the shape is wrong */ + code: string | null; + /** min glyph score across recognized characters */ + confidence: number; + raw: RecognizedText; } const CODE_RE = /^[0-9A-Z]{4}(-[0-9A-Z]{4}){3}$/; @@ -82,41 +93,44 @@ const CODE_RE = /^[0-9A-Z]{4}(-[0-9A-Z]{4}){3}$/; * view over the same template mats, so dispose only the source set. */ export function codeCharsetOf(set: GlyphSet): GlyphSet { - const glyphs = set.glyphs.filter((g) => /^[0-9A-Z-]$/.test(g.char)); - const widths = glyphs.map((g) => g.mat.cols).sort((a, b) => a - b); - return { - glyphs, - height: set.height, - medianWidth: widths[Math.floor(widths.length / 2)] ?? set.medianWidth, - }; + const glyphs = set.glyphs.filter((g) => /^[0-9A-Z-]$/.test(g.char)); + const widths = glyphs.map((g) => g.mat.cols).sort((a, b) => a - b); + return { + glyphs, + height: set.height, + medianWidth: widths[Math.floor(widths.length / 2)] ?? set.medianWidth, + }; } /** rgb: full normalized frame in RGB (not RGBA). */ export function parseReplayCode(rgb: Mat, glyphs: GlyphSet): ParsedReplayCode { - const cv = getCV(); - const view = cropRoi(rgb, REPLAY_CODE_ROI); - const channels = new cv.MatVector(); - cv.split(view, channels); - const g = channels.get(1); - const green = new cv.Mat(); - g.copyTo(green); - g.delete(); - channels.delete(); - view.delete(); + const cv = getCV(); + const view = cropRoi(rgb, REPLAY_CODE_ROI); + const channels = new cv.MatVector(); + cv.split(view, channels); + const g = channels.get(1); + const green = new cv.Mat(); + g.copyTo(green); + g.delete(); + channels.delete(); + view.delete(); - const raw = recognizeText(green, glyphs, { spaceGap: Infinity, minCharScore: 0.3 }); - const resolved = resolveUsByTopRightInk(raw, green); - green.delete(); + const raw = recognizeText(green, glyphs, { + spaceGap: Number.POSITIVE_INFINITY, + minCharScore: 0.3, + }); + const resolved = resolveUsByTopRightInk(raw, green); + green.delete(); - let text = resolveQsByDescent(resolved).toUpperCase(); - // Dashes are thin and can drop out of segmentation; a clean 16-char - // alphanumeric read is unambiguous, so re-insert them. - if (/^[0-9A-Z]{16}$/.test(text)) { - text = text.replace(/(.{4})(?=.)/g, "$1-"); - } - return { - code: CODE_RE.test(text) ? text : null, - confidence: raw.confidence, - raw, - }; + let text = resolveQsByDescent(resolved).toUpperCase(); + // Dashes are thin and can drop out of segmentation; a clean 16-char + // alphanumeric read is unambiguous, so re-insert them. + if (/^[0-9A-Z]{16}$/.test(text)) { + text = text.replace(/(.{4})(?=.)/g, "$1-"); + } + return { + code: CODE_RE.test(text) ? text : null, + confidence: raw.confidence, + raw, + }; } diff --git a/app/features/cv/core/detectors/scoreboard-replay/header.ts b/app/features/cv/core/detectors/scoreboard-replay/header.ts index 6ea0289a7..45a06e374 100644 --- a/app/features/cv/core/detectors/scoreboard-replay/header.ts +++ b/app/features/cv/core/detectors/scoreboard-replay/header.ts @@ -18,18 +18,18 @@ import { readTagBand } from "../scoreboard/header"; import { HEADER_BOTTOM_BAND, HEADER_TOP_BAND } from "./rois"; export interface ParsedReplayHeader { - timestamp: string | null; - stage: StageId | null; - lobby: CvLobby | null; - mode: ModeShort | null; - /** min of the closed-set match scores that were attempted */ - confidence: number; - debug: { - topReading: string; - bottomReading: string; - stageScore: number; - bottomScore: number; - }; + timestamp: string | null; + stage: StageId | null; + lobby: CvLobby | null; + mode: ModeShort | null; + /** min of the closed-set match scores that were attempted */ + confidence: number; + debug: { + topReading: string; + bottomReading: string; + stageScore: number; + bottomScore: number; + }; } const MIN_MATCH_SCORE = 0.62; @@ -51,84 +51,91 @@ const TAG_DARK_MAX_LIFTED = 120; * captures ("14:1 1"), so a lone space is tolerated between them and * stripped when the timestamp is assembled. */ -const TIMESTAMP_RE = /^(\d{1,4}[./-]\d{1,2}[./-]\d{1,4})\s+(\d(?: ?\d)?: ?\d ?\d)\s*(.*)$/; +const TIMESTAMP_RE = + /^(\d{1,4}[./-]\d{1,2}[./-]\d{1,4})\s+(\d(?: ?\d)?: ?\d ?\d)\s*(.*)$/; interface TopBandParse { - reading: string; - timestamp: string | null; - stage: StageId | null; - stageScore: number; + reading: string; + timestamp: string | null; + stage: StageId | null; + stageScore: number; } function parseTopBand(reading: string): TopBandParse { - let timestamp: string | null = null; - let stage: StageId | null = null; - let stageScore = 0; - // The top band reads with the BlitzMain name glyphs, where 1/I/l/| are - // identical bars ("I9:04") and O rides a hair above 0 ("2O26"); in the - // digits-only timestamp every bar is a '1' and every O a '0'. Match on - // the normalized line, keep the stage part's original reading (the - // replacements are 1:1, so offsets line up). - const normalized = reading.replace(/[Il|]/g, "1").replace(/O/g, "0"); - const m = TIMESTAMP_RE.exec(normalized); - const stageReading = m ? reading.slice(reading.length - m[3]!.length) : reading; - if (m) timestamp = `${m[1]!} ${m[2]!.replace(/ /g, "")}`; - if (stageReading) { - const match = closestBy(stageReading, ALL_STAGE_ENTRIES, (e) => e.text); - if (match) { - stageScore = match.score; - if (match.score >= MIN_MATCH_SCORE) stage = match.entry.stageId; - } - } - return { reading, timestamp, stage, stageScore }; + let timestamp: string | null = null; + let stage: StageId | null = null; + let stageScore = 0; + // The top band reads with the BlitzMain name glyphs, where 1/I/l/| are + // identical bars ("I9:04") and O rides a hair above 0 ("2O26"); in the + // digits-only timestamp every bar is a '1' and every O a '0'. Match on + // the normalized line, keep the stage part's original reading (the + // replacements are 1:1, so offsets line up). + const normalized = reading.replace(/[Il|]/g, "1").replace(/O/g, "0"); + const m = TIMESTAMP_RE.exec(normalized); + const stageReading = m + ? reading.slice(reading.length - m[3]!.length) + : reading; + if (m) timestamp = `${m[1]!} ${m[2]!.replace(/ /g, "")}`; + if (stageReading) { + const match = closestBy(stageReading, ALL_STAGE_ENTRIES, (e) => e.text); + if (match) { + stageScore = match.score; + if (match.score >= MIN_MATCH_SCORE) stage = match.entry.stageId; + } + } + return { reading, timestamp, stage, stageScore }; } export function parseReplayHeader( - gray: Mat, - topGlyphs: GlyphSet, - bottomGlyphs: GlyphSet, + gray: Mat, + topGlyphs: GlyphSet, + bottomGlyphs: GlyphSet, ): ParsedReplayHeader { - let top = parseTopBand(readTagBand(gray, HEADER_TOP_BAND, topGlyphs)); - if (top.stage === null) { - const retry = parseTopBand( - readTagBand(gray, HEADER_TOP_BAND, topGlyphs, { tagDarkMax: TAG_DARK_MAX_LIFTED }), - ); - if (retry.stageScore >= top.stageScore) top = retry; - } + let top = parseTopBand(readTagBand(gray, HEADER_TOP_BAND, topGlyphs)); + if (top.stage === null) { + const retry = parseTopBand( + readTagBand(gray, HEADER_TOP_BAND, topGlyphs, { + tagDarkMax: TAG_DARK_MAX_LIFTED, + }), + ); + if (retry.stageScore >= top.stageScore) top = retry; + } - let bottomReading = readTagBand(gray, HEADER_BOTTOM_BAND, bottomGlyphs); - let bottomMatch = bottomReading - ? closestBy(bottomReading, LOBBY_MODE_COMBOS, (c) => c.text) - : null; - if (!bottomMatch || bottomMatch.score < MIN_MATCH_SCORE) { - const reading = readTagBand(gray, HEADER_BOTTOM_BAND, bottomGlyphs, { - tagDarkMax: TAG_DARK_MAX_LIFTED, - }); - const match = reading ? closestBy(reading, LOBBY_MODE_COMBOS, (c) => c.text) : null; - if ((match?.score ?? 0) >= (bottomMatch?.score ?? 0)) { - bottomReading = reading; - bottomMatch = match; - } - } + let bottomReading = readTagBand(gray, HEADER_BOTTOM_BAND, bottomGlyphs); + let bottomMatch = bottomReading + ? closestBy(bottomReading, LOBBY_MODE_COMBOS, (c) => c.text) + : null; + if (!bottomMatch || bottomMatch.score < MIN_MATCH_SCORE) { + const reading = readTagBand(gray, HEADER_BOTTOM_BAND, bottomGlyphs, { + tagDarkMax: TAG_DARK_MAX_LIFTED, + }); + const match = reading + ? closestBy(reading, LOBBY_MODE_COMBOS, (c) => c.text) + : null; + if ((match?.score ?? 0) >= (bottomMatch?.score ?? 0)) { + bottomReading = reading; + bottomMatch = match; + } + } - let lobby: CvLobby | null = null; - let mode: ModeShort | null = null; - if (bottomMatch && bottomMatch.score >= MIN_MATCH_SCORE) { - lobby = bottomMatch.entry.lobby; - mode = bottomMatch.entry.mode; - } + let lobby: CvLobby | null = null; + let mode: ModeShort | null = null; + if (bottomMatch && bottomMatch.score >= MIN_MATCH_SCORE) { + lobby = bottomMatch.entry.lobby; + mode = bottomMatch.entry.mode; + } - return { - timestamp: top.timestamp, - stage: top.stage, - lobby, - mode, - confidence: Math.min(top.stageScore, bottomMatch?.score ?? 0), - debug: { - topReading: top.reading, - bottomReading, - stageScore: top.stageScore, - bottomScore: bottomMatch?.score ?? 0, - }, - }; + return { + timestamp: top.timestamp, + stage: top.stage, + lobby, + mode, + confidence: Math.min(top.stageScore, bottomMatch?.score ?? 0), + debug: { + topReading: top.reading, + bottomReading, + stageScore: top.stageScore, + bottomScore: bottomMatch?.score ?? 0, + }, + }; } diff --git a/app/features/cv/core/detectors/scoreboard-replay/index.ts b/app/features/cv/core/detectors/scoreboard-replay/index.ts index ee887d04d..a7f7a6afa 100644 --- a/app/features/cv/core/detectors/scoreboard-replay/index.ts +++ b/app/features/cv/core/detectors/scoreboard-replay/index.ts @@ -11,15 +11,21 @@ */ import { getCV, type Mat } from "../../cv"; import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs"; -import { cropRoi, maxBrightness, maxChannel, meanBrightness, type Roi } from "../../image"; +import { + cropRoi, + maxBrightness, + maxChannel, + meanBrightness, + type Roi, +} from "../../image"; import { RESULT_TAG_ENTRIES } from "../../localized"; import { closestBy } from "../../text"; import { type ParsedNumber, parseNumber } from "../scoreboard/digits"; import type { - ScoreboardData, - ScoreboardPlayer, - ScoreboardResources, - ScoreboardRowDebug, + ScoreboardData, + ScoreboardPlayer, + ScoreboardResources, + ScoreboardRowDebug, } from "../scoreboard/index"; import { findPovIndex } from "../scoreboard/pov"; import { parseScoreboardRow, type RowRois } from "../scoreboard/row"; @@ -27,49 +33,49 @@ import type { DetectedEvent, Detector, GateResult } from "../types"; import { codeCharsetOf, type ParsedReplayCode, parseReplayCode } from "./code"; import { type ParsedReplayHeader, parseReplayHeader } from "./header"; import { - CODE_TEXT_HEIGHT, - GATE_CODE_BLUE_MAX, - GATE_CODE_GREEN_MIN, - GATE_CODE_MIN_FRACTION, - GATE_FLAT_MAX_MEAN, - GATE_FLAT_MIN_MEAN, - GATE_GAP_MAX_MEAN, - GATE_GAP_PROBES, - GATE_TEXT_MIN_MAX, - gateFlatProbe, - HEADER_LINE_HEIGHT, - HEADER_TIMESTAMP_HEIGHT, - MATCH_SCORE_DIGIT_HEIGHT, - MATCH_SCORE_ROIS, - NAME_TEXT_HEIGHT, - nameRoi, - PAINT_DIGIT_HEIGHT, - PANEL_XS, - paintRoi, - paintSuffixRoi, - povArrowRoi, - REPLAY_CODE_ROI, - RESULT_TAG_TEXT_HEIGHT, - ROW_CENTERS, - resultTagRoi, - STAT_DIGIT_HEIGHT, - specialIconRoi, - statRoi, - TEAM_DIGIT_HEIGHT, - teamScoreRoi, - weaponRoi, + CODE_TEXT_HEIGHT, + GATE_CODE_BLUE_MAX, + GATE_CODE_GREEN_MIN, + GATE_CODE_MIN_FRACTION, + GATE_FLAT_MAX_MEAN, + GATE_FLAT_MIN_MEAN, + GATE_GAP_MAX_MEAN, + GATE_GAP_PROBES, + GATE_TEXT_MIN_MAX, + gateFlatProbe, + HEADER_LINE_HEIGHT, + HEADER_TIMESTAMP_HEIGHT, + MATCH_SCORE_DIGIT_HEIGHT, + MATCH_SCORE_ROIS, + NAME_TEXT_HEIGHT, + nameRoi, + PAINT_DIGIT_HEIGHT, + PANEL_XS, + paintRoi, + paintSuffixRoi, + povArrowRoi, + REPLAY_CODE_ROI, + RESULT_TAG_TEXT_HEIGHT, + ROW_CENTERS, + resultTagRoi, + STAT_DIGIT_HEIGHT, + specialIconRoi, + statRoi, + TEAM_DIGIT_HEIGHT, + teamScoreRoi, + weaponRoi, } from "./rois"; export interface ScoreboardReplayData extends ScoreboardData { - /** recording timestamp as shown, e.g. "3/7/2026 22:28"; locale-formatted */ - timestamp: string | null; - /** "XXXX-XXXX-XXXX-XXXX" */ - replayCode: string | null; - /** - * the colored "Score:" banner values, [winner, loser] like `scores`; - * a knockout's winner reports 100 (the burst hides the real banner) - */ - matchScores: [number | null, number | null]; + /** recording timestamp as shown, e.g. "3/7/2026 22:28"; locale-formatted */ + timestamp: string | null; + /** "XXXX-XXXX-XXXX-XXXX" */ + replayCode: string | null; + /** + * the colored "Score:" banner values, [winner, loser] like `scores`; + * a knockout's winner reports 100 (the burst hides the real banner) + */ + matchScores: [number | null, number | null]; } export const SCOREBOARD_REPLAY_EVENT_TYPE = "ScoreboardReplay"; @@ -114,272 +120,328 @@ const RESULT_MIN_SCORE = 0.6; const RESULT_TAG_BIN_THRESHOLD = 190; interface PanelParse { - players: ScoreboardPlayer[]; - rows: ScoreboardRowDebug[]; - teamScore: ParsedNumber | null; - matchScore: ParsedNumber | null; - result: PanelResult | null; - resultReading: string; - resultScore: number; - confidences: number[]; + players: ScoreboardPlayer[]; + rows: ScoreboardRowDebug[]; + teamScore: ParsedNumber | null; + matchScore: ParsedNumber | null; + result: PanelResult | null; + resultReading: string; + resultScore: number; + confidences: number[]; } /** Fraction of ROI pixels matching the replay code's green (RGBA frame). */ function greenFraction(frame: Mat, roi: Roi): number { - const cv = getCV(); - const view = cropRoi(frame, roi); - const cont = new cv.Mat(); - view.copyTo(cont); - view.delete(); - const d = cont.data; - const n = cont.rows * cont.cols; - let green = 0; - for (let i = 0; i < n; i++) { - if (d[i * 4 + 1]! > GATE_CODE_GREEN_MIN && d[i * 4 + 2]! < GATE_CODE_BLUE_MAX) green++; - } - cont.delete(); - return n > 0 ? green / n : 0; + const cv = getCV(); + const view = cropRoi(frame, roi); + const cont = new cv.Mat(); + view.copyTo(cont); + view.delete(); + const d = cont.data; + const n = cont.rows * cont.cols; + let green = 0; + for (let i = 0; i < n; i++) { + if ( + d[i * 4 + 1]! > GATE_CODE_GREEN_MIN && + d[i * 4 + 2]! < GATE_CODE_BLUE_MAX + ) + green++; + } + cont.delete(); + return n > 0 ? green / n : 0; } export function createScoreboardReplayDetector( - resources: ScoreboardResources, + resources: ScoreboardResources, ): Detector { - const cv = getCV(); + const cv = getCV(); - const scaled = (set: GlyphSet | null, height: number): GlyphSet | null => - set ? scaleGlyphSet(set, height / set.height) : null; + const scaled = (set: GlyphSet | null, height: number): GlyphSet | null => + set ? scaleGlyphSet(set, height / set.height) : null; - const nameGlyphs = scaled(resources.nameGlyphs, NAME_TEXT_HEIGHT); - const paintDigits = scaled(resources.paintDigits, PAINT_DIGIT_HEIGHT); - const statDigits = scaled(resources.statDigits, STAT_DIGIT_HEIGHT); - const teamBase = resources.teamDigits ?? resources.paintDigits; - const teamDigits = scaled(teamBase, TEAM_DIGIT_HEIGHT); - const matchScoreDigits = scaled(teamBase, MATCH_SCORE_DIGIT_HEIGHT); - /** Timestamp needs digits + '/' + ':' — only the names atlas has them. */ - const headerTopGlyphs = scaled(resources.nameGlyphs, HEADER_TIMESTAMP_HEIGHT); - const headerBottomGlyphs = scaled(resources.headerLineGlyphs, HEADER_LINE_HEIGHT); - // Code and result tags render in FOT-RowdyStd — use the dedicated atlases - // when present; the BlitzMain-based fallbacks read them only roughly. - const resultGlyphs = - scaled(resources.replayResultGlyphs ?? null, RESULT_TAG_TEXT_HEIGHT) ?? - scaled(resources.headerLineGlyphs, RESULT_TAG_TEXT_HEIGHT); - const codeGlyphs = resources.replayCodeGlyphs - ? scaled(resources.replayCodeGlyphs, CODE_TEXT_HEIGHT) - : resources.nameGlyphs - ? scaleGlyphSet( - codeCharsetOf(resources.nameGlyphs), - CODE_TEXT_HEIGHT / resources.nameGlyphs.height, - ) - : null; + const nameGlyphs = scaled(resources.nameGlyphs, NAME_TEXT_HEIGHT); + const paintDigits = scaled(resources.paintDigits, PAINT_DIGIT_HEIGHT); + const statDigits = scaled(resources.statDigits, STAT_DIGIT_HEIGHT); + const teamBase = resources.teamDigits ?? resources.paintDigits; + const teamDigits = scaled(teamBase, TEAM_DIGIT_HEIGHT); + const matchScoreDigits = scaled(teamBase, MATCH_SCORE_DIGIT_HEIGHT); + /** Timestamp needs digits + '/' + ':' — only the names atlas has them. */ + const headerTopGlyphs = scaled(resources.nameGlyphs, HEADER_TIMESTAMP_HEIGHT); + const headerBottomGlyphs = scaled( + resources.headerLineGlyphs, + HEADER_LINE_HEIGHT, + ); + // Code and result tags render in FOT-RowdyStd — use the dedicated atlases + // when present; the BlitzMain-based fallbacks read them only roughly. + const resultGlyphs = + scaled(resources.replayResultGlyphs ?? null, RESULT_TAG_TEXT_HEIGHT) ?? + scaled(resources.headerLineGlyphs, RESULT_TAG_TEXT_HEIGHT); + const codeGlyphs = resources.replayCodeGlyphs + ? scaled(resources.replayCodeGlyphs, CODE_TEXT_HEIGHT) + : resources.nameGlyphs + ? scaleGlyphSet( + codeCharsetOf(resources.nameGlyphs), + CODE_TEXT_HEIGHT / resources.nameGlyphs.height, + ) + : null; - function gate(frame: Mat): GateResult { - const gray = new cv.Mat(); - cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); + function gate(frame: Mat): GateResult { + const gray = new cv.Mat(); + cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); - let flatOk = 0; - let suffixOk = 0; - for (const dx of PANEL_XS) { - for (const cy of ROW_CENTERS) { - const flat = meanBrightness(frame, gateFlatProbe(cy, dx)); - if (flat >= GATE_FLAT_MIN_MEAN && flat <= GATE_FLAT_MAX_MEAN) flatOk++; - if (maxBrightness(gray, paintSuffixRoi(cy, dx)) > GATE_TEXT_MIN_MAX) suffixOk++; - } - } - let gapOk = 0; - for (const roi of GATE_GAP_PROBES) { - if (meanBrightness(frame, roi) < GATE_GAP_MAX_MEAN) gapOk++; - } - const codeFraction = greenFraction(frame, REPLAY_CODE_ROI); - gray.delete(); + let flatOk = 0; + let suffixOk = 0; + for (const dx of PANEL_XS) { + for (const cy of ROW_CENTERS) { + const flat = meanBrightness(frame, gateFlatProbe(cy, dx)); + if (flat >= GATE_FLAT_MIN_MEAN && flat <= GATE_FLAT_MAX_MEAN) flatOk++; + if (maxBrightness(gray, paintSuffixRoi(cy, dx)) > GATE_TEXT_MIN_MAX) + suffixOk++; + } + } + let gapOk = 0; + for (const roi of GATE_GAP_PROBES) { + if (meanBrightness(frame, roi) < GATE_GAP_MAX_MEAN) gapOk++; + } + const codeFraction = greenFraction(frame, REPLAY_CODE_ROI); + gray.delete(); - const rowCount = PANEL_XS.length * ROW_CENTERS.length; - const score = - (flatOk / rowCount + - suffixOk / rowCount + - gapOk / GATE_GAP_PROBES.length + - Math.min(1, codeFraction / (2 * GATE_CODE_MIN_FRACTION))) / - 4; - const pass = - flatOk >= 7 && suffixOk >= 7 && gapOk === 2 && codeFraction >= GATE_CODE_MIN_FRACTION; - return { pass, score }; - } + const rowCount = PANEL_XS.length * ROW_CENTERS.length; + const score = + (flatOk / rowCount + + suffixOk / rowCount + + gapOk / GATE_GAP_PROBES.length + + Math.min(1, codeFraction / (2 * GATE_CODE_MIN_FRACTION))) / + 4; + const pass = + flatOk >= 7 && + suffixOk >= 7 && + gapOk === 2 && + codeFraction >= GATE_CODE_MIN_FRACTION; + return { pass, score }; + } - function parsePanel(gray: Mat, rgb: Mat, dx: number): PanelParse { - const players: ScoreboardPlayer[] = []; - const rows: ScoreboardRowDebug[] = []; - const confidences: number[] = []; + function parsePanel(gray: Mat, rgb: Mat, dx: number): PanelParse { + const players: ScoreboardPlayer[] = []; + const rows: ScoreboardRowDebug[] = []; + const confidences: number[] = []; - const rowRois: RowRois = { - weapon: (cy) => weaponRoi(cy, dx), - specialIcon: (cy) => specialIconRoi(cy, dx), - paint: (cy) => paintRoi(cy, dx), - name: (cy) => nameRoi(cy, dx), - stat: (cy, i) => statRoi(cy, dx, i), - povArrow: (cy) => povArrowRoi(cy, dx), - }; - const rowResources = { - weapons: resources.weapons, - specials: resources.specials, - paintDigits, - statDigits, - nameGlyphs, - }; - for (const cy of ROW_CENTERS) { - // A short team (e.g. a 7-player private battle) renders no pill for - // the unused bottom row — just near-black panel background where the - // flat probe expects the mid-gray pill (the gate's flatOk >= 7 already - // tolerates the missing row). Skip it: no phantom player. - const flat = meanBrightness(rgb, gateFlatProbe(cy, dx)); - if (flat < GATE_FLAT_MIN_MEAN || flat > GATE_FLAT_MAX_MEAN) continue; + const rowRois: RowRois = { + weapon: (cy) => weaponRoi(cy, dx), + specialIcon: (cy) => specialIconRoi(cy, dx), + paint: (cy) => paintRoi(cy, dx), + name: (cy) => nameRoi(cy, dx), + stat: (cy, i) => statRoi(cy, dx, i), + povArrow: (cy) => povArrowRoi(cy, dx), + }; + const rowResources = { + weapons: resources.weapons, + specials: resources.specials, + paintDigits, + statDigits, + nameGlyphs, + }; + for (const cy of ROW_CENTERS) { + // A short team (e.g. a 7-player private battle) renders no pill for + // the unused bottom row — just near-black panel background where the + // flat probe expects the mid-gray pill (the gate's flatOk >= 7 already + // tolerates the missing row). Skip it: no phantom player. + const flat = meanBrightness(rgb, gateFlatProbe(cy, dx)); + if (flat < GATE_FLAT_MIN_MEAN || flat > GATE_FLAT_MAX_MEAN) continue; - // replay rows render smaller (icons ~26px, inside the live template - // set's slide range) on a lighter panel; the paint number is - // left-aligned so the "p" suffix lands inside the ROI on short paints - const row = parseScoreboardRow(gray, rgb, cy, rowRois, rowResources, confidences, { - weaponInkThreshold: REPLAY_INK_THRESHOLD, - paintDropLoweredTrailing: true, - }); - players.push(row.player); - rows.push(row.debug); - } + // replay rows render smaller (icons ~26px, inside the live template + // set's slide range) on a lighter panel; the paint number is + // left-aligned so the "p" suffix lands inside the ROI on short paints + const row = parseScoreboardRow( + gray, + rgb, + cy, + rowRois, + rowResources, + confidences, + { + weaponInkThreshold: REPLAY_INK_THRESHOLD, + paintDropLoweredTrailing: true, + }, + ); + players.push(row.player); + rows.push(row.debug); + } - let teamScore: ParsedNumber | null = null; - if (teamDigits) { - const crop = cropRoi(gray, teamScoreRoi(dx)); - teamScore = parseNumber(crop, teamDigits, { binThreshold: BANNER_BIN_THRESHOLD }); - crop.delete(); - confidences.push(teamScore.confidence); - } + let teamScore: ParsedNumber | null = null; + if (teamDigits) { + const crop = cropRoi(gray, teamScoreRoi(dx)); + teamScore = parseNumber(crop, teamDigits, { + binThreshold: BANNER_BIN_THRESHOLD, + }); + crop.delete(); + confidences.push(teamScore.confidence); + } - let matchScore: ParsedNumber | null = null; - if (matchScoreDigits) { - const crop = cropRoi(gray, MATCH_SCORE_ROIS[dx === 0 ? 0 : 1]!); - matchScore = parseNumber(crop, matchScoreDigits, { - binThreshold: BANNER_BIN_THRESHOLD, - }); - if (matchScore.confidence < MATCH_SCORE_MIN_CONF) { - matchScore = { ...matchScore, value: null }; - } - crop.delete(); - confidences.push(matchScore.confidence); - // No number under the floor + a full team count = the KNOCKOUT! burst - // sitting where the banner's score would be. Report the count it won at - // rather than a hole; an unreadable banner on a lesser total stays null. - if (matchScore.value === null && teamScore?.value === FULL_COUNT_TEAM_SCORE) { - matchScore = { ...matchScore, value: KO_MATCH_SCORE }; - } - } + let matchScore: ParsedNumber | null = null; + if (matchScoreDigits) { + const crop = cropRoi(gray, MATCH_SCORE_ROIS[dx === 0 ? 0 : 1]!); + matchScore = parseNumber(crop, matchScoreDigits, { + binThreshold: BANNER_BIN_THRESHOLD, + }); + if (matchScore.confidence < MATCH_SCORE_MIN_CONF) { + matchScore = { ...matchScore, value: null }; + } + crop.delete(); + confidences.push(matchScore.confidence); + // No number under the floor + a full team count = the KNOCKOUT! burst + // sitting where the banner's score would be. Report the count it won at + // rather than a hole; an unreadable banner on a lesser total stays null. + if ( + matchScore.value === null && + teamScore?.value === FULL_COUNT_TEAM_SCORE + ) { + matchScore = { ...matchScore, value: KO_MATCH_SCORE }; + } + } - let result: PanelParse["result"] = null; - let resultReading = ""; - let resultScore = 0; - if (resultGlyphs) { - const bright = maxChannel(rgb, resultTagRoi(dx)); - const raw = recognizeText(bright, resultGlyphs, { - binThreshold: RESULT_TAG_BIN_THRESHOLD, - spaceGap: Infinity, - minCharScore: 0.25, - }); - bright.delete(); - resultReading = raw.text; - if (resultReading) { - const match = closestBy(resultReading, RESULT_TAG_ENTRIES, (e) => e.text); - if (match) { - resultScore = match.score; - if (match.score >= RESULT_MIN_SCORE) result = match.entry.canonical as PanelResult; - } - } - } + let result: PanelParse["result"] = null; + let resultReading = ""; + let resultScore = 0; + if (resultGlyphs) { + const bright = maxChannel(rgb, resultTagRoi(dx)); + const raw = recognizeText(bright, resultGlyphs, { + binThreshold: RESULT_TAG_BIN_THRESHOLD, + spaceGap: Number.POSITIVE_INFINITY, + minCharScore: 0.25, + }); + bright.delete(); + resultReading = raw.text; + if (resultReading) { + const match = closestBy( + resultReading, + RESULT_TAG_ENTRIES, + (e) => e.text, + ); + if (match) { + resultScore = match.score; + if (match.score >= RESULT_MIN_SCORE) + result = match.entry.canonical as PanelResult; + } + } + } - return { - players, - rows, - teamScore, - matchScore, - result, - resultReading, - resultScore, - confidences, - }; - } + return { + players, + rows, + teamScore, + matchScore, + result, + resultReading, + resultScore, + confidences, + }; + } - function parse(frame: Mat, t: number): DetectedEvent[] { - const gray = new cv.Mat(); - cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); - const rgb = new cv.Mat(); - cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB); + function parse(frame: Mat, t: number): DetectedEvent[] { + const gray = new cv.Mat(); + cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); + const rgb = new cv.Mat(); + cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB); - const [left, right] = PANEL_XS.map((dx) => parsePanel(gray, rgb, dx)) as [ - PanelParse, - PanelParse, - ]; + const [left, right] = PANEL_XS.map((dx) => parsePanel(gray, rgb, dx)) as [ + PanelParse, + PanelParse, + ]; - // Winners first. Trust a confident VICTORY/DEFEAT tag read; when the - // tags are inconclusive, the higher "Score:" banner marks the winner - // (the shown match score decides the game). Default to left otherwise. - let swapped = false; - if (left.result !== null || right.result !== null) { - swapped = left.result === "DEFEAT" || right.result === "VICTORY"; - } else if (left.matchScore?.value != null && right.matchScore?.value != null) { - swapped = right.matchScore.value > left.matchScore.value; - } - const [winner, loser] = swapped ? [right, left] : [left, right]; - // POV arrow row, indexed into the winners-first players ordering - const povIndex = findPovIndex([...winner.rows, ...loser.rows].map((r) => r.povFraction)); + // Winners first. Trust a confident VICTORY/DEFEAT tag read; when the + // tags are inconclusive, the higher "Score:" banner marks the winner + // (the shown match score decides the game). Default to left otherwise. + let swapped = false; + if (left.result !== null || right.result !== null) { + swapped = left.result === "DEFEAT" || right.result === "VICTORY"; + } else if ( + left.matchScore?.value != null && + right.matchScore?.value != null + ) { + swapped = right.matchScore.value > left.matchScore.value; + } + const [winner, loser] = swapped ? [right, left] : [left, right]; + // POV arrow row, indexed into the winners-first players ordering + const povIndex = findPovIndex( + [...winner.rows, ...loser.rows].map((r) => r.povFraction), + ); - let header: ParsedReplayHeader | null = null; - if (headerTopGlyphs && headerBottomGlyphs) { - header = parseReplayHeader(gray, headerTopGlyphs, headerBottomGlyphs); - } + let header: ParsedReplayHeader | null = null; + if (headerTopGlyphs && headerBottomGlyphs) { + header = parseReplayHeader(gray, headerTopGlyphs, headerBottomGlyphs); + } - let code: ParsedReplayCode | null = null; - if (codeGlyphs) { - code = parseReplayCode(rgb, codeGlyphs); - } + let code: ParsedReplayCode | null = null; + if (codeGlyphs) { + code = parseReplayCode(rgb, codeGlyphs); + } - gray.delete(); - rgb.delete(); + gray.delete(); + rgb.delete(); - const confidences = [ - ...winner.confidences, - ...loser.confidences, - ...(header ? [header.confidence] : []), - ...(code ? [code.confidence] : []), - ]; - const confidence = - confidences.length > 0 ? confidences.reduce((a, b) => a + b, 0) / confidences.length : 0; + const confidences = [ + ...winner.confidences, + ...loser.confidences, + ...(header ? [header.confidence] : []), + ...(code ? [code.confidence] : []), + ]; + const confidence = + confidences.length > 0 + ? confidences.reduce((a, b) => a + b, 0) / confidences.length + : 0; - return [ - { - type: SCOREBOARD_REPLAY_EVENT_TYPE, - t, - confidence, - data: { - lobby: header?.lobby ?? null, - mode: header?.mode ?? null, - stage: header?.stage ?? null, - timestamp: header?.timestamp ?? null, - replayCode: code?.code ?? null, - scores: [winner.teamScore?.value ?? null, loser.teamScore?.value ?? null], - matchScores: [winner.matchScore?.value ?? null, loser.matchScore?.value ?? null], - players: [...winner.players, ...loser.players], - povIndex, - }, - debug: { - rows: [...winner.rows, ...loser.rows], - teamScoreConf: [winner.teamScore?.confidence ?? 0, loser.teamScore?.confidence ?? 0], - matchScoreConf: [winner.matchScore?.confidence ?? 0, loser.matchScore?.confidence ?? 0], - header: header?.debug, - codeRaw: code?.raw.text, - winnerSide: swapped ? "right" : "left", - resultTags: { - left: { reading: left.resultReading, score: left.resultScore, result: left.result }, - right: { reading: right.resultReading, score: right.resultScore, result: right.result }, - }, - }, - }, - ]; - } + return [ + { + type: SCOREBOARD_REPLAY_EVENT_TYPE, + t, + confidence, + data: { + lobby: header?.lobby ?? null, + mode: header?.mode ?? null, + stage: header?.stage ?? null, + timestamp: header?.timestamp ?? null, + replayCode: code?.code ?? null, + scores: [ + winner.teamScore?.value ?? null, + loser.teamScore?.value ?? null, + ], + matchScores: [ + winner.matchScore?.value ?? null, + loser.matchScore?.value ?? null, + ], + players: [...winner.players, ...loser.players], + povIndex, + }, + debug: { + rows: [...winner.rows, ...loser.rows], + teamScoreConf: [ + winner.teamScore?.confidence ?? 0, + loser.teamScore?.confidence ?? 0, + ], + matchScoreConf: [ + winner.matchScore?.confidence ?? 0, + loser.matchScore?.confidence ?? 0, + ], + header: header?.debug, + codeRaw: code?.raw.text, + winnerSide: swapped ? "right" : "left", + resultTags: { + left: { + reading: left.resultReading, + score: left.resultScore, + result: left.result, + }, + right: { + reading: right.resultReading, + score: right.resultScore, + result: right.result, + }, + }, + }, + }, + ]; + } - return { id: "scoreboard-replay", gate, parse }; + return { id: "scoreboard-replay", gate, parse }; } diff --git a/app/features/cv/core/detectors/scoreboard-replay/rois.ts b/app/features/cv/core/detectors/scoreboard-replay/rois.ts index 02d7a6c7a..4110f07ff 100644 --- a/app/features/cv/core/detectors/scoreboard-replay/rois.ts +++ b/app/features/cv/core/detectors/scoreboard-replay/rois.ts @@ -29,7 +29,7 @@ export const PANEL_XS = [0, PANEL_DX] as const; * vertical slide room. matchTemplate silently skips taller templates. */ export function weaponRoi(cy: number, dx: number): Roi { - return { x: 522 + dx, y: cy - 34, w: 96, h: 68 }; + return { x: 522 + dx, y: cy - 34, w: 96, h: 68 }; } /** @@ -40,7 +40,7 @@ export function weaponRoi(cy: number, dx: number): Roi { * pill around it sits above matchSpecial's ink threshold. */ export function specialIconRoi(cy: number, dx: number): Roi { - return { x: 1104 + dx, y: cy - 32, w: 38, h: 33 }; + return { x: 1104 + dx, y: cy - 32, w: 38, h: 33 }; } /** @@ -50,7 +50,7 @@ export function specialIconRoi(cy: number, dx: number): Roi { * into the left edge — kept narrower than any observed first glyph. */ export function nameRoi(cy: number, dx: number): Roi { - return { x: 620 + dx, y: cy - 16, w: 226, h: 37 }; + return { x: 620 + dx, y: cy - 16, w: 226, h: 37 }; } /** @@ -60,7 +60,7 @@ export function nameRoi(cy: number, dx: number): Roi { * charset drops it on score. */ export function paintRoi(cy: number, dx: number): Roi { - return { x: 821 + dx, y: cy - 15, w: 88, h: 34 }; + return { x: 821 + dx, y: cy - 15, w: 88, h: 34 }; } /** @@ -69,7 +69,7 @@ export function paintRoi(cy: number, dx: number): Roi { * 898-907 after 3 digits, 915-924 after 4 — the probe spans both. */ export function paintSuffixRoi(cy: number, dx: number): Roi { - return { x: 896 + dx, y: cy - 13, w: 31, h: 26 }; + return { x: 896 + dx, y: cy - 13, w: 31, h: 26 }; } /** @@ -78,8 +78,8 @@ export function paintSuffixRoi(cy: number, dx: number): Roi { * anything higher, so the top edge must stay below them. */ export function statRoi(cy: number, dx: number, index: 0 | 1 | 2): Roi { - const x = [1000, 1057, 1114][index]!; - return { x: x + dx, y: cy + 3, w: 36, h: 24 }; + const x = [1000, 1057, 1114][index]!; + return { x: x + dx, y: cy + 3, w: 36, h: 24 }; } /** @@ -88,12 +88,12 @@ export function statRoi(cy: number, dx: number, index: 0 | 1 | 2): Roi { * short of the weapon-icon region (x 522+) core so icon yellows can't leak in. */ export function povArrowRoi(cy: number, dx: number): Roi { - return { x: 480 + dx, y: cy - 32, w: 54, h: 56 }; + return { x: 480 + dx, y: cy - 32, w: 54, h: 56 }; } /** Team totals ("440p") on the VICTORY/DEFEAT banner, digits ending x~1119. */ export function teamScoreRoi(dx: number): Roi { - return { x: 1040 + dx, y: 481, w: 86, h: 36 }; + return { x: 1040 + dx, y: 481, w: 86, h: 36 }; } /** @@ -101,13 +101,13 @@ export function teamScoreRoi(dx: number): Roi { * won (the replay owner's team may sit on either side). */ export function resultTagRoi(dx: number): Roi { - return { x: 540 + dx, y: 460, w: 220, h: 50 }; + return { x: 540 + dx, y: 460, w: 220, h: 50 }; } /** The colored "Score: NN" banners; digits after the constant label. */ export const MATCH_SCORE_ROIS: readonly [Roi, Roi] = [ - { x: 742, y: 340, w: 130, h: 56 }, - { x: 1620, y: 340, w: 130, h: 56 }, + { x: 742, y: 340, w: 130, h: 56 }, + { x: 1620, y: 340, w: 130, h: 56 }, ]; /** @@ -136,13 +136,13 @@ export const REPLAY_CODE_ROI: Roi = { x: 574, y: 960, w: 400, h: 38 }; * the first stat "x" — mid-gray on this screen, not near-black. */ export function gateFlatProbe(cy: number, dx: number): Roi { - return { x: 930 + dx, y: cy - 10, w: 42, h: 20 }; + return { x: 930 + dx, y: cy - 10, w: 42, h: 20 }; } /** Dark gap between row 1 and row 2 pills, one strip per panel. */ export const GATE_GAP_PROBES: readonly Roi[] = [ - { x: 560, y: 611, w: 540, h: 5 }, - { x: 560 + PANEL_DX, y: 611, w: 540, h: 5 }, + { x: 560, y: 611, w: 540, h: 5 }, + { x: 560 + PANEL_DX, y: 611, w: 540, h: 5 }, ]; /** Flat pill strips must sit in this mid-gray band. */ diff --git a/app/features/cv/core/detectors/scoreboard/digits.ts b/app/features/cv/core/detectors/scoreboard/digits.ts index 6917d64e2..2abe4e296 100644 --- a/app/features/cv/core/detectors/scoreboard/digits.ts +++ b/app/features/cv/core/detectors/scoreboard/digits.ts @@ -2,15 +2,19 @@ * Number field parsing on top of glyph recognition. */ import type { Mat } from "../../cv"; -import { type GlyphSet, type RecognizedText, recognizeText } from "../../glyphs"; +import { + type GlyphSet, + type RecognizedText, + recognizeText, +} from "../../glyphs"; export interface ParsedNumber { - value: number | null; - /** min glyph score across recognized digits */ - confidence: number; - /** x of the leftmost digit, relative to the crop (null when nothing found) */ - leftX: number | null; - raw: RecognizedText; + value: number | null; + /** min glyph score across recognized digits */ + confidence: number; + /** x of the leftmost digit, relative to the crop (null when nothing found) */ + leftX: number | null; + raw: RecognizedText; } /** @@ -21,31 +25,34 @@ export interface ParsedNumber { const LOWERED_TRAILING_MIN_PX = 5; export function parseNumber( - gray: Mat, - digits: GlyphSet, - options: { binThreshold?: number; dropLoweredTrailing?: boolean } = {}, + gray: Mat, + digits: GlyphSet, + options: { binThreshold?: number; dropLoweredTrailing?: boolean } = {}, ): ParsedNumber { - const raw = recognizeText(gray, digits, { - spaceGap: Infinity, - minCharScore: 0.3, - binThreshold: options.binThreshold, - }); - // The replay paint column is left-aligned, so its "p" suffix moves with - // the digit count and can land inside the ROI, where the digit-only - // charset misreads it (a "6"). The geometry still tells it apart. - let chars = raw.chars; - if (options.dropLoweredTrailing && chars.length > 1) { - const capY0 = Math.min(...chars.slice(0, -1).map((c) => c.y0)); - if (chars[chars.length - 1]!.y0 - capY0 >= LOWERED_TRAILING_MIN_PX) { - chars = chars.slice(0, -1); - } - } - const text = chars.map((c) => c.char).join(""); - const isNumeric = /^[0-9]+$/.test(text); - return { - value: isNumeric ? Number.parseInt(text, 10) : null, - confidence: chars.length > 0 ? Math.min(...chars.map((c) => c.score)) : raw.confidence, - leftX: chars.length > 0 ? chars[0]!.x0 : null, - raw, - }; + const raw = recognizeText(gray, digits, { + spaceGap: Number.POSITIVE_INFINITY, + minCharScore: 0.3, + binThreshold: options.binThreshold, + }); + // The replay paint column is left-aligned, so its "p" suffix moves with + // the digit count and can land inside the ROI, where the digit-only + // charset misreads it (a "6"). The geometry still tells it apart. + let chars = raw.chars; + if (options.dropLoweredTrailing && chars.length > 1) { + const capY0 = Math.min(...chars.slice(0, -1).map((c) => c.y0)); + if (chars[chars.length - 1]!.y0 - capY0 >= LOWERED_TRAILING_MIN_PX) { + chars = chars.slice(0, -1); + } + } + const text = chars.map((c) => c.char).join(""); + const isNumeric = /^[0-9]+$/.test(text); + return { + value: isNumeric ? Number.parseInt(text, 10) : null, + confidence: + chars.length > 0 + ? Math.min(...chars.map((c) => c.score)) + : raw.confidence, + leftX: chars.length > 0 ? chars[0]!.x0 : null, + raw, + }; } diff --git a/app/features/cv/core/detectors/scoreboard/header.ts b/app/features/cv/core/detectors/scoreboard/header.ts index de370b820..b7a01e233 100644 --- a/app/features/cv/core/detectors/scoreboard/header.ts +++ b/app/features/cv/core/detectors/scoreboard/header.ts @@ -13,24 +13,28 @@ import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import type { CvLobby } from "../../../cv-types"; import { getCV, type Mat } from "../../cv"; -import { type GlyphSet, type RecognizeOptions, recognizeText } from "../../glyphs"; +import { + type GlyphSet, + type RecognizeOptions, + recognizeText, +} from "../../glyphs"; import { copyRoi } from "../../image"; import { ALL_LOBBY_ENTRIES, MODE_STAGE_COMBOS } from "../../localized"; import { closestBy } from "../../text"; import { HEADER_LINE_BAND, HEADER_LOBBY_BAND } from "./rois"; export interface ParsedHeader { - lobby: CvLobby | null; - mode: ModeShort | null; - stage: StageId | null; - /** min of the closed-set match scores that were attempted */ - confidence: number; - debug: { - lobbyReading: string; - lineReading: string; - lobbyScore: number; - lineScore: number; - }; + lobby: CvLobby | null; + mode: ModeShort | null; + stage: StageId | null; + /** min of the closed-set match scores that were attempted */ + confidence: number; + debug: { + lobbyReading: string; + lineReading: string; + lobbyScore: number; + lineScore: number; + }; } /** Accept a closed-set match only above this score (1 = exact). */ @@ -48,33 +52,33 @@ const TAG_GAP_TOLERANCE = 6; * Returns the trimmed width (0 when no tag is present at all). */ function tagExtent(crop: Mat, darkMax: number): number { - const { cols, rows, data } = crop; - let end = 0; - let gap = 0; - for (let x = 0; x < cols; x++) { - let tagLike = 0; - for (let y = 0; y < rows; y++) { - const v = data[y * cols + x]!; - if (v < darkMax || v > TAG_BRIGHT_MIN) tagLike++; - } - if (tagLike / rows >= TAG_COLUMN_FRACTION) { - end = x + 1; - gap = 0; - } else if (++gap > TAG_GAP_TOLERANCE) { - break; - } - } - return end; + const { cols, rows, data } = crop; + let end = 0; + let gap = 0; + for (let x = 0; x < cols; x++) { + let tagLike = 0; + for (let y = 0; y < rows; y++) { + const v = data[y * cols + x]!; + if (v < darkMax || v > TAG_BRIGHT_MIN) tagLike++; + } + if (tagLike / rows >= TAG_COLUMN_FRACTION) { + end = x + 1; + gap = 0; + } else if (++gap > TAG_GAP_TOLERANCE) { + break; + } + } + return end; } export interface TagBandOptions extends RecognizeOptions { - /** - * Dark ceiling for the tag-extent trim. Lifted-blacks captures (720p - * streams upscaled and re-encoded) raise the tag background above the - * default, truncating the trim to a sliver — callers whose closed-set - * snap fails retry with a lifted ceiling. - */ - tagDarkMax?: number; + /** + * Dark ceiling for the tag-extent trim. Lifted-blacks captures (720p + * streams upscaled and re-encoded) raise the tag background above the + * default, truncating the trim to a sliver — callers whose closed-set + * snap fails retry with a lifted ceiling. + */ + tagDarkMax?: number; } /** @@ -83,56 +87,69 @@ export interface TagBandOptions extends RecognizeOptions { * whose tags have the same style at different positions/sizes. */ export function readTagBand( - gray: Mat, - band: { x: number; y: number; w: number; h: number }, - glyphs: GlyphSet, - options: TagBandOptions = {}, + gray: Mat, + band: { x: number; y: number; w: number; h: number }, + glyphs: GlyphSet, + options: TagBandOptions = {}, ): string { - const crop = copyRoi(gray, band); - const width = tagExtent(crop, options.tagDarkMax ?? TAG_DARK_MAX); - if (width < 12) { - crop.delete(); - return ""; - } - const cv = getCV(); - const view = crop.roi(new cv.Rect(0, 0, width, crop.rows)); - const trimmed = new cv.Mat(); - view.copyTo(trimmed); - view.delete(); - crop.delete(); - const result = recognizeText(trimmed, glyphs, { spaceGap: 9, minCharScore: 0.3, ...options }); - trimmed.delete(); - return result.text.trim(); + const crop = copyRoi(gray, band); + const width = tagExtent(crop, options.tagDarkMax ?? TAG_DARK_MAX); + if (width < 12) { + crop.delete(); + return ""; + } + const cv = getCV(); + const view = crop.roi(new cv.Rect(0, 0, width, crop.rows)); + const trimmed = new cv.Mat(); + view.copyTo(trimmed); + view.delete(); + crop.delete(); + const result = recognizeText(trimmed, glyphs, { + spaceGap: 9, + minCharScore: 0.3, + ...options, + }); + trimmed.delete(); + return result.text.trim(); } -export function parseHeader(gray: Mat, lobbyGlyphs: GlyphSet, lineGlyphs: GlyphSet): ParsedHeader { - const lobbyReading = readTagBand(gray, HEADER_LOBBY_BAND, lobbyGlyphs); - const lineReading = readTagBand(gray, HEADER_LINE_BAND, lineGlyphs); +export function parseHeader( + gray: Mat, + lobbyGlyphs: GlyphSet, + lineGlyphs: GlyphSet, +): ParsedHeader { + const lobbyReading = readTagBand(gray, HEADER_LOBBY_BAND, lobbyGlyphs); + const lineReading = readTagBand(gray, HEADER_LINE_BAND, lineGlyphs); - const lobbyMatch = lobbyReading - ? closestBy(lobbyReading, ALL_LOBBY_ENTRIES, (e) => e.text) - : null; - const lineMatch = lineReading ? closestBy(lineReading, MODE_STAGE_COMBOS, (c) => c.text) : null; + const lobbyMatch = lobbyReading + ? closestBy(lobbyReading, ALL_LOBBY_ENTRIES, (e) => e.text) + : null; + const lineMatch = lineReading + ? closestBy(lineReading, MODE_STAGE_COMBOS, (c) => c.text) + : null; - const lobby = lobbyMatch && lobbyMatch.score >= MIN_MATCH_SCORE ? lobbyMatch.entry.lobby : null; - let mode: ModeShort | null = null; - let stage: StageId | null = null; - if (lineMatch && lineMatch.score >= MIN_MATCH_SCORE) { - mode = lineMatch.entry.mode; - stage = lineMatch.entry.stageId; - } + const lobby = + lobbyMatch && lobbyMatch.score >= MIN_MATCH_SCORE + ? lobbyMatch.entry.lobby + : null; + let mode: ModeShort | null = null; + let stage: StageId | null = null; + if (lineMatch && lineMatch.score >= MIN_MATCH_SCORE) { + mode = lineMatch.entry.mode; + stage = lineMatch.entry.stageId; + } - const attempted = [lobbyMatch?.score ?? 0, lineMatch?.score ?? 0]; - return { - lobby, - mode, - stage, - confidence: Math.min(...attempted), - debug: { - lobbyReading, - lineReading, - lobbyScore: lobbyMatch?.score ?? 0, - lineScore: lineMatch?.score ?? 0, - }, - }; + const attempted = [lobbyMatch?.score ?? 0, lineMatch?.score ?? 0]; + return { + lobby, + mode, + stage, + confidence: Math.min(...attempted), + debug: { + lobbyReading, + lineReading, + lobbyScore: lobbyMatch?.score ?? 0, + lineScore: lineMatch?.score ?? 0, + }, + }; } diff --git a/app/features/cv/core/detectors/scoreboard/index.ts b/app/features/cv/core/detectors/scoreboard/index.ts index 6ec4bf543..54c9b72ec 100644 --- a/app/features/cv/core/detectors/scoreboard/index.ts +++ b/app/features/cv/core/detectors/scoreboard/index.ts @@ -2,7 +2,11 @@ * ScoreboardDetector: parses the end-of-match results scoreboard * (team scores + 8 rows of weapon / name / paint / splats / deaths / specials). */ -import type { MainWeaponId, ModeShort, StageId } from "~/modules/in-game-lists/types"; +import type { + MainWeaponId, + ModeShort, + StageId, +} from "~/modules/in-game-lists/types"; import type { CvLobby } from "../../../cv-types"; import { getCV, type Mat } from "../../cv"; import { type GlyphSet, scaleGlyphSet } from "../../glyphs"; @@ -12,258 +16,278 @@ import { parseNumber } from "./digits"; import { type ParsedHeader, parseHeader } from "./header"; import { findPovIndex } from "./pov"; import { - GATE_DARK_MAX_MEAN, - GATE_PANEL_MAX_MEAN, - GATE_PANEL_PROBES, - GATE_TEXT_MIN_MAX, - gateDarkProbe, - nameRoi, - PAINT_DIGIT_HEIGHT, - paintRoi, - paintSuffixRoi, - povArrowRoi, - ROW_CENTERS, - specialIconRoi, - statRoi, - TEAM_DIGIT_HEIGHT, - TEAM_SCORE_ROIS, - weaponRoi, + GATE_DARK_MAX_MEAN, + GATE_PANEL_MAX_MEAN, + GATE_PANEL_PROBES, + GATE_TEXT_MIN_MAX, + gateDarkProbe, + nameRoi, + PAINT_DIGIT_HEIGHT, + paintRoi, + paintSuffixRoi, + povArrowRoi, + ROW_CENTERS, + specialIconRoi, + statRoi, + TEAM_DIGIT_HEIGHT, + TEAM_SCORE_ROIS, + weaponRoi, } from "./rois"; import { parseScoreboardRow, type RowRois } from "./row"; import type { SpecialMatch, SpecialTemplate } from "./specials"; import type { WeaponMatch, WeaponTemplate } from "./weapons"; export interface ScoreboardPlayer { - name: string; - /** sendou main-weapon id; null when the row's weapon was unreadable */ - weaponId: MainWeaponId | null; - paint: number | null; - /** kills+assists (the combined counter as shown) */ - ka: number | null; - d: number | null; - s: number | null; + name: string; + /** sendou main-weapon id; null when the row's weapon was unreadable */ + weaponId: MainWeaponId | null; + paint: number | null; + /** kills+assists (the combined counter as shown) */ + ka: number | null; + d: number | null; + s: number | null; } export interface ScoreboardData { - /** from the header tag; null when unreadable */ - lobby: CvLobby | null; - mode: ModeShort | null; - stage: StageId | null; - /** [winning team total, losing team total] as shown ("500 p") */ - scores: [number | null, number | null]; - /** 8 players: rows 0-3 winning team, rows 4-7 losing team */ - players: ScoreboardPlayer[]; - /** - * index into `players` of the recording player's row, marked by the - * yellow arrow; null when no arrow is found (spectator/overhead footage) - */ - povIndex: number | null; + /** from the header tag; null when unreadable */ + lobby: CvLobby | null; + mode: ModeShort | null; + stage: StageId | null; + /** [winning team total, losing team total] as shown ("500 p") */ + scores: [number | null, number | null]; + /** 8 players: rows 0-3 winning team, rows 4-7 losing team */ + players: ScoreboardPlayer[]; + /** + * index into `players` of the recording player's row, marked by the + * yellow arrow; null when no arrow is found (spectator/overhead footage) + */ + povIndex: number | null; } export interface ScoreboardRowDebug { - weapon: WeaponMatch | null; - /** row's special icon match, when it was consulted for a weapon tie */ - special?: SpecialMatch; - paintScore: number; - nameScore: number; - statScores: [number, number, number]; - /** fraction of the row's POV-arrow probe that is arrow-yellow */ - povFraction: number; + weapon: WeaponMatch | null; + /** row's special icon match, when it was consulted for a weapon tie */ + special?: SpecialMatch; + paintScore: number; + nameScore: number; + statScores: [number, number, number]; + /** fraction of the row's POV-arrow probe that is arrow-yellow */ + povFraction: number; } export interface ScoreboardResources { - weapons: WeaponTemplate[]; - /** - * Special-weapon silhouettes (assets/cv/specials). Optional: without - * them, near-tied weapon icons (Splash- vs Sploosh-o-matic) stay decided - * by icon score alone. - */ - specials?: SpecialTemplate[] | null; - /** digit templates at paint size (h~28); team scores reuse these, scaled */ - paintDigits: GlyphSet | null; - /** digit templates at stat-counter size (h~17) */ - statDigits: GlyphSet | null; - /** - * digit templates for team totals (h~33, outlined, on team-color box). - * Optional: falls back to scaled paint digits, at reduced accuracy. - */ - teamDigits: GlyphSet | null; - nameGlyphs: GlyphSet | null; - /** header tag glyphs: lobby line, and the mode+stage line */ - headerLobbyGlyphs: GlyphSet | null; - headerLineGlyphs: GlyphSet | null; - /** - * Replay-browser extras (FOT-RowdyStd face, unlike everything above): - * the replay code line, and the VICTORY/DEFEAT panel tags. Optional: - * the replay detector falls back to rescaled name/header glyphs, at - * reduced accuracy. - */ - replayCodeGlyphs?: GlyphSet | null; - replayResultGlyphs?: GlyphSet | null; - /** - * Death-screen extras. Optional: the death detector skips the fields it - * has no resources for. The weapon atlas carries both Blitz faces; the - * tag-name atlas is BlitzBold + Rowdy (kana). - */ - abilities?: import("../death/abilities").AbilityTemplates | null; - deathWeaponGlyphs?: GlyphSet | null; - /** - * JA death-message glyphs (Kurokane/Rowdy condensed + fixture crops), - * built at the on-screen text's native size. Optional: without them JA - * death screens fail the constant-line confirmation and emit nothing. - */ - deathWeaponJaGlyphs?: GlyphSet | null; - deathTagNameGlyphs?: GlyphSet | null; - /** - * The main-weapon icons again, rebuilt at the death burst's icon size - * (~124px vs the rows' 40-64px). Optional: without them the death - * detector cannot recover the killer's weapon when the message text is - * unreadable (e.g. the WIPEOUT banner covering the weapon name line). - */ - deathBurstWeapons?: WeaponTemplate[] | null; - /** - * Personal-results extras: the same ability icons rebuilt at the gear - * cards' badge sizes (scoreboard-own/abilities.ts). Optional: without - * them the scoreboard-own detector skips the ability grid. - */ - ownAbilities?: import("../death/abilities").AbilityTemplates | null; - /** - * Map-start intro extras: the big BlitzBold mode title, and the BlitzMain - * stage name (whose atlas also reads the "MODE" label, rescaled). - * Optional: without them the map-start detector emits nothing. - */ - mapStartModeGlyphs?: GlyphSet | null; - mapStartStageGlyphs?: GlyphSet | null; - /** - * Minimap extras (minimap/rois.ts documents the screen): the main-weapon - * icons composited on the card-pill background for the teammate cards' - * light silhouettes, a light-background variant for highlighted enemy - * rows, and the ability icons at the cards' badge size. Optional: without - * them the minimap detector skips the corresponding fields (enemy rows - * fall back to the standard `weapons` set). Names reuse `nameGlyphs`. - */ - minimapCardWeapons?: WeaponTemplate[] | null; - minimapLightWeapons?: WeaponTemplate[] | null; - minimapAbilities?: WeaponTemplate[] | null; - /** - * Sub-weapon silhouettes (assets/cv/sub-weapons) at the minimap sub - * tile's sizes. Optional: without them, near-tied minimap weapon icons - * whose kits differ only by sub (plain vs Custom Dualie Squelchers) stay - * decided by icon score alone. - */ - minimapSubWeapons?: SpecialTemplate[] | null; - /** - * Planner-map structural signatures (assets/cv/planner) used by the - * minimap detector to identify the stage. Optional: without them the - * minimap event's `stage` stays null. - */ - plannerStages?: import("../minimap/stage").PlannerStage[] | null; + weapons: WeaponTemplate[]; + /** + * Special-weapon silhouettes (assets/cv/specials). Optional: without + * them, near-tied weapon icons (Splash- vs Sploosh-o-matic) stay decided + * by icon score alone. + */ + specials?: SpecialTemplate[] | null; + /** digit templates at paint size (h~28); team scores reuse these, scaled */ + paintDigits: GlyphSet | null; + /** digit templates at stat-counter size (h~17) */ + statDigits: GlyphSet | null; + /** + * digit templates for team totals (h~33, outlined, on team-color box). + * Optional: falls back to scaled paint digits, at reduced accuracy. + */ + teamDigits: GlyphSet | null; + nameGlyphs: GlyphSet | null; + /** header tag glyphs: lobby line, and the mode+stage line */ + headerLobbyGlyphs: GlyphSet | null; + headerLineGlyphs: GlyphSet | null; + /** + * Replay-browser extras (FOT-RowdyStd face, unlike everything above): + * the replay code line, and the VICTORY/DEFEAT panel tags. Optional: + * the replay detector falls back to rescaled name/header glyphs, at + * reduced accuracy. + */ + replayCodeGlyphs?: GlyphSet | null; + replayResultGlyphs?: GlyphSet | null; + /** + * Death-screen extras. Optional: the death detector skips the fields it + * has no resources for. The weapon atlas carries both Blitz faces; the + * tag-name atlas is BlitzBold + Rowdy (kana). + */ + abilities?: import("../death/abilities").AbilityTemplates | null; + deathWeaponGlyphs?: GlyphSet | null; + /** + * JA death-message glyphs (Kurokane/Rowdy condensed + fixture crops), + * built at the on-screen text's native size. Optional: without them JA + * death screens fail the constant-line confirmation and emit nothing. + */ + deathWeaponJaGlyphs?: GlyphSet | null; + deathTagNameGlyphs?: GlyphSet | null; + /** + * The main-weapon icons again, rebuilt at the death burst's icon size + * (~124px vs the rows' 40-64px). Optional: without them the death + * detector cannot recover the killer's weapon when the message text is + * unreadable (e.g. the WIPEOUT banner covering the weapon name line). + */ + deathBurstWeapons?: WeaponTemplate[] | null; + /** + * Personal-results extras: the same ability icons rebuilt at the gear + * cards' badge sizes (scoreboard-own/abilities.ts). Optional: without + * them the scoreboard-own detector skips the ability grid. + */ + ownAbilities?: import("../death/abilities").AbilityTemplates | null; + /** + * Map-start intro extras: the big BlitzBold mode title, and the BlitzMain + * stage name (whose atlas also reads the "MODE" label, rescaled). + * Optional: without them the map-start detector emits nothing. + */ + mapStartModeGlyphs?: GlyphSet | null; + mapStartStageGlyphs?: GlyphSet | null; + /** + * Minimap extras (minimap/rois.ts documents the screen): the main-weapon + * icons composited on the card-pill background for the teammate cards' + * light silhouettes, a light-background variant for highlighted enemy + * rows, and the ability icons at the cards' badge size. Optional: without + * them the minimap detector skips the corresponding fields (enemy rows + * fall back to the standard `weapons` set). Names reuse `nameGlyphs`. + */ + minimapCardWeapons?: WeaponTemplate[] | null; + minimapLightWeapons?: WeaponTemplate[] | null; + minimapAbilities?: WeaponTemplate[] | null; + /** + * Sub-weapon silhouettes (assets/cv/sub-weapons) at the minimap sub + * tile's sizes. Optional: without them, near-tied minimap weapon icons + * whose kits differ only by sub (plain vs Custom Dualie Squelchers) stay + * decided by icon score alone. + */ + minimapSubWeapons?: SpecialTemplate[] | null; + /** + * Planner-map structural signatures (assets/cv/planner) used by the + * minimap detector to identify the stage. Optional: without them the + * minimap event's `stage` stays null. + */ + plannerStages?: import("../minimap/stage").PlannerStage[] | null; } export const SCOREBOARD_EVENT_TYPE = "Scoreboard"; -export function createScoreboardDetector(resources: ScoreboardResources): Detector { - const cv = getCV(); - const teamDigits = - resources.teamDigits ?? - (resources.paintDigits - ? scaleGlyphSet(resources.paintDigits, TEAM_DIGIT_HEIGHT / PAINT_DIGIT_HEIGHT) - : null); +export function createScoreboardDetector( + resources: ScoreboardResources, +): Detector { + const cv = getCV(); + const teamDigits = + resources.teamDigits ?? + (resources.paintDigits + ? scaleGlyphSet( + resources.paintDigits, + TEAM_DIGIT_HEIGHT / PAINT_DIGIT_HEIGHT, + ) + : null); - function gate(frame: Mat): GateResult { - const gray = new cv.Mat(); - cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); + function gate(frame: Mat): GateResult { + const gray = new cv.Mat(); + cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); - let darkOk = 0; - let suffixOk = 0; - for (const cy of ROW_CENTERS) { - if (meanBrightness(frame, gateDarkProbe(cy)) < GATE_DARK_MAX_MEAN) darkOk++; - if (maxBrightness(gray, paintSuffixRoi(cy)) > GATE_TEXT_MIN_MAX) suffixOk++; - } - let panelOk = 0; - for (const roi of GATE_PANEL_PROBES) { - if (meanBrightness(frame, roi) < GATE_PANEL_MAX_MEAN) panelOk++; - } - gray.delete(); + let darkOk = 0; + let suffixOk = 0; + for (const cy of ROW_CENTERS) { + if (meanBrightness(frame, gateDarkProbe(cy)) < GATE_DARK_MAX_MEAN) + darkOk++; + if (maxBrightness(gray, paintSuffixRoi(cy)) > GATE_TEXT_MIN_MAX) + suffixOk++; + } + let panelOk = 0; + for (const roi of GATE_PANEL_PROBES) { + if (meanBrightness(frame, roi) < GATE_PANEL_MAX_MEAN) panelOk++; + } + gray.delete(); - const score = - (darkOk / ROW_CENTERS.length + - suffixOk / ROW_CENTERS.length + - panelOk / GATE_PANEL_PROBES.length) / - 3; - const pass = darkOk >= 7 && suffixOk >= 6 && panelOk >= 2; - return { pass, score }; - } + const score = + (darkOk / ROW_CENTERS.length + + suffixOk / ROW_CENTERS.length + + panelOk / GATE_PANEL_PROBES.length) / + 3; + const pass = darkOk >= 7 && suffixOk >= 6 && panelOk >= 2; + return { pass, score }; + } - function parse(frame: Mat, t: number): DetectedEvent[] { - const gray = new cv.Mat(); - cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); - const rgb = new cv.Mat(); - cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB); + function parse(frame: Mat, t: number): DetectedEvent[] { + const gray = new cv.Mat(); + cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); + const rgb = new cv.Mat(); + cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB); - const players: ScoreboardPlayer[] = []; - const rowDebug: ScoreboardRowDebug[] = []; - const confidences: number[] = []; + const players: ScoreboardPlayer[] = []; + const rowDebug: ScoreboardRowDebug[] = []; + const confidences: number[] = []; - const rowRois: RowRois = { - weapon: weaponRoi, - specialIcon: specialIconRoi, - paint: paintRoi, - name: nameRoi, - stat: statRoi, - povArrow: povArrowRoi, - }; - for (const cy of ROW_CENTERS) { - const row = parseScoreboardRow(gray, rgb, cy, rowRois, resources, confidences); - players.push(row.player); - rowDebug.push(row.debug); - } - const povIndex = findPovIndex(rowDebug.map((r) => r.povFraction)); + const rowRois: RowRois = { + weapon: weaponRoi, + specialIcon: specialIconRoi, + paint: paintRoi, + name: nameRoi, + stat: statRoi, + povArrow: povArrowRoi, + }; + for (const cy of ROW_CENTERS) { + const row = parseScoreboardRow( + gray, + rgb, + cy, + rowRois, + resources, + confidences, + ); + players.push(row.player); + rowDebug.push(row.debug); + } + const povIndex = findPovIndex(rowDebug.map((r) => r.povFraction)); - let header: ParsedHeader | null = null; - if (resources.headerLobbyGlyphs && resources.headerLineGlyphs) { - header = parseHeader(gray, resources.headerLobbyGlyphs, resources.headerLineGlyphs); - confidences.push(header.confidence); - } + let header: ParsedHeader | null = null; + if (resources.headerLobbyGlyphs && resources.headerLineGlyphs) { + header = parseHeader( + gray, + resources.headerLobbyGlyphs, + resources.headerLineGlyphs, + ); + confidences.push(header.confidence); + } - const scores: [number | null, number | null] = [null, null]; - const teamScoreConf: number[] = []; - if (teamDigits) { - for (const i of [0, 1] as const) { - // Team totals sit on the team-colored box (light swirl pattern), - // so binarize more aggressively than on the black pills. - const crop = cropRoi(gray, TEAM_SCORE_ROIS[i]); - const parsed = parseNumber(crop, teamDigits, { binThreshold: 175 }); - crop.delete(); - scores[i] = parsed.value; - teamScoreConf.push(parsed.confidence); - confidences.push(parsed.confidence); - } - } + const scores: [number | null, number | null] = [null, null]; + const teamScoreConf: number[] = []; + if (teamDigits) { + for (const i of [0, 1] as const) { + // Team totals sit on the team-colored box (light swirl pattern), + // so binarize more aggressively than on the black pills. + const crop = cropRoi(gray, TEAM_SCORE_ROIS[i]); + const parsed = parseNumber(crop, teamDigits, { binThreshold: 175 }); + crop.delete(); + scores[i] = parsed.value; + teamScoreConf.push(parsed.confidence); + confidences.push(parsed.confidence); + } + } - gray.delete(); - rgb.delete(); + gray.delete(); + rgb.delete(); - const confidence = - confidences.length > 0 ? confidences.reduce((a, b) => a + b, 0) / confidences.length : 0; + const confidence = + confidences.length > 0 + ? confidences.reduce((a, b) => a + b, 0) / confidences.length + : 0; - return [ - { - type: SCOREBOARD_EVENT_TYPE, - t, - confidence, - data: { - lobby: header?.lobby ?? null, - mode: header?.mode ?? null, - stage: header?.stage ?? null, - scores, - players, - povIndex, - }, - debug: { rows: rowDebug, teamScoreConf, header: header?.debug }, - }, - ]; - } + return [ + { + type: SCOREBOARD_EVENT_TYPE, + t, + confidence, + data: { + lobby: header?.lobby ?? null, + mode: header?.mode ?? null, + stage: header?.stage ?? null, + scores, + players, + povIndex, + }, + debug: { rows: rowDebug, teamScoreConf, header: header?.debug }, + }, + ]; + } - return { id: "scoreboard", gate, parse }; + return { id: "scoreboard", gate, parse }; } diff --git a/app/features/cv/core/detectors/scoreboard/kits.ts b/app/features/cv/core/detectors/scoreboard/kits.ts index 568480e5b..4705a6b48 100644 --- a/app/features/cv/core/detectors/scoreboard/kits.ts +++ b/app/features/cv/core/detectors/scoreboard/kits.ts @@ -7,18 +7,24 @@ * sub-tile disambiguation (assets/cv/sub-weapons) reads `sub` the same way. */ import { weaponParams } from "~/features/build-analyzer/data/weapon-params"; -import type { SpecialWeaponId, SubWeaponId } from "~/modules/in-game-lists/types"; +import type { + SpecialWeaponId, + SubWeaponId, +} from "~/modules/in-game-lists/types"; export interface WeaponKit { - /** sendou.ink sub weapon id */ - sub: SubWeaponId; - /** sendou.ink special weapon id, matching assets/cv/specials */ - special: SpecialWeaponId; + /** sendou.ink sub weapon id */ + sub: SubWeaponId; + /** sendou.ink special weapon id, matching assets/cv/specials */ + special: SpecialWeaponId; } export const WEAPON_KITS: ReadonlyMap = new Map( - Object.entries(weaponParams.weaponKits).map(([id, kit]) => [ - id, - { sub: kit.subWeaponId as SubWeaponId, special: kit.specialWeaponId as SpecialWeaponId }, - ]), + Object.entries(weaponParams.weaponKits).map(([id, kit]) => [ + id, + { + sub: kit.subWeaponId as SubWeaponId, + special: kit.specialWeaponId as SpecialWeaponId, + }, + ]), ); diff --git a/app/features/cv/core/detectors/scoreboard/names.ts b/app/features/cv/core/detectors/scoreboard/names.ts index b0474af74..e1d37fa01 100644 --- a/app/features/cv/core/detectors/scoreboard/names.ts +++ b/app/features/cv/core/detectors/scoreboard/names.ts @@ -3,17 +3,17 @@ */ import type { Mat } from "../../cv"; import { - type GlyphSet, - type RecognizedChar, - type RecognizedText, - recognizeText, + type GlyphSet, + type RecognizedChar, + type RecognizedText, + recognizeText, } from "../../glyphs"; export interface ParsedName { - name: string; - /** min glyph score; 0 when ink was present but nothing recognized */ - confidence: number; - raw: RecognizedText; + name: string; + /** min glyph score; 0 when ink was present but nothing recognized */ + confidence: number; + raw: RecognizedText; } /** @@ -30,36 +30,37 @@ export interface ParsedName { const BAR_CHARS = new Set(["I", "l", "|", "1"]); function normalizeBars(name: string): string { - const chars = [...name]; - // Context is the nearest NON-BAR char within the word: consecutive bars - // ("ll" in "Chill") must all resolve from the same real-letter neighbor, - // not from each other's arbitrary raw reading. Underscores bound words - // like spaces do — "gori_1"'s lowercase must not leak across the - // separator onto the suffix. - const neighbor = (i: number, step: -1 | 1): string | undefined => { - for (let j = i + step; j >= 0 && j < chars.length; j += step) { - const c = chars[j]!; - if (c === " " || c === "_") return undefined; - if (!BAR_CHARS.has(c)) return c; - } - return undefined; - }; - const test = (re: RegExp) => (c: string | undefined) => c !== undefined && re.test(c); - const isLower = test(/\p{Ll}/u); - const isDigit = test(/\d/); - const isUpper = test(/\p{Lu}/u); - return chars - .map((c, i) => { - if (!BAR_CHARS.has(c)) return c; - const left = neighbor(i, -1); - const right = neighbor(i, 1); - if (isLower(left) || isLower(right)) return "l"; - if (isDigit(left) || isDigit(right)) return "1"; - if (isUpper(left) || isUpper(right)) return "I"; - if (chars[i - 1] === "_" || chars[i + 1] === "_") return "1"; - return "l"; - }) - .join(""); + const chars = [...name]; + // Context is the nearest NON-BAR char within the word: consecutive bars + // ("ll" in "Chill") must all resolve from the same real-letter neighbor, + // not from each other's arbitrary raw reading. Underscores bound words + // like spaces do — "gori_1"'s lowercase must not leak across the + // separator onto the suffix. + const neighbor = (i: number, step: -1 | 1): string | undefined => { + for (let j = i + step; j >= 0 && j < chars.length; j += step) { + const c = chars[j]!; + if (c === " " || c === "_") return undefined; + if (!BAR_CHARS.has(c)) return c; + } + return undefined; + }; + const test = (re: RegExp) => (c: string | undefined) => + c !== undefined && re.test(c); + const isLower = test(/\p{Ll}/u); + const isDigit = test(/\d/); + const isUpper = test(/\p{Lu}/u); + return chars + .map((c, i) => { + if (!BAR_CHARS.has(c)) return c; + const left = neighbor(i, -1); + const right = neighbor(i, 1); + if (isLower(left) || isLower(right)) return "l"; + if (isDigit(left) || isDigit(right)) return "1"; + if (isUpper(left) || isUpper(right)) return "I"; + if (chars[i - 1] === "_" || chars[i + 1] === "_") return "1"; + return "l"; + }) + .join(""); } /** @@ -73,28 +74,29 @@ function normalizeBars(name: string): string { const LONG_BAR_CHARS = new Set(["-", "ー"]); function normalizeLongBars(name: string): string { - const chars = [...name]; - const neighbor = (i: number, step: -1 | 1): string | undefined => { - for (let j = i + step; j >= 0 && j < chars.length; j += step) { - const c = chars[j]!; - if (c === " " || c === "_") return undefined; - if (!LONG_BAR_CHARS.has(c)) return c; - } - return undefined; - }; - const test = (re: RegExp) => (c: string | undefined) => c !== undefined && re.test(c); - const isKana = test(/[ぁ-ヾ]/u); - const isLatinOrDigit = test(/[a-zA-Z0-9]/); - return chars - .map((c, i) => { - if (!LONG_BAR_CHARS.has(c)) return c; - const left = neighbor(i, -1); - const right = neighbor(i, 1); - if (isKana(left) || isKana(right)) return "ー"; - if (isLatinOrDigit(left) || isLatinOrDigit(right)) return "-"; - return c; - }) - .join(""); + const chars = [...name]; + const neighbor = (i: number, step: -1 | 1): string | undefined => { + for (let j = i + step; j >= 0 && j < chars.length; j += step) { + const c = chars[j]!; + if (c === " " || c === "_") return undefined; + if (!LONG_BAR_CHARS.has(c)) return c; + } + return undefined; + }; + const test = (re: RegExp) => (c: string | undefined) => + c !== undefined && re.test(c); + const isKana = test(/[ぁ-ヾ]/u); + const isLatinOrDigit = test(/[a-zA-Z0-9]/); + return chars + .map((c, i) => { + if (!LONG_BAR_CHARS.has(c)) return c; + const left = neighbor(i, -1); + const right = neighbor(i, 1); + if (isKana(left) || isKana(right)) return "ー"; + if (isLatinOrDigit(left) || isLatinOrDigit(right)) return "-"; + return c; + }) + .join(""); } /** @@ -106,32 +108,33 @@ function normalizeLongBars(name: string): string { * capitalized name ("Olise"). */ function normalizeOhs(name: string): string { - const chars = [...name]; - const ambiguous = (c: string | undefined) => c === "O" || c === "0"; - const neighbor = (i: number, step: -1 | 1): string | undefined => { - for (let j = i + step; j >= 0 && j < chars.length; j += step) { - const c = chars[j]!; - if (c === " ") return undefined; - if (!ambiguous(c)) return c; - } - return undefined; - }; - const test = (re: RegExp) => (c: string | undefined) => c !== undefined && re.test(c); - const isDigit = test(/\d/); - const isUpper = test(/\p{Lu}/u); - const isLower = test(/\p{Ll}/u); - return chars - .map((c, i) => { - if (!ambiguous(c)) return c; - const left = neighbor(i, -1); - const right = neighbor(i, 1); - if (isDigit(left) || isDigit(right)) return "0"; - if (isUpper(left) || isUpper(right)) return "O"; - if (isLower(left)) return "0"; - if (isLower(right)) return "O"; - return c; - }) - .join(""); + const chars = [...name]; + const ambiguous = (c: string | undefined) => c === "O" || c === "0"; + const neighbor = (i: number, step: -1 | 1): string | undefined => { + for (let j = i + step; j >= 0 && j < chars.length; j += step) { + const c = chars[j]!; + if (c === " ") return undefined; + if (!ambiguous(c)) return c; + } + return undefined; + }; + const test = (re: RegExp) => (c: string | undefined) => + c !== undefined && re.test(c); + const isDigit = test(/\d/); + const isUpper = test(/\p{Lu}/u); + const isLower = test(/\p{Ll}/u); + return chars + .map((c, i) => { + if (!ambiguous(c)) return c; + const left = neighbor(i, -1); + const right = neighbor(i, 1); + if (isDigit(left) || isDigit(right)) return "0"; + if (isUpper(left) || isUpper(right)) return "O"; + if (isLower(left)) return "0"; + if (isLower(right)) return "O"; + return c; + }) + .join(""); } /** @@ -147,21 +150,25 @@ const DOT_CHARS = new Set([".", "・", "·"]); const DOT_BASELINE_SLACK_PX = 3; function fixRaisedDots(raw: RecognizedText): RecognizedText { - if (!raw.chars.some((c) => c.char === ".")) return raw; - const anchors = raw.chars - .filter((c) => !DOT_CHARS.has(c.char)) - .map((c) => c.y1) - .sort((a, b) => a - b); - if (anchors.length === 0) return raw; - const baseline = anchors[Math.floor(anchors.length / 2)]!; - const chars = raw.chars.map((c) => { - if (c.char !== "." || baseline - c.y1 <= DOT_BASELINE_SLACK_PX) return c; - const alt = c.candidates?.find((k) => DOT_CHARS.has(k.char) && k.char !== "."); - return { ...c, char: alt?.char ?? "・" }; - }); - let ci = 0; - const text = [...raw.text].map((ch) => (ch === " " ? ch : chars[ci++]!.char)).join(""); - return { ...raw, text, chars }; + if (!raw.chars.some((c) => c.char === ".")) return raw; + const anchors = raw.chars + .filter((c) => !DOT_CHARS.has(c.char)) + .map((c) => c.y1) + .sort((a, b) => a - b); + if (anchors.length === 0) return raw; + const baseline = anchors[Math.floor(anchors.length / 2)]!; + const chars = raw.chars.map((c) => { + if (c.char !== "." || baseline - c.y1 <= DOT_BASELINE_SLACK_PX) return c; + const alt = c.candidates?.find( + (k) => DOT_CHARS.has(k.char) && k.char !== ".", + ); + return { ...c, char: alt?.char ?? "・" }; + }); + let ci = 0; + const text = [...raw.text] + .map((ch) => (ch === " " ? ch : chars[ci++]!.char)) + .join(""); + return { ...raw, text, chars }; } /** @@ -175,46 +182,48 @@ function fixRaisedDots(raw: RecognizedText): RecognizedText { * anchors the baseline. */ const DESCENDER_TWINS: Record = { - P: ["P", "p"], - p: ["P", "p"], + P: ["P", "p"], + p: ["P", "p"], }; const DESCENT_MIN_PX = 3; function resolveCaseByDescent(raw: RecognizedText): string { - if (!raw.chars.some((c) => c.char in DESCENDER_TWINS)) return raw.text; - const anchors = raw.chars - .filter((c) => !(c.char in DESCENDER_TWINS)) - .map((c) => c.y1) - .sort((a, b) => a - b); - if (anchors.length === 0) return raw.text; - const baseline = anchors[Math.floor(anchors.length / 2)]!; - let ci = 0; - return [...raw.text] - .map((ch) => { - if (ch === " ") return ch; - const rc: RecognizedChar = raw.chars[ci++]!; - const twin = DESCENDER_TWINS[rc.char]; - if (!twin) return rc.char; - return rc.y1 - baseline >= DESCENT_MIN_PX ? twin[1] : twin[0]; - }) - .join(""); + if (!raw.chars.some((c) => c.char in DESCENDER_TWINS)) return raw.text; + const anchors = raw.chars + .filter((c) => !(c.char in DESCENDER_TWINS)) + .map((c) => c.y1) + .sort((a, b) => a - b); + if (anchors.length === 0) return raw.text; + const baseline = anchors[Math.floor(anchors.length / 2)]!; + let ci = 0; + return [...raw.text] + .map((ch) => { + if (ch === " ") return ch; + const rc: RecognizedChar = raw.chars[ci++]!; + const twin = DESCENDER_TWINS[rc.char]; + if (!twin) return rc.char; + return rc.y1 - baseline >= DESCENT_MIN_PX ? twin[1] : twin[0]; + }) + .join(""); } export function parseName( - gray: Mat, - glyphs: GlyphSet, - options: { spaceGap?: number; binThreshold?: number } = {}, + gray: Mat, + glyphs: GlyphSet, + options: { spaceGap?: number; binThreshold?: number } = {}, ): ParsedName { - const raw = recognizeText(gray, glyphs, { - spaceGap: options.spaceGap ?? 7, - binThreshold: options.binThreshold, - minCharScore: 0.35, - }); - return { - name: normalizeLongBars( - normalizeOhs(normalizeBars(resolveCaseByDescent(fixRaisedDots(raw)).trim())), - ), - confidence: raw.confidence, - raw, - }; + const raw = recognizeText(gray, glyphs, { + spaceGap: options.spaceGap ?? 7, + binThreshold: options.binThreshold, + minCharScore: 0.35, + }); + return { + name: normalizeLongBars( + normalizeOhs( + normalizeBars(resolveCaseByDescent(fixRaisedDots(raw)).trim()), + ), + ), + confidence: raw.confidence, + raw, + }; } diff --git a/app/features/cv/core/detectors/scoreboard/pov.ts b/app/features/cv/core/detectors/scoreboard/pov.ts index 2ecb6ab48..dda76ebc6 100644 --- a/app/features/cv/core/detectors/scoreboard/pov.ts +++ b/app/features/cv/core/detectors/scoreboard/pov.ts @@ -25,29 +25,29 @@ const POV_RUNNER_UP_MAX_RATIO = 0.5; /** Fraction of `roi` pixels that are arrow-yellow. `rgb` is a 3-channel RGB mat. */ export function povYellowFraction(rgb: Mat, roi: Roi): number { - const cv = getCV(); - const view = cropRoi(rgb, roi); - const cont = new cv.Mat(); - view.copyTo(cont); - view.delete(); - const d = cont.data; - const n = cont.rows * cont.cols; - let yellow = 0; - for (let i = 0; i < n; i++) { - const r = d[i * 3]!; - const g = d[i * 3 + 1]!; - const b = d[i * 3 + 2]!; - if ( - r > YELLOW_R_MIN && - g > YELLOW_G_MIN && - b < YELLOW_B_MAX && - Math.abs(r - g) < YELLOW_RG_MAX_DIFF - ) { - yellow++; - } - } - cont.delete(); - return n > 0 ? yellow / n : 0; + const cv = getCV(); + const view = cropRoi(rgb, roi); + const cont = new cv.Mat(); + view.copyTo(cont); + view.delete(); + const d = cont.data; + const n = cont.rows * cont.cols; + let yellow = 0; + for (let i = 0; i < n; i++) { + const r = d[i * 3]!; + const g = d[i * 3 + 1]!; + const b = d[i * 3 + 2]!; + if ( + r > YELLOW_R_MIN && + g > YELLOW_G_MIN && + b < YELLOW_B_MAX && + Math.abs(r - g) < YELLOW_RG_MAX_DIFF + ) { + yellow++; + } + } + cont.delete(); + return n > 0 ? yellow / n : 0; } /** @@ -55,13 +55,17 @@ export function povYellowFraction(rgb: Mat, roi: Roi): number { * POV_MIN_FRACTION and stand clear of the runner-up; null means no arrow. */ export function findPovIndex(fractions: readonly number[]): number | null { - let best = -1; - for (let i = 0; i < fractions.length; i++) { - if (best < 0 || fractions[i]! > fractions[best]!) best = i; - } - if (best < 0 || fractions[best]! < POV_MIN_FRACTION) return null; - for (let i = 0; i < fractions.length; i++) { - if (i !== best && fractions[i]! > fractions[best]! * POV_RUNNER_UP_MAX_RATIO) return null; - } - return best; + let best = -1; + for (let i = 0; i < fractions.length; i++) { + if (best < 0 || fractions[i]! > fractions[best]!) best = i; + } + if (best < 0 || fractions[best]! < POV_MIN_FRACTION) return null; + for (let i = 0; i < fractions.length; i++) { + if ( + i !== best && + fractions[i]! > fractions[best]! * POV_RUNNER_UP_MAX_RATIO + ) + return null; + } + return best; } diff --git a/app/features/cv/core/detectors/scoreboard/rois.ts b/app/features/cv/core/detectors/scoreboard/rois.ts index c077afa1b..694c7e184 100644 --- a/app/features/cv/core/detectors/scoreboard/rois.ts +++ b/app/features/cv/core/detectors/scoreboard/rois.ts @@ -22,7 +22,7 @@ export const ROW_CENTERS = [416, 482, 547, 613, 770, 836, 901, 967] as const; * and intentionally excludes the larger replay-browser template sizes. */ export function weaponRoi(cy: number): Roi { - return { x: 1054, y: cy - 28, w: 67, h: 56 }; + return { x: 1054, y: cy - 28, w: 67, h: 56 }; } /** @@ -31,23 +31,23 @@ export function weaponRoi(cy: number): Roi { * region at the leftmost paint digit. */ export function nameRoi(cy: number): Roi { - return { x: 1122, y: cy - 14, w: 208, h: 32 }; + return { x: 1122, y: cy - 14, w: 208, h: 32 }; } /** Paint amount digits, right-aligned ending at x=1409 (the "p" suffix is excluded). */ export function paintRoi(cy: number): Roi { - return { x: 1325, y: cy - 17, w: 84, h: 34 }; + return { x: 1325, y: cy - 17, w: 84, h: 34 }; } /** The constant white "p" after the paint number — used as a gate anchor. */ export function paintSuffixRoi(cy: number): Roi { - return { x: 1409, y: cy - 14, w: 18, h: 28 }; + return { x: 1409, y: cy - 14, w: 18, h: 28 }; } /** Stat counter digits (two, zero-padded; the small "x" prefix at 1477/1540/1603 is excluded). */ export function statRoi(cy: number, index: 0 | 1 | 2): Roi { - const x = [1484, 1547, 1610][index]!; - return { x, y: cy + 1, w: 32, h: 23 }; + const x = [1484, 1547, 1610][index]!; + return { x, y: cy + 1, w: 32, h: 23 }; } /** @@ -56,7 +56,7 @@ export function statRoi(cy: number, index: 0 | 1 | 2): Roi { * below at cy+1 so the white counter digits stay out of the binarized shape. */ export function specialIconRoi(cy: number): Roi { - return { x: 1595, y: cy - 29, w: 40, h: 30 }; + return { x: 1595, y: cy - 29, w: 40, h: 30 }; } /** @@ -66,13 +66,13 @@ export function specialIconRoi(cy: number): Roi { * circle (~x 995) so yellow hair/gear can't leak in. */ export function povArrowRoi(cy: number): Roi { - return { x: 930, y: cy - 32, w: 58, h: 56 }; + return { x: 930, y: cy - 32, w: 58, h: 56 }; } /** Team score totals ("500 p"), larger digits, right-aligned ending at x=1658. */ export const TEAM_SCORE_ROIS: readonly [Roi, Roi] = [ - { x: 1530, y: 330, w: 132, h: 44 }, - { x: 1530, y: 684, w: 132, h: 44 }, + { x: 1530, y: 330, w: 132, h: 44 }, + { x: 1530, y: 684, w: 132, h: 44 }, ]; /** @@ -80,14 +80,14 @@ export const TEAM_SCORE_ROIS: readonly [Roi, Roi] = [ * first stat "x" (starts 1477) is always empty pill background (near-black). */ export function gateDarkProbe(cy: number): Roi { - return { x: 1434, y: cy - 10, w: 38, h: 20 }; + return { x: 1434, y: cy - 10, w: 38, h: 20 }; } /** Panel background probes (dark gray ~35) outside the team boxes. */ export const GATE_PANEL_PROBES: readonly Roi[] = [ - { x: 800, y: 500, w: 30, h: 30 }, - { x: 800, y: 900, w: 30, h: 30 }, - { x: 1770, y: 930, w: 30, h: 30 }, + { x: 800, y: 500, w: 30, h: 30 }, + { x: 800, y: 900, w: 30, h: 30 }, + { x: 1770, y: 930, w: 30, h: 30 }, ]; export const GATE_DARK_MAX_MEAN = 60; diff --git a/app/features/cv/core/detectors/scoreboard/row.ts b/app/features/cv/core/detectors/scoreboard/row.ts index 66fce3230..40cf8c1b6 100644 --- a/app/features/cv/core/detectors/scoreboard/row.ts +++ b/app/features/cv/core/detectors/scoreboard/row.ts @@ -15,129 +15,132 @@ import type { ScoreboardPlayer, ScoreboardRowDebug } from "./index"; import { parseName } from "./names"; import { povYellowFraction } from "./pov"; import { - disambiguateWeaponBySpecial, - matchSpecial, - type SpecialMatch, - type SpecialTemplate, - tiedWeaponsWithDistinctSpecials, + disambiguateWeaponBySpecial, + matchSpecial, + type SpecialMatch, + type SpecialTemplate, + tiedWeaponsWithDistinctSpecials, } from "./specials"; import { matchWeapon, type WeaponMatch, type WeaponTemplate } from "./weapons"; /** Per-row ROI geometry; the replay detector closes these over its panel dx. */ export interface RowRois { - weapon(cy: number): Roi; - specialIcon(cy: number): Roi; - paint(cy: number): Roi; - name(cy: number): Roi; - stat(cy: number, i: 0 | 1 | 2): Roi; - povArrow(cy: number): Roi; + weapon(cy: number): Roi; + specialIcon(cy: number): Roi; + paint(cy: number): Roi; + name(cy: number): Roi; + stat(cy: number, i: 0 | 1 | 2): Roi; + povArrow(cy: number): Roi; } export interface RowResources { - weapons: WeaponTemplate[]; - specials?: SpecialTemplate[] | null; - paintDigits: GlyphSet | null; - statDigits: GlyphSet | null; - nameGlyphs: GlyphSet | null; + weapons: WeaponTemplate[]; + specials?: SpecialTemplate[] | null; + paintDigits: GlyphSet | null; + statDigits: GlyphSet | null; + nameGlyphs: GlyphSet | null; } export interface RowOptions { - /** passed through to matchWeapon (replay rows sit on a lighter panel) */ - weaponInkThreshold?: number; - /** - * replay only: the left-aligned paint number puts the "p" suffix inside - * the ROI when the paint has fewer than 4 digits - */ - paintDropLoweredTrailing?: boolean; + /** passed through to matchWeapon (replay rows sit on a lighter panel) */ + weaponInkThreshold?: number; + /** + * replay only: the left-aligned paint number puts the "p" suffix inside + * the ROI when the paint has fewer than 4 digits + */ + paintDropLoweredTrailing?: boolean; } /** Parses one player row; per-field confidences append to `confidences`. */ export function parseScoreboardRow( - gray: Mat, - rgb: Mat, - cy: number, - rois: RowRois, - resources: RowResources, - confidences: number[], - options: RowOptions = {}, + gray: Mat, + rgb: Mat, + cy: number, + rois: RowRois, + resources: RowResources, + confidences: number[], + options: RowOptions = {}, ): { player: ScoreboardPlayer; debug: ScoreboardRowDebug } { - let weapon: WeaponMatch | null = null; - let special: SpecialMatch | undefined; - if (resources.weapons.length > 0) { - const crop = cropRoi(rgb, rois.weapon(cy)); - weapon = matchWeapon( - crop, - resources.weapons, - options.weaponInkThreshold !== undefined ? { inkThreshold: options.weaponInkThreshold } : {}, - ); - crop.delete(); - // near-tied icons with different kit specials: let the row's special - // icon break the tie - if (resources.specials?.length && tiedWeaponsWithDistinctSpecials(weapon)) { - const spCrop = cropRoi(rgb, rois.specialIcon(cy)); - special = matchSpecial(spCrop, resources.specials); - spCrop.delete(); - weapon = disambiguateWeaponBySpecial(weapon, special); - } - confidences.push(Math.max(0, weapon.score)); - } + let weapon: WeaponMatch | null = null; + let special: SpecialMatch | undefined; + if (resources.weapons.length > 0) { + const crop = cropRoi(rgb, rois.weapon(cy)); + weapon = matchWeapon( + crop, + resources.weapons, + options.weaponInkThreshold !== undefined + ? { inkThreshold: options.weaponInkThreshold } + : {}, + ); + crop.delete(); + // near-tied icons with different kit specials: let the row's special + // icon break the tie + if (resources.specials?.length && tiedWeaponsWithDistinctSpecials(weapon)) { + const spCrop = cropRoi(rgb, rois.specialIcon(cy)); + special = matchSpecial(spCrop, resources.specials); + spCrop.delete(); + weapon = disambiguateWeaponBySpecial(weapon, special); + } + confidences.push(Math.max(0, weapon.score)); + } - // paint (parse first so the name region can be trimmed at the digits) - let paint: ParsedNumber | null = null; - const pRoi = rois.paint(cy); - if (resources.paintDigits) { - const crop = cropRoi(gray, pRoi); - paint = parseNumber(crop, resources.paintDigits, { - dropLoweredTrailing: options.paintDropLoweredTrailing, - }); - crop.delete(); - confidences.push(paint.confidence); - } + // paint (parse first so the name region can be trimmed at the digits) + let paint: ParsedNumber | null = null; + const pRoi = rois.paint(cy); + if (resources.paintDigits) { + const crop = cropRoi(gray, pRoi); + paint = parseNumber(crop, resources.paintDigits, { + dropLoweredTrailing: options.paintDropLoweredTrailing, + }); + crop.delete(); + confidences.push(paint.confidence); + } - // name, trimmed at the leftmost paint digit - let name: ReturnType | null = null; - if (resources.nameGlyphs) { - const base = rois.name(cy); - const paintLeftAbs = paint && paint.leftX !== null ? pRoi.x + paint.leftX : pRoi.x + pRoi.w; - const w = Math.min(base.w, Math.max(0, paintLeftAbs - 6 - base.x)); - if (w > 8) { - const crop = cropRoi(gray, { ...base, w }); - name = parseName(crop, resources.nameGlyphs); - crop.delete(); - confidences.push(name.confidence); - } - } + // name, trimmed at the leftmost paint digit + let name: ReturnType | null = null; + if (resources.nameGlyphs) { + const base = rois.name(cy); + const paintLeftAbs = + paint && paint.leftX !== null ? pRoi.x + paint.leftX : pRoi.x + pRoi.w; + const w = Math.min(base.w, Math.max(0, paintLeftAbs - 6 - base.x)); + if (w > 8) { + const crop = cropRoi(gray, { ...base, w }); + name = parseName(crop, resources.nameGlyphs); + crop.delete(); + confidences.push(name.confidence); + } + } - // stat counters - const statValues: (number | null)[] = [null, null, null]; - const statScores: [number, number, number] = [0, 0, 0]; - if (resources.statDigits) { - for (const i of [0, 1, 2] as const) { - const crop = cropRoi(gray, rois.stat(cy, i)); - const parsed = parseNumber(crop, resources.statDigits); - crop.delete(); - statValues[i] = parsed.value; - statScores[i] = parsed.confidence; - confidences.push(parsed.confidence); - } - } + // stat counters + const statValues: (number | null)[] = [null, null, null]; + const statScores: [number, number, number] = [0, 0, 0]; + if (resources.statDigits) { + for (const i of [0, 1, 2] as const) { + const crop = cropRoi(gray, rois.stat(cy, i)); + const parsed = parseNumber(crop, resources.statDigits); + crop.delete(); + statValues[i] = parsed.value; + statScores[i] = parsed.confidence; + confidences.push(parsed.confidence); + } + } - return { - player: { - name: name?.name ?? "", - weaponId: weapon ? toMainWeaponId(weapon.id) : null, - paint: paint?.value ?? null, - ka: statValues[0] ?? null, - d: statValues[1] ?? null, - s: statValues[2] ?? null, - }, - debug: { - weapon, - special, - paintScore: paint?.confidence ?? 0, - nameScore: name?.confidence ?? 0, - statScores, - povFraction: povYellowFraction(rgb, rois.povArrow(cy)), - }, - }; + return { + player: { + name: name?.name ?? "", + weaponId: weapon ? toMainWeaponId(weapon.id) : null, + paint: paint?.value ?? null, + ka: statValues[0] ?? null, + d: statValues[1] ?? null, + s: statValues[2] ?? null, + }, + debug: { + weapon, + special, + paintScore: paint?.confidence ?? 0, + nameScore: name?.confidence ?? 0, + statScores, + povFraction: povYellowFraction(rgb, rois.povArrow(cy)), + }, + }; } diff --git a/app/features/cv/core/detectors/scoreboard/specials.ts b/app/features/cv/core/detectors/scoreboard/specials.ts index 1da31760f..60c098656 100644 --- a/app/features/cv/core/detectors/scoreboard/specials.ts +++ b/app/features/cv/core/detectors/scoreboard/specials.ts @@ -26,15 +26,15 @@ const SPECIAL_TEMPLATE_SIZES = [19, 21, 23, 25] as const; const SPECIAL_INK_THRESHOLD = 48; export interface SpecialTemplate { - id: string; - /** binary silhouette + ink pixel count at each templateSizes entry */ - sizes: { mat: Mat; ink: number }[]; + id: string; + /** binary silhouette + ink pixel count at each templateSizes entry */ + sizes: { mat: Mat; ink: number }[]; } export interface SpecialMatch { - id: string; - score: number; - top: { id: string; score: number }[]; + id: string; + score: number; + top: { id: string; score: number }[]; } /** @@ -44,104 +44,115 @@ export interface SpecialMatch { * minimap builds its sub-weapon set at the sub tile's larger sizes. */ export function prepareSpecialTemplates( - icons: { id: string; image: FrameData }[], - templateSizes: readonly number[] = SPECIAL_TEMPLATE_SIZES, + icons: { id: string; image: FrameData }[], + templateSizes: readonly number[] = SPECIAL_TEMPLATE_SIZES, ): SpecialTemplate[] { - const cv = getCV(); - return icons.map(({ id, image }) => { - const { width, height, data } = image; - let xMin = width; - let xMax = -1; - let yMin = height; - let yMax = -1; - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - if (data[(y * width + x) * 4 + 3]! > 128) { - if (x < xMin) xMin = x; - if (x > xMax) xMax = x; - if (y < yMin) yMin = y; - if (y > yMax) yMax = y; - } - } - } - const w = Math.max(1, xMax - xMin + 1); - const h = Math.max(1, yMax - yMin + 1); - const silhouette = new cv.Mat(h, w, cv.CV_8UC1, new cv.Scalar(0)); - const dst = silhouette.data; - for (let y = 0; y < h; y++) { - for (let x = 0; x < w; x++) { - if (data[((y + yMin) * width + x + xMin) * 4 + 3]! > 128) { - dst[y * w + x] = 255; - } - } - } - const sizes = templateSizes.map((size) => { - const scale = size / Math.max(w, h); - const resized = new cv.Mat(); - cv.resize( - silhouette, - resized, - new cv.Size(Math.max(1, Math.round(w * scale)), Math.max(1, Math.round(h * scale))), - 0, - 0, - cv.INTER_AREA, - ); - // re-binarize the interpolated edges so template and search region - // live on the same two-level scale - const mat = new cv.Mat(); - cv.threshold(resized, mat, 127, 255, cv.THRESH_BINARY); - resized.delete(); - let ink = 0; - for (const v of mat.data) if (v > 0) ink++; - return { mat, ink }; - }); - silhouette.delete(); - return { id, sizes }; - }); + const cv = getCV(); + return icons.map(({ id, image }) => { + const { width, height, data } = image; + let xMin = width; + let xMax = -1; + let yMin = height; + let yMax = -1; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (data[(y * width + x) * 4 + 3]! > 128) { + if (x < xMin) xMin = x; + if (x > xMax) xMax = x; + if (y < yMin) yMin = y; + if (y > yMax) yMax = y; + } + } + } + const w = Math.max(1, xMax - xMin + 1); + const h = Math.max(1, yMax - yMin + 1); + const silhouette = new cv.Mat(h, w, cv.CV_8UC1, new cv.Scalar(0)); + const dst = silhouette.data; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + if (data[((y + yMin) * width + x + xMin) * 4 + 3]! > 128) { + dst[y * w + x] = 255; + } + } + } + const sizes = templateSizes.map((size) => { + const scale = size / Math.max(w, h); + const resized = new cv.Mat(); + cv.resize( + silhouette, + resized, + new cv.Size( + Math.max(1, Math.round(w * scale)), + Math.max(1, Math.round(h * scale)), + ), + 0, + 0, + cv.INTER_AREA, + ); + // re-binarize the interpolated edges so template and search region + // live on the same two-level scale + const mat = new cv.Mat(); + cv.threshold(resized, mat, 127, 255, cv.THRESH_BINARY); + resized.delete(); + let ink = 0; + for (const v of mat.data) if (v > 0) ink++; + return { mat, ink }; + }); + silhouette.delete(); + return { id, sizes }; + }); } /** * searchRgb: RGB crop of the row's special-icon ROI (view is fine). * The region is binarized on max(r,g,b) so any ink tint reads as shape. */ -export function matchSpecial(searchRgb: Mat, templates: SpecialTemplate[]): SpecialMatch { - const cv = getCV(); +export function matchSpecial( + searchRgb: Mat, + templates: SpecialTemplate[], +): SpecialMatch { + const cv = getCV(); - // binarized copy of the search region (pixel access needs a copy) - const cont = new cv.Mat(); - searchRgb.copyTo(cont); - const { rows, cols } = cont; - const binary = new cv.Mat(rows, cols, cv.CV_8UC1, new cv.Scalar(0)); - const src = cont.data; - const dst = binary.data; - let searchInk = 0; - for (let i = 0; i < rows * cols; i++) { - const v = Math.max(src[i * 3]!, src[i * 3 + 1]!, src[i * 3 + 2]!); - if (v > SPECIAL_INK_THRESHOLD) { - dst[i] = 255; - searchInk++; - } - } - cont.delete(); + // binarized copy of the search region (pixel access needs a copy) + const cont = new cv.Mat(); + searchRgb.copyTo(cont); + const { rows, cols } = cont; + const binary = new cv.Mat(rows, cols, cv.CV_8UC1, new cv.Scalar(0)); + const src = cont.data; + const dst = binary.data; + let searchInk = 0; + for (let i = 0; i < rows * cols; i++) { + const v = Math.max(src[i * 3]!, src[i * 3 + 1]!, src[i * 3 + 2]!); + if (v > SPECIAL_INK_THRESHOLD) { + dst[i] = 255; + searchInk++; + } + } + cont.delete(); - const result = new cv.Mat(); - const ranked: { id: string; score: number }[] = []; - for (const template of templates) { - let score = -1; - for (const { mat, ink } of template.sizes) { - if (mat.rows > binary.rows || mat.cols > binary.cols) continue; - cv.matchTemplate(binary, mat, result, cv.TM_CCOEFF_NORMED); - const { maxVal } = minMaxLoc(result); - const r = Math.min(ink, searchInk) / Math.max(Math.max(ink, searchInk), 1); - const adjusted = maxVal * (0.75 + 0.25 * r); - if (adjusted > score) score = adjusted; - } - ranked.push({ id: template.id, score }); - } - result.delete(); - binary.delete(); - ranked.sort((a, b) => b.score - a.score); - return { id: ranked[0]?.id ?? "unknown", score: ranked[0]?.score ?? -1, top: ranked }; + const result = new cv.Mat(); + const ranked: { id: string; score: number }[] = []; + for (const template of templates) { + let score = -1; + for (const { mat, ink } of template.sizes) { + if (mat.rows > binary.rows || mat.cols > binary.cols) continue; + cv.matchTemplate(binary, mat, result, cv.TM_CCOEFF_NORMED); + const { maxVal } = minMaxLoc(result); + const r = + Math.min(ink, searchInk) / Math.max(Math.max(ink, searchInk), 1); + const adjusted = maxVal * (0.75 + 0.25 * r); + if (adjusted > score) score = adjusted; + } + ranked.push({ id: template.id, score }); + } + result.delete(); + binary.delete(); + ranked.sort((a, b) => b.score - a.score); + return { + id: ranked[0]?.id ?? "unknown", + score: ranked[0]?.score ?? -1, + top: ranked, + }; } /** Weapon-icon score gap under which two candidates count as a tie. */ @@ -158,8 +169,8 @@ const WEAPON_TIE_MARGIN = 0.04; * Symmetric pairs, extended per attested fixture need only. */ const ICON_TWINS: ReadonlyMap = new Map([ - ["0", "20"], // Sploosh-o-matic <-> Splash-o-matic - ["20", "0"], + ["0", "20"], // Sploosh-o-matic <-> Splash-o-matic + ["20", "0"], ]); /** @@ -179,34 +190,38 @@ type KitPart = "sub" | "special"; * decided (or the kit part can't). */ function tiedWeaponsWithDistinctKit( - match: WeaponMatch, - part: KitPart, + match: WeaponMatch, + part: KitPart, ): { id: string; score: number }[] | null { - const leader = match.top[0]; - if (!leader) return null; - const tied = match.top.filter((t) => leader.score - t.score < WEAPON_TIE_MARGIN); - const twinId = ICON_TWINS.get(leader.id); - if (twinId && !tied.some((t) => t.id === twinId)) { - const twin = match.top.find((t) => t.id === twinId); - if (twin) tied.push(twin); - } - if (tied.length < 2) return null; - const parts = new Set( - tied.map((t) => WEAPON_KITS.get(t.id)?.[part]).filter((s) => s !== undefined), - ); - return parts.size >= 2 ? tied : null; + const leader = match.top[0]; + if (!leader) return null; + const tied = match.top.filter( + (t) => leader.score - t.score < WEAPON_TIE_MARGIN, + ); + const twinId = ICON_TWINS.get(leader.id); + if (twinId && !tied.some((t) => t.id === twinId)) { + const twin = match.top.find((t) => t.id === twinId); + if (twin) tied.push(twin); + } + if (tied.length < 2) return null; + const parts = new Set( + tied + .map((t) => WEAPON_KITS.get(t.id)?.[part]) + .filter((s) => s !== undefined), + ); + return parts.size >= 2 ? tied : null; } export function tiedWeaponsWithDistinctSpecials( - match: WeaponMatch, + match: WeaponMatch, ): { id: string; score: number }[] | null { - return tiedWeaponsWithDistinctKit(match, "special"); + return tiedWeaponsWithDistinctKit(match, "special"); } export function tiedWeaponsWithDistinctSubs( - match: WeaponMatch, + match: WeaponMatch, ): { id: string; score: number }[] | null { - return tiedWeaponsWithDistinctKit(match, "sub"); + return tiedWeaponsWithDistinctKit(match, "sub"); } /** @@ -215,38 +230,47 @@ export function tiedWeaponsWithDistinctSubs( * kit entries all score alike keep the icon-match order. */ function disambiguateWeaponByKit( - match: WeaponMatch, - evidence: SpecialMatch, - part: KitPart, + match: WeaponMatch, + evidence: SpecialMatch, + part: KitPart, ): WeaponMatch { - const tied = tiedWeaponsWithDistinctKit(match, part); - if (!tied) return match; - // worst-possible floor: adjusted NCC never drops below -1 - const kitScore = (weaponId: string): number => { - const kit = WEAPON_KITS.get(weaponId); - if (!kit) return -1; - return evidence.top.find((t) => t.id === String(kit[part]))?.score ?? -1; - }; - const ranked = [...tied].sort((a, b) => kitScore(b.id) - kitScore(a.id)); - const winner = ranked[0]!; - if (winner.id === match.id) return match; - const runnerUp = ranked.find((t) => kitScore(t.id) < kitScore(winner.id)); - if (!runnerUp || kitScore(winner.id) - kitScore(runnerUp.id) < KIT_DECISION_MARGIN) { - return match; - } - const top = [winner, ...match.top.filter((t) => t.id !== winner.id)].slice(0, 3); - const flag: Partial = - part === "special" ? { specialResolved: true } : { subResolved: true }; - return { ...match, id: winner.id, score: winner.score, top, ...flag }; + const tied = tiedWeaponsWithDistinctKit(match, part); + if (!tied) return match; + // worst-possible floor: adjusted NCC never drops below -1 + const kitScore = (weaponId: string): number => { + const kit = WEAPON_KITS.get(weaponId); + if (!kit) return -1; + return evidence.top.find((t) => t.id === String(kit[part]))?.score ?? -1; + }; + const ranked = [...tied].sort((a, b) => kitScore(b.id) - kitScore(a.id)); + const winner = ranked[0]!; + if (winner.id === match.id) return match; + const runnerUp = ranked.find((t) => kitScore(t.id) < kitScore(winner.id)); + if ( + !runnerUp || + kitScore(winner.id) - kitScore(runnerUp.id) < KIT_DECISION_MARGIN + ) { + return match; + } + const top = [winner, ...match.top.filter((t) => t.id !== winner.id)].slice( + 0, + 3, + ); + const flag: Partial = + part === "special" ? { specialResolved: true } : { subResolved: true }; + return { ...match, id: winner.id, score: winner.score, top, ...flag }; } export function disambiguateWeaponBySpecial( - match: WeaponMatch, - special: SpecialMatch, + match: WeaponMatch, + special: SpecialMatch, ): WeaponMatch { - return disambiguateWeaponByKit(match, special, "special"); + return disambiguateWeaponByKit(match, special, "special"); } -export function disambiguateWeaponBySub(match: WeaponMatch, sub: SpecialMatch): WeaponMatch { - return disambiguateWeaponByKit(match, sub, "sub"); +export function disambiguateWeaponBySub( + match: WeaponMatch, + sub: SpecialMatch, +): WeaponMatch { + return disambiguateWeaponByKit(match, sub, "sub"); } diff --git a/app/features/cv/core/detectors/scoreboard/weapons.ts b/app/features/cv/core/detectors/scoreboard/weapons.ts index fd21ee647..4f58ae693 100644 --- a/app/features/cv/core/detectors/scoreboard/weapons.ts +++ b/app/features/cv/core/detectors/scoreboard/weapons.ts @@ -40,10 +40,10 @@ const INK_THRESHOLD = 40; * Map: scoped id -> unscoped id. */ const SCOPED_TWINS: ReadonlyMap = new Map([ - ["2040", "2030"], // Splat Scope -> Splat Charger - ["2041", "2031"], // Z+F variants - ["2070", "2060"], // E-liter 4K Scope -> E-liter 4K - ["2071", "2061"], // Custom variants + ["2040", "2030"], // Splat Scope -> Splat Charger + ["2041", "2031"], // Z+F variants + ["2070", "2060"], // E-liter 4K Scope -> E-liter 4K + ["2071", "2061"], // Custom variants ]); const TWIN_MARGIN = 0.05; @@ -53,40 +53,40 @@ const COARSE_SCALE = 0.25; const COARSE_SHORTLIST = 16; export interface TemplateSize { - mat: Mat; - ink: number; - /** the same template at COARSE_SCALE, for the coarse ranking pass */ - coarse: { mat: Mat; ink: number }; + mat: Mat; + ink: number; + /** the same template at COARSE_SCALE, for the coarse ranking pass */ + coarse: { mat: Mat; ink: number }; } export interface WeaponTemplate { - id: string; - /** RGB template + ink pixel count at each candidate size */ - sizes: TemplateSize[]; + id: string; + /** RGB template + ink pixel count at each candidate size */ + sizes: TemplateSize[]; } export interface WeaponMatch { - id: string; - score: number; - top: { id: string; score: number }[]; - /** true when a scoped/unscoped twin tie was resolved by the unscoped prior */ - twinAmbiguous?: boolean; - /** true when a near-tie was re-ranked by the row's special icon (specials.ts) */ - specialResolved?: boolean; - /** true when a near-tie was re-ranked by the minimap sub tile (specials.ts) */ - subResolved?: boolean; + id: string; + score: number; + top: { id: string; score: number }[]; + /** true when a scoped/unscoped twin tie was resolved by the unscoped prior */ + twinAmbiguous?: boolean; + /** true when a near-tie was re-ranked by the row's special icon (specials.ts) */ + specialResolved?: boolean; + /** true when a near-tie was re-ranked by the minimap sub tile (specials.ts) */ + subResolved?: boolean; } /** Icon-ink pixel count of a continuous RGB mat (NOT an ROI view). */ function countInkRgb(mat: Mat, threshold: number): number { - let ink = 0; - const d = mat.data; - const n = mat.rows * mat.cols; - for (let i = 0; i < n; i++) { - const v = Math.max(d[i * 3]!, d[i * 3 + 1]!, d[i * 3 + 2]!); - if (v > threshold) ink++; - } - return ink; + let ink = 0; + const d = mat.data; + const n = mat.rows * mat.cols; + for (let i = 0; i < n; i++) { + const v = Math.max(d[i * 3]!, d[i * 3 + 1]!, d[i * 3 + 2]!); + if (v > threshold) ink++; + } + return ink; } /** @@ -105,38 +105,45 @@ function countInkRgb(mat: Mat, threshold: number): number { * still compete when its art fits (see prepareWeaponTemplates cropToArt). */ export function buildTemplateSizes( - composited: Mat, - sizes: readonly number[], - inkThreshold: number, - referenceSize?: number, + composited: Mat, + sizes: readonly number[], + inkThreshold: number, + referenceSize?: number, ): TemplateSize[] { - const cv = getCV(); - return sizes.map((size) => { - const mat = new cv.Mat(); - if (referenceSize) { - const scale = size / referenceSize; - cv.resize( - composited, - mat, - new cv.Size( - Math.max(1, Math.round(composited.cols * scale)), - Math.max(1, Math.round(composited.rows * scale)), - ), - 0, - 0, - cv.INTER_AREA, - ); - } else { - cv.resize(composited, mat, new cv.Size(size, size), 0, 0, cv.INTER_AREA); - } - const coarseMat = new cv.Mat(); - cv.resize(mat, coarseMat, new cv.Size(0, 0), COARSE_SCALE, COARSE_SCALE, cv.INTER_AREA); - return { - mat, - ink: countInkRgb(mat, inkThreshold), - coarse: { mat: coarseMat, ink: countInkRgb(coarseMat, inkThreshold) }, - }; - }); + const cv = getCV(); + return sizes.map((size) => { + const mat = new cv.Mat(); + if (referenceSize) { + const scale = size / referenceSize; + cv.resize( + composited, + mat, + new cv.Size( + Math.max(1, Math.round(composited.cols * scale)), + Math.max(1, Math.round(composited.rows * scale)), + ), + 0, + 0, + cv.INTER_AREA, + ); + } else { + cv.resize(composited, mat, new cv.Size(size, size), 0, 0, cv.INTER_AREA); + } + const coarseMat = new cv.Mat(); + cv.resize( + mat, + coarseMat, + new cv.Size(0, 0), + COARSE_SCALE, + COARSE_SCALE, + cv.INTER_AREA, + ); + return { + mat, + ink: countInkRgb(mat, inkThreshold), + coarse: { mat: coarseMat, ink: countInkRgb(coarseMat, inkThreshold) }, + }; + }); } /** @@ -157,24 +164,33 @@ export function buildTemplateSizes( * 54px-tall box) can never match at the size actually on screen. */ export function prepareWeaponTemplates( - icons: { id: string; image: FrameData }[], - templateSizes: readonly number[] = WEAPON_TEMPLATE_SIZES, - options: { background?: number; inkThreshold?: number; cropToArt?: boolean } = {}, + icons: { id: string; image: FrameData }[], + templateSizes: readonly number[] = WEAPON_TEMPLATE_SIZES, + options: { + background?: number; + inkThreshold?: number; + cropToArt?: boolean; + } = {}, ): WeaponTemplate[] { - const cv = getCV(); - const background = options.background ?? PILL_BACKGROUND; - const inkThreshold = options.inkThreshold ?? INK_THRESHOLD; - return icons.map(({ id, image }) => { - const rgba = cv.matFromImageData(image as unknown as ImageData); - const source = options.cropToArt ? cropToAlphaBbox(rgba) : rgba; - const composited = compositeOnBackground(source, background); - if (source !== rgba) source.delete(); - const referenceSize = options.cropToArt ? rgba.cols : undefined; - rgba.delete(); - const sizes = buildTemplateSizes(composited, templateSizes, inkThreshold, referenceSize); - composited.delete(); - return { id, sizes }; - }); + const cv = getCV(); + const background = options.background ?? PILL_BACKGROUND; + const inkThreshold = options.inkThreshold ?? INK_THRESHOLD; + return icons.map(({ id, image }) => { + const rgba = cv.matFromImageData(image as unknown as ImageData); + const source = options.cropToArt ? cropToAlphaBbox(rgba) : rgba; + const composited = compositeOnBackground(source, background); + if (source !== rgba) source.delete(); + const referenceSize = options.cropToArt ? rgba.cols : undefined; + rgba.delete(); + const sizes = buildTemplateSizes( + composited, + templateSizes, + inkThreshold, + referenceSize, + ); + composited.delete(); + return { id, sizes }; + }); } /** Alpha threshold and margin (source px) for the cropToArt bounding box. */ @@ -182,54 +198,54 @@ const ART_ALPHA_MIN = 32; const ART_BBOX_MARGIN = 6; function cropToAlphaBbox(rgba: Mat): Mat { - const cv = getCV(); - const d = rgba.data; - const cols = rgba.cols; - const rows = rgba.rows; - let x0 = cols, - y0 = rows, - x1 = -1, - y1 = -1; - for (let y = 0; y < rows; y++) { - const rowBase = y * cols * 4 + 3; - for (let x = 0; x < cols; x++) { - if (d[rowBase + x * 4]! >= ART_ALPHA_MIN) { - if (x < x0) x0 = x; - if (x > x1) x1 = x; - if (y < y0) y0 = y; - if (y > y1) y1 = y; - } - } - } - if (x1 < 0) { - const copy = new cv.Mat(); - rgba.copyTo(copy); - return copy; - } - x0 = Math.max(0, x0 - ART_BBOX_MARGIN); - y0 = Math.max(0, y0 - ART_BBOX_MARGIN); - x1 = Math.min(cols - 1, x1 + ART_BBOX_MARGIN); - y1 = Math.min(rows - 1, y1 + ART_BBOX_MARGIN); - const view = rgba.roi(new cv.Rect(x0, y0, x1 - x0 + 1, y1 - y0 + 1)); - const out = new cv.Mat(); - view.copyTo(out); // ROI views: .data/.clone broken in this build, copy out - view.delete(); - return out; + const cv = getCV(); + const d = rgba.data; + const cols = rgba.cols; + const rows = rgba.rows; + let x0 = cols; + let y0 = rows; + let x1 = -1; + let y1 = -1; + for (let y = 0; y < rows; y++) { + const rowBase = y * cols * 4 + 3; + for (let x = 0; x < cols; x++) { + if (d[rowBase + x * 4]! >= ART_ALPHA_MIN) { + if (x < x0) x0 = x; + if (x > x1) x1 = x; + if (y < y0) y0 = y; + if (y > y1) y1 = y; + } + } + } + if (x1 < 0) { + const copy = new cv.Mat(); + rgba.copyTo(copy); + return copy; + } + x0 = Math.max(0, x0 - ART_BBOX_MARGIN); + y0 = Math.max(0, y0 - ART_BBOX_MARGIN); + x1 = Math.min(cols - 1, x1 + ART_BBOX_MARGIN); + y1 = Math.min(rows - 1, y1 + ART_BBOX_MARGIN); + const view = rgba.roi(new cv.Rect(x0, y0, x1 - x0 + 1, y1 - y0 + 1)); + const out = new cv.Mat(); + view.copyTo(out); // ROI views: .data/.clone broken in this build, copy out + view.delete(); + return out; } function compositeOnBackground(rgba: Mat, background: number): Mat { - const cv = getCV(); - const out = new cv.Mat(rgba.rows, rgba.cols, cv.CV_8UC3); - const src = rgba.data; - const dst = out.data; - const n = rgba.rows * rgba.cols; - for (let i = 0; i < n; i++) { - const a = src[i * 4 + 3]! / 255; - dst[i * 3] = Math.round(src[i * 4]! * a + background * (1 - a)); - dst[i * 3 + 1] = Math.round(src[i * 4 + 1]! * a + background * (1 - a)); - dst[i * 3 + 2] = Math.round(src[i * 4 + 2]! * a + background * (1 - a)); - } - return out; + const cv = getCV(); + const out = new cv.Mat(rgba.rows, rgba.cols, cv.CV_8UC3); + const src = rgba.data; + const dst = out.data; + const n = rgba.rows * rgba.cols; + for (let i = 0; i < n; i++) { + const a = src[i * 4 + 3]! / 255; + dst[i * 3] = Math.round(src[i * 4]! * a + background * (1 - a)); + dst[i * 3 + 1] = Math.round(src[i * 4 + 1]! * a + background * (1 - a)); + dst[i * 3 + 2] = Math.round(src[i * 4 + 2]! * a + background * (1 - a)); + } + return out; } /** @@ -241,47 +257,57 @@ function compositeOnBackground(rgba: Mat, background: number): Mat { * full set, which then skips the same templates and reports unknown). */ function coarseShortlist( - searchRgb: Mat, - templates: WeaponTemplate[], - inkThreshold: number, + searchRgb: Mat, + templates: WeaponTemplate[], + inkThreshold: number, ): Set | null { - const cv = getCV(); - const region = new cv.Mat(); - cv.resize(searchRgb, region, new cv.Size(0, 0), COARSE_SCALE, COARSE_SCALE, cv.INTER_AREA); - const searchInk = countInkRgb(region, inkThreshold); + const cv = getCV(); + const region = new cv.Mat(); + cv.resize( + searchRgb, + region, + new cv.Size(0, 0), + COARSE_SCALE, + COARSE_SCALE, + cv.INTER_AREA, + ); + const searchInk = countInkRgb(region, inkThreshold); - const result = new cv.Mat(); - const scored: { id: string; score: number }[] = []; - const searchRows = searchRgb.rows; - const searchCols = searchRgb.cols; - const regionRows = region.rows; - const regionCols = region.cols; - for (const template of templates) { - let score = -1; - for (const { mat, coarse } of template.sizes) { - // gate on the *full-res* dims so a size competes here iff it competes - // in the full pass, then also require the coarse pair to fit - if (mat.rows > searchRows || mat.cols > searchCols) continue; - if (coarse.mat.rows > regionRows || coarse.mat.cols > regionCols) continue; - cv.matchTemplate(region, coarse.mat, result, cv.TM_CCOEFF_NORMED); - const { maxVal } = minMaxLoc(result); - const r = Math.min(coarse.ink, searchInk) / Math.max(Math.max(coarse.ink, searchInk), 1); - const adjusted = maxVal * (0.75 + 0.25 * r); - if (adjusted > score) score = adjusted; - } - if (score > -1) scored.push({ id: template.id, score }); - } - result.delete(); - region.delete(); - if (scored.length === 0) return null; + const result = new cv.Mat(); + const scored: { id: string; score: number }[] = []; + const searchRows = searchRgb.rows; + const searchCols = searchRgb.cols; + const regionRows = region.rows; + const regionCols = region.cols; + for (const template of templates) { + let score = -1; + for (const { mat, coarse } of template.sizes) { + // gate on the *full-res* dims so a size competes here iff it competes + // in the full pass, then also require the coarse pair to fit + if (mat.rows > searchRows || mat.cols > searchCols) continue; + if (coarse.mat.rows > regionRows || coarse.mat.cols > regionCols) + continue; + cv.matchTemplate(region, coarse.mat, result, cv.TM_CCOEFF_NORMED); + const { maxVal } = minMaxLoc(result); + const r = + Math.min(coarse.ink, searchInk) / + Math.max(Math.max(coarse.ink, searchInk), 1); + const adjusted = maxVal * (0.75 + 0.25 * r); + if (adjusted > score) score = adjusted; + } + if (score > -1) scored.push({ id: template.id, score }); + } + result.delete(); + region.delete(); + if (scored.length === 0) return null; - scored.sort((a, b) => b.score - a.score); - const ids = new Set(scored.slice(0, COARSE_SHORTLIST).map((s) => s.id)); - for (const id of [...ids]) { - const twin = SCOPED_TWINS.get(id); - if (twin) ids.add(twin); - } - return ids; + scored.sort((a, b) => b.score - a.score); + const ids = new Set(scored.slice(0, COARSE_SHORTLIST).map((s) => s.id)); + for (const id of [...ids]) { + const twin = SCOPED_TWINS.get(id); + if (twin) ids.add(twin); + } + return ids; } /** @@ -292,61 +318,62 @@ function coarseShortlist( * as ink and the coverage penalty collapses. */ export function matchWeapon( - searchRgb: Mat, - templates: WeaponTemplate[], - options: { inkThreshold?: number } = {}, + searchRgb: Mat, + templates: WeaponTemplate[], + options: { inkThreshold?: number } = {}, ): WeaponMatch { - const cv = getCV(); - const inkThreshold = options.inkThreshold ?? INK_THRESHOLD; + const cv = getCV(); + const inkThreshold = options.inkThreshold ?? INK_THRESHOLD; - // icon ink present in the search region (pixel access needs a copy) - const cont = new cv.Mat(); - searchRgb.copyTo(cont); - const searchInk = countInkRgb(cont, inkThreshold); - cont.delete(); + // icon ink present in the search region (pixel access needs a copy) + const cont = new cv.Mat(); + searchRgb.copyTo(cont); + const searchInk = countInkRgb(cont, inkThreshold); + cont.delete(); - // shortlist large sets at coarse resolution first; below ~2x the shortlist - // size the coarse pass costs more calls than it saves - let pool = templates; - if (templates.length > COARSE_SHORTLIST * 2) { - const ids = coarseShortlist(searchRgb, templates, inkThreshold); - if (ids) pool = templates.filter((t) => ids.has(t.id)); - } + // shortlist large sets at coarse resolution first; below ~2x the shortlist + // size the coarse pass costs more calls than it saves + let pool = templates; + if (templates.length > COARSE_SHORTLIST * 2) { + const ids = coarseShortlist(searchRgb, templates, inkThreshold); + if (ids) pool = templates.filter((t) => ids.has(t.id)); + } - const result = new cv.Mat(); - const best = new Map(); - const searchRows = searchRgb.rows; - const searchCols = searchRgb.cols; - for (const template of pool) { - let score = -1; - for (const { mat, ink } of template.sizes) { - if (mat.rows > searchRows || mat.cols > searchCols) continue; - cv.matchTemplate(searchRgb, mat, result, cv.TM_CCOEFF_NORMED); - const { maxVal } = minMaxLoc(result); - const r = Math.min(ink, searchInk) / Math.max(Math.max(ink, searchInk), 1); - const adjusted = maxVal * (0.75 + 0.25 * r); - if (adjusted > score) score = adjusted; - } - best.set(template.id, score); - } - result.delete(); - const ranked = [...best.entries()] - .map(([id, score]) => ({ id, score })) - .sort((a, b) => b.score - a.score); - const top = ranked.slice(0, 3); - let first = top[0] ?? { id: "unknown", score: -1 }; - let twinAmbiguous = false; - const unscopedId = SCOPED_TWINS.get(first.id); - if (unscopedId) { - const twin = ranked.find((r) => r.id === unscopedId); - if (twin && first.score - twin.score < TWIN_MARGIN) { - twinAmbiguous = true; - first = twin; - const i = top.findIndex((t) => t.id === twin.id); - if (i >= 0) top.splice(i, 1); - top.unshift(twin); - top.length = Math.min(top.length, 3); - } - } - return { id: first.id, score: first.score, top, twinAmbiguous }; + const result = new cv.Mat(); + const best = new Map(); + const searchRows = searchRgb.rows; + const searchCols = searchRgb.cols; + for (const template of pool) { + let score = -1; + for (const { mat, ink } of template.sizes) { + if (mat.rows > searchRows || mat.cols > searchCols) continue; + cv.matchTemplate(searchRgb, mat, result, cv.TM_CCOEFF_NORMED); + const { maxVal } = minMaxLoc(result); + const r = + Math.min(ink, searchInk) / Math.max(Math.max(ink, searchInk), 1); + const adjusted = maxVal * (0.75 + 0.25 * r); + if (adjusted > score) score = adjusted; + } + best.set(template.id, score); + } + result.delete(); + const ranked = [...best.entries()] + .map(([id, score]) => ({ id, score })) + .sort((a, b) => b.score - a.score); + const top = ranked.slice(0, 3); + let first = top[0] ?? { id: "unknown", score: -1 }; + let twinAmbiguous = false; + const unscopedId = SCOPED_TWINS.get(first.id); + if (unscopedId) { + const twin = ranked.find((r) => r.id === unscopedId); + if (twin && first.score - twin.score < TWIN_MARGIN) { + twinAmbiguous = true; + first = twin; + const i = top.findIndex((t) => t.id === twin.id); + if (i >= 0) top.splice(i, 1); + top.unshift(twin); + top.length = Math.min(top.length, 3); + } + } + return { id: first.id, score: first.score, top, twinAmbiguous }; } diff --git a/app/features/cv/core/detectors/suppressor.ts b/app/features/cv/core/detectors/suppressor.ts index 4eb0b966c..2f653d6fb 100644 --- a/app/features/cv/core/detectors/suppressor.ts +++ b/app/features/cv/core/detectors/suppressor.ts @@ -11,62 +11,73 @@ */ export interface SuppressorOptions { - /** consecutive non-improving parses tolerated before suppression kicks in */ - maxStagnantParses: number; - /** minimum confidence gain that counts as an improvement */ - minImprovement: number; + /** consecutive non-improving parses tolerated before suppression kicks in */ + maxStagnantParses: number; + /** minimum confidence gain that counts as an improvement */ + minImprovement: number; } const DEFAULT_SUPPRESSOR_OPTIONS: SuppressorOptions = { - // at the 2fps sample rate: give a stable screen ~3s to produce its best - // read, then stop paying for parses until the screen changes - maxStagnantParses: 6, - minImprovement: 0.001, + // at the 2fps sample rate: give a stable screen ~3s to produce its best + // read, then stop paying for parses until the screen changes + maxStagnantParses: 6, + minImprovement: 0.001, }; interface StreakState { - best: number; - stagnant: number; - suppressed: boolean; + best: number; + stagnant: number; + suppressed: boolean; } export class ParseSuppressor { - #options: SuppressorOptions; - #streaks = new Map(); + #options: SuppressorOptions; + #streaks = new Map(); - constructor(options: Partial = {}) { - this.#options = { ...DEFAULT_SUPPRESSOR_OPTIONS, ...options }; - } + constructor(options: Partial = {}) { + this.#options = { ...DEFAULT_SUPPRESSOR_OPTIONS, ...options }; + } - /** - * Call once per detector per frame with the gate outcome; returns whether - * parse() should run. A failed gate ends the streak, so the next gate pass - * starts a fresh, unsuppressed streak. - */ - shouldParse(detectorId: string, gatePass: boolean): boolean { - if (!gatePass) { - this.#streaks.delete(detectorId); - return false; - } - return !this.#streaks.get(detectorId)?.suppressed; - } + /** + * Call once per detector per frame with the gate outcome; returns whether + * parse() should run. A failed gate ends the streak, so the next gate pass + * starts a fresh, unsuppressed streak. + */ + shouldParse(detectorId: string, gatePass: boolean): boolean { + if (!gatePass) { + this.#streaks.delete(detectorId); + return false; + } + return !this.#streaks.get(detectorId)?.suppressed; + } - /** Report the outcome of a parse this suppressor approved. */ - recordParse(detectorId: string, events: readonly { confidence: number }[]): void { - // no events counts as confidence 0: a false-firing gate on a static - // screen stagnates and gets suppressed just like a parsed one - const confidence = events.reduce((max, e) => Math.max(max, e.confidence), 0); - const streak = this.#streaks.get(detectorId); - if (!streak) { - this.#streaks.set(detectorId, { best: confidence, stagnant: 0, suppressed: false }); - return; - } - if (confidence > streak.best + this.#options.minImprovement) { - streak.best = confidence; - streak.stagnant = 0; - return; - } - streak.stagnant += 1; - if (streak.stagnant >= this.#options.maxStagnantParses) streak.suppressed = true; - } + /** Report the outcome of a parse this suppressor approved. */ + recordParse( + detectorId: string, + events: readonly { confidence: number }[], + ): void { + // no events counts as confidence 0: a false-firing gate on a static + // screen stagnates and gets suppressed just like a parsed one + const confidence = events.reduce( + (max, e) => Math.max(max, e.confidence), + 0, + ); + const streak = this.#streaks.get(detectorId); + if (!streak) { + this.#streaks.set(detectorId, { + best: confidence, + stagnant: 0, + suppressed: false, + }); + return; + } + if (confidence > streak.best + this.#options.minImprovement) { + streak.best = confidence; + streak.stagnant = 0; + return; + } + streak.stagnant += 1; + if (streak.stagnant >= this.#options.maxStagnantParses) + streak.suppressed = true; + } } diff --git a/app/features/cv/core/detectors/types.ts b/app/features/cv/core/detectors/types.ts index 0d3f2e5a5..a57d3a5dc 100644 --- a/app/features/cv/core/detectors/types.ts +++ b/app/features/cv/core/detectors/types.ts @@ -1,26 +1,26 @@ import type { Mat } from "../cv"; export interface DetectedEvent { - type: string; - /** seconds into the stream/video */ - t: number; - /** 0..1 aggregate confidence; TimelineBuilder drops events below threshold */ - confidence: number; - data: TData; - /** per-field match scores etc. for the harness/debugging; not persisted upstream */ - debug?: Record; + type: string; + /** seconds into the stream/video */ + t: number; + /** 0..1 aggregate confidence; TimelineBuilder drops events below threshold */ + confidence: number; + data: TData; + /** per-field match scores etc. for the harness/debugging; not persisted upstream */ + debug?: Record; } export interface GateResult { - pass: boolean; - /** raw score of the gate check, for tuning */ - score: number; - /** - * which screen variant the gate recognized, for detectors that gate more - * than one (minimap: "overlay" | "spectator") — parse() branches on it - * instead of re-running the gate probes - */ - variant?: string; + pass: boolean; + /** raw score of the gate check, for tuning */ + score: number; + /** + * which screen variant the gate recognized, for detectors that gate more + * than one (minimap: "overlay" | "spectator") — parse() branches on it + * instead of re-running the gate probes + */ + variant?: string; } /** @@ -31,7 +31,7 @@ export interface GateResult { * whatever it needs) so one-shot tools can call parse() directly. */ export interface Detector { - id: string; - gate(frame: Mat): GateResult; - parse(frame: Mat, t: number, gate?: GateResult): DetectedEvent[]; + id: string; + gate(frame: Mat): GateResult; + parse(frame: Mat, t: number, gate?: GateResult): DetectedEvent[]; } diff --git a/app/features/cv/core/glyphs.ts b/app/features/cv/core/glyphs.ts index 1c5875722..df1ce1418 100644 --- a/app/features/cv/core/glyphs.ts +++ b/app/features/cv/core/glyphs.ts @@ -24,269 +24,279 @@ import { getCV, type Mat } from "./cv"; import type { FrameData } from "./image"; interface AtlasGlyphMeta { - char: string; - x: number; - y: number; - w: number; - h: number; - /** - * Where the template came from. Atlases are hybrids: "fixture" glyphs are - * exact pixel crops from labeled frames (highest fidelity, sparse - * coverage), "font" glyphs are rendered from the game fonts (full - * coverage, slightly off in-game rendering). Recognition scores every - * glyph and takes the max, so fixture glyphs win where they exist. - */ - source?: "fixture" | "font"; + char: string; + x: number; + y: number; + w: number; + h: number; + /** + * Where the template came from. Atlases are hybrids: "fixture" glyphs are + * exact pixel crops from labeled frames (highest fidelity, sparse + * coverage), "font" glyphs are rendered from the game fonts (full + * coverage, slightly off in-game rendering). Recognition scores every + * glyph and takes the max, so fixture glyphs win where they exist. + */ + source?: "fixture" | "font"; } export interface AtlasMeta { - /** nominal glyph height in canonical-1080p pixels */ - height: number; - glyphs: AtlasGlyphMeta[]; + /** nominal glyph height in canonical-1080p pixels */ + height: number; + glyphs: AtlasGlyphMeta[]; } interface Glyph { - char: string; - /** grayscale white-on-black, tight box */ - mat: Mat; - /** count of pixels above the binarization threshold */ - ink: number; - /** exact fixture crop vs font-rendered approximation */ - source: "fixture" | "font"; + char: string; + /** grayscale white-on-black, tight box */ + mat: Mat; + /** count of pixels above the binarization threshold */ + ink: number; + /** exact fixture crop vs font-rendered approximation */ + source: "fixture" | "font"; } export interface GlyphSet { - glyphs: Glyph[]; - height: number; - /** median tight-box glyph width, used for split/space heuristics */ - medianWidth: number; + glyphs: Glyph[]; + height: number; + /** median tight-box glyph width, used for split/space heuristics */ + medianWidth: number; } const TEMPLATE_BIN_THRESHOLD = 128; /** Slice glyph templates out of an atlas image using its metadata. */ export function loadGlyphSet(atlas: FrameData, meta: AtlasMeta): GlyphSet { - const cv = getCV(); - const full = cv.matFromImageData(atlas as unknown as ImageData); - const gray = new cv.Mat(); - cv.cvtColor(full, gray, cv.COLOR_RGBA2GRAY); - full.delete(); + const cv = getCV(); + const full = cv.matFromImageData(atlas as unknown as ImageData); + const gray = new cv.Mat(); + cv.cvtColor(full, gray, cv.COLOR_RGBA2GRAY); + full.delete(); - const glyphs: Glyph[] = meta.glyphs.map((g) => { - // NB: .data/.clone() are broken on ROI views in this opencv.js build; - // always copyTo into a fresh mat before pixel access. - const view = gray.roi(new cv.Rect(g.x, g.y, g.w, g.h)); - const cell = new cv.Mat(); - view.copyTo(cell); - view.delete(); - const mat = tightCropGray(cell, TEMPLATE_BIN_THRESHOLD); - cell.delete(); - let ink = 0; - for (const v of mat.data) if (v > TEMPLATE_BIN_THRESHOLD) ink++; - // untagged glyphs predate hybrid atlases and were all fixture crops - return { char: g.char, mat, ink, source: g.source ?? "fixture" }; - }); - gray.delete(); - const widths = glyphs.map((g) => g.mat.cols).sort((a, b) => a - b); - const medianWidth = widths[Math.floor(widths.length / 2)] ?? 8; - return { glyphs, height: meta.height, medianWidth }; + const glyphs: Glyph[] = meta.glyphs.map((g) => { + // NB: .data/.clone() are broken on ROI views in this opencv.js build; + // always copyTo into a fresh mat before pixel access. + const view = gray.roi(new cv.Rect(g.x, g.y, g.w, g.h)); + const cell = new cv.Mat(); + view.copyTo(cell); + view.delete(); + const mat = tightCropGray(cell, TEMPLATE_BIN_THRESHOLD); + cell.delete(); + let ink = 0; + for (const v of mat.data) if (v > TEMPLATE_BIN_THRESHOLD) ink++; + // untagged glyphs predate hybrid atlases and were all fixture crops + return { char: g.char, mat, ink, source: g.source ?? "fixture" }; + }); + gray.delete(); + const widths = glyphs.map((g) => g.mat.cols).sort((a, b) => a - b); + const medianWidth = widths[Math.floor(widths.length / 2)] ?? 8; + return { glyphs, height: meta.height, medianWidth }; } /** Resize every glyph by `factor` (e.g. to reuse paint digits for team scores). */ export function scaleGlyphSet(set: GlyphSet, factor: number): GlyphSet { - const cv = getCV(); - const glyphs = set.glyphs.map((g) => { - const mat = new cv.Mat(); - cv.resize(g.mat, mat, new cv.Size(0, 0), factor, factor, cv.INTER_CUBIC); - let ink = 0; - for (const v of mat.data) if (v > TEMPLATE_BIN_THRESHOLD) ink++; - return { char: g.char, mat, ink, source: g.source }; - }); - return { - glyphs, - height: Math.round(set.height * factor), - medianWidth: Math.round(set.medianWidth * factor), - }; + const cv = getCV(); + const glyphs = set.glyphs.map((g) => { + const mat = new cv.Mat(); + cv.resize(g.mat, mat, new cv.Size(0, 0), factor, factor, cv.INTER_CUBIC); + let ink = 0; + for (const v of mat.data) if (v > TEMPLATE_BIN_THRESHOLD) ink++; + return { char: g.char, mat, ink, source: g.source }; + }); + return { + glyphs, + height: Math.round(set.height * factor), + medianWidth: Math.round(set.medianWidth * factor), + }; } /** Crop a grayscale mat to the tight bounds of its above-threshold pixels. */ function tightCropGray(gray: Mat, threshold: number): Mat { - const cv = getCV(); - const { cols, rows, data } = gray; - let xMin = cols; - let xMax = -1; - let yMin = rows; - let yMax = -1; - for (let y = 0; y < rows; y++) { - for (let x = 0; x < cols; x++) { - if (data[y * cols + x]! > threshold) { - if (x < xMin) xMin = x; - if (x > xMax) xMax = x; - if (y < yMin) yMin = y; - if (y > yMax) yMax = y; - } - } - } - const out = new cv.Mat(); - if (xMax < 0) { - gray.copyTo(out); - return out; - } - const view = gray.roi(new cv.Rect(xMin, yMin, xMax - xMin + 1, yMax - yMin + 1)); - view.copyTo(out); - view.delete(); - return out; + const cv = getCV(); + const { cols, rows, data } = gray; + let xMin = cols; + let xMax = -1; + let yMin = rows; + let yMax = -1; + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + if (data[y * cols + x]! > threshold) { + if (x < xMin) xMin = x; + if (x > xMax) xMax = x; + if (y < yMin) yMin = y; + if (y > yMax) yMax = y; + } + } + } + const out = new cv.Mat(); + if (xMax < 0) { + gray.copyTo(out); + return out; + } + const view = gray.roi( + new cv.Rect(xMin, yMin, xMax - xMin + 1, yMax - yMin + 1), + ); + view.copyTo(out); + view.delete(); + return out; } export interface RecognizedChar { - char: string; - score: number; - /** segment bounds relative to the crop */ - x0: number; - x1: number; - /** segment ink extent relative to the crop (y1 exclusive), for baseline checks */ - y0: number; - y1: number; - /** runner-up candidates for this segment (debugging/tuning aid) */ - candidates?: { - char: string; - score: number; - ncc: number; - source: "fixture" | "font"; - }[]; + char: string; + score: number; + /** segment bounds relative to the crop */ + x0: number; + x1: number; + /** segment ink extent relative to the crop (y1 exclusive), for baseline checks */ + y0: number; + y1: number; + /** runner-up candidates for this segment (debugging/tuning aid) */ + candidates?: { + char: string; + score: number; + ncc: number; + source: "fixture" | "font"; + }[]; } export interface RecognizedText { - text: string; - chars: RecognizedChar[]; - /** min char score (0 when ink was present but nothing was recognized) */ - confidence: number; + text: string; + chars: RecognizedChar[]; + /** min char score (0 when ink was present but nothing was recognized) */ + confidence: number; } export interface RecognizeOptions { - /** binarization threshold for segmentation/masking (white text) */ - binThreshold?: number; - /** min white pixels for a column to count as ink */ - minColumnPixels?: number; - /** gaps wider than this emit a space; Infinity disables spaces */ - spaceGap?: number; - /** discard chars scoring below this */ - minCharScore?: number; + /** binarization threshold for segmentation/masking (white text) */ + binThreshold?: number; + /** min white pixels for a column to count as ink */ + minColumnPixels?: number; + /** gaps wider than this emit a space; Infinity disables spaces */ + spaceGap?: number; + /** discard chars scoring below this */ + minCharScore?: number; } interface Segment { - x0: number; - x1: number; + x0: number; + x1: number; } function columnProfile(binary: Mat): number[] { - const { cols, rows, data } = binary; - const profile = new Array(cols).fill(0); - for (let y = 0; y < rows; y++) { - for (let x = 0; x < cols; x++) { - if (data[y * cols + x]! > 0) profile[x]!++; - } - } - return profile; + const { cols, rows, data } = binary; + const profile = new Array(cols).fill(0); + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + if (data[y * cols + x]! > 0) profile[x]!++; + } + } + return profile; } function segmentColumns(profile: number[], minColumnPixels: number): Segment[] { - const segments: Segment[] = []; - let start = -1; - for (let x = 0; x < profile.length; x++) { - const on = profile[x]! > minColumnPixels; - if (on && start < 0) start = x; - if (!on && start >= 0) { - if (x - start >= 2) segments.push({ x0: start, x1: x }); - start = -1; - } - } - if (start >= 0 && profile.length - start >= 2) { - segments.push({ x0: start, x1: profile.length }); - } - return segments; + const segments: Segment[] = []; + let start = -1; + for (let x = 0; x < profile.length; x++) { + const on = profile[x]! > minColumnPixels; + if (on && start < 0) start = x; + if (!on && start >= 0) { + if (x - start >= 2) segments.push({ x0: start, x1: x }); + start = -1; + } + } + if (start >= 0 && profile.length - start >= 2) { + segments.push({ x0: start, x1: profile.length }); + } + return segments; } /** Distance from `w` to the nearest positive multiple of `unit`. */ function widthError(w: number, unit: number): number { - const n = Math.max(1, Math.round(w / unit)); - return Math.abs(w - n * unit); + const n = Math.max(1, Math.round(w / unit)); + return Math.abs(w - n * unit); } -function splitWideSegment(profile: number[], seg: Segment, medianWidth: number): Segment[] { - const maxCharWidth = Math.round(medianWidth * 1.5); - if (seg.x1 - seg.x0 <= maxCharWidth) return [seg]; - // Pick the split column by dip depth *relative to the peaks on both sides*, - // not by raw minimum: the raw minimum can land inside a thin-but-real stroke - // (a 'T' top bar profiles as low as the blurred gap to the next glyph), - // while the true boundary is the column lowest relative to its neighbours. - const leftMax = new Array(seg.x1).fill(0); - const rightMax = new Array(seg.x1).fill(0); - let running = 0; - for (let i = seg.x0; i < seg.x1; i++) { - leftMax[i] = running; - running = Math.max(running, profile[i]!); - } - running = 0; - for (let i = seg.x1 - 1; i >= seg.x0; i--) { - rightMax[i] = running; - running = Math.max(running, profile[i]!); - } - let best = -1; - let bestRatio = Infinity; - for (let i = seg.x0 + 3; i < seg.x1 - 3; i++) { - const ratio = profile[i]! / Math.max(1, Math.min(leftMax[i]!, rightMax[i]!)); - if (ratio < bestRatio) { - bestRatio = ratio; - best = i; - } - } - if (best < 0 || bestRatio > 0.45) return [seg]; - // The dip is often a plateau of equally-low columns (a 'T' arm blurring - // into the next glyph profiles no higher than the gap itself), and the - // profile cannot say which glyph the plateau's ink belongs to. Cut at the - // plateau edge that leaves the left part closest to whole glyph widths. - let lo = best; - let hi = best; - while (lo - 1 >= seg.x0 + 3 && profile[lo - 1]! <= profile[best]!) lo--; - while (hi + 1 < seg.x1 - 3 && profile[hi + 1]! <= profile[best]!) hi++; - const cut = - widthError(lo - seg.x0, medianWidth) <= widthError(hi + 1 - seg.x0, medianWidth) ? lo : hi + 1; - return [ - ...splitWideSegment(profile, { x0: seg.x0, x1: cut }, medianWidth), - ...splitWideSegment(profile, { x0: cut, x1: seg.x1 }, medianWidth), - ]; +function splitWideSegment( + profile: number[], + seg: Segment, + medianWidth: number, +): Segment[] { + const maxCharWidth = Math.round(medianWidth * 1.5); + if (seg.x1 - seg.x0 <= maxCharWidth) return [seg]; + // Pick the split column by dip depth *relative to the peaks on both sides*, + // not by raw minimum: the raw minimum can land inside a thin-but-real stroke + // (a 'T' top bar profiles as low as the blurred gap to the next glyph), + // while the true boundary is the column lowest relative to its neighbours. + const leftMax = new Array(seg.x1).fill(0); + const rightMax = new Array(seg.x1).fill(0); + let running = 0; + for (let i = seg.x0; i < seg.x1; i++) { + leftMax[i] = running; + running = Math.max(running, profile[i]!); + } + running = 0; + for (let i = seg.x1 - 1; i >= seg.x0; i--) { + rightMax[i] = running; + running = Math.max(running, profile[i]!); + } + let best = -1; + let bestRatio = Number.POSITIVE_INFINITY; + for (let i = seg.x0 + 3; i < seg.x1 - 3; i++) { + const ratio = + profile[i]! / Math.max(1, Math.min(leftMax[i]!, rightMax[i]!)); + if (ratio < bestRatio) { + bestRatio = ratio; + best = i; + } + } + if (best < 0 || bestRatio > 0.45) return [seg]; + // The dip is often a plateau of equally-low columns (a 'T' arm blurring + // into the next glyph profiles no higher than the gap itself), and the + // profile cannot say which glyph the plateau's ink belongs to. Cut at the + // plateau edge that leaves the left part closest to whole glyph widths. + let lo = best; + let hi = best; + while (lo - 1 >= seg.x0 + 3 && profile[lo - 1]! <= profile[best]!) lo--; + while (hi + 1 < seg.x1 - 3 && profile[hi + 1]! <= profile[best]!) hi++; + const cut = + widthError(lo - seg.x0, medianWidth) <= + widthError(hi + 1 - seg.x0, medianWidth) + ? lo + : hi + 1; + return [ + ...splitWideSegment(profile, { x0: seg.x0, x1: cut }, medianWidth), + ...splitWideSegment(profile, { x0: cut, x1: seg.x1 }, medianWidth), + ]; } interface SegmentInfo extends Segment { - ink: number; - height: number; - /** ink extent rows (y1 exclusive) */ - y0: number; - y1: number; + ink: number; + height: number; + /** ink extent rows (y1 exclusive) */ + y0: number; + y1: number; } function measureSegment(binary: Mat, seg: Segment): SegmentInfo { - const { cols, data, rows } = binary; - let ink = 0; - let yMin = rows; - let yMax = -1; - for (let y = 0; y < rows; y++) { - for (let x = seg.x0; x < seg.x1; x++) { - if (data[y * cols + x]! > 0) { - ink++; - if (y < yMin) yMin = y; - if (y > yMax) yMax = y; - } - } - } - return { - ...seg, - ink, - height: Math.max(0, yMax - yMin + 1), - y0: Math.min(yMin, rows), - y1: yMax + 1, - }; + const { cols, data, rows } = binary; + let ink = 0; + let yMin = rows; + let yMax = -1; + for (let y = 0; y < rows; y++) { + for (let x = seg.x0; x < seg.x1; x++) { + if (data[y * cols + x]! > 0) { + ink++; + if (y < yMin) yMin = y; + if (y > yMax) yMax = y; + } + } + } + return { + ...seg, + ink, + height: Math.max(0, yMax - yMin + 1), + y0: Math.min(yMin, rows), + y1: yMax + 1, + }; } /** @@ -299,152 +309,160 @@ function measureSegment(binary: Mat, seg: Segment): SegmentInfo { const FIXTURE_TIEBREAK = 0.02; function classifySegment( - masked: Mat, - seg: SegmentInfo, - set: GlyphSet, - /** - * Exact early-reject floor: a score can never exceed its precomputed - * bound, so a caller that only needs to know whether any glyph can beat - * `scoreFloor` (mergeSplitGlyphs) lets the bound-sorted loop stop as soon - * as none can. Scores that ARE computed come out identical — but the - * returned list omits sub-floor candidates, so pass it only for probes. - */ - scoreFloor = -Infinity, + masked: Mat, + seg: SegmentInfo, + set: GlyphSet, + /** + * Exact early-reject floor: a score can never exceed its precomputed + * bound, so a caller that only needs to know whether any glyph can beat + * `scoreFloor` (mergeSplitGlyphs) lets the bound-sorted loop stop as soon + * as none can. Scores that ARE computed come out identical — but the + * returned list omits sub-floor candidates, so pass it only for probes. + */ + scoreFloor = Number.NEGATIVE_INFINITY, ): { char: string; score: number; ncc: number; source: "fixture" | "font" }[] { - const cv = getCV(); - const segWidth = seg.x1 - seg.x0; - const pad = 5; - // NB: Mat dimension/data accessors go through embind (validateThis etc.) - // and are expensive — hoist them out of the per-glyph/per-pixel loops. - const maskedRows = masked.rows; - const x0 = Math.max(0, seg.x0 - pad); - const x1 = Math.min(masked.cols, seg.x1 + pad); - const regionCols = x1 - x0; - // Crop the search region vertically to the segment's ink rows: line crops - // can run far taller than the text (the death tag band is ~2x its glyphs), - // and every extra row multiplies matchTemplate work while only offering - // placements away from the ink no correct match can occupy. The slack - // keeps every hRatio-eligible template (tRows <= 1.3 * seg.height) - // placeable: seg.height + 2 * (0.15 * seg.height + 2) >= 1.3 * seg.height. - const vSlack = Math.ceil(0.15 * seg.height) + 2; - const y0 = Math.max(0, seg.y0 - vSlack); - const y1 = Math.min(maskedRows, seg.y1 + vSlack); - const regionRows = y1 - y0; - const region = masked.roi(new cv.Rect(x0, y0, regionCols, regionRows)); + const cv = getCV(); + const segWidth = seg.x1 - seg.x0; + const pad = 5; + // NB: Mat dimension/data accessors go through embind (validateThis etc.) + // and are expensive — hoist them out of the per-glyph/per-pixel loops. + const maskedRows = masked.rows; + const x0 = Math.max(0, seg.x0 - pad); + const x1 = Math.min(masked.cols, seg.x1 + pad); + const regionCols = x1 - x0; + // Crop the search region vertically to the segment's ink rows: line crops + // can run far taller than the text (the death tag band is ~2x its glyphs), + // and every extra row multiplies matchTemplate work while only offering + // placements away from the ink no correct match can occupy. The slack + // keeps every hRatio-eligible template (tRows <= 1.3 * seg.height) + // placeable: seg.height + 2 * (0.15 * seg.height + 2) >= 1.3 * seg.height. + const vSlack = Math.ceil(0.15 * seg.height) + 2; + const y0 = Math.max(0, seg.y0 - vSlack); + const y1 = Math.min(maskedRows, seg.y1 + vSlack); + const regionRows = y1 - y0; + const region = masked.roi(new cv.Rect(x0, y0, regionCols, regionRows)); - // Both penalty factors below depend only on the glyph and the segment, and - // NCC <= 1, so their product bounds the score a glyph can reach before any - // matching runs. Matching in descending-bound order lets the loop stop as - // soon as no remaining glyph could come within FIXTURE_TIEBREAK of the - // best — those can neither win nor take part in the fixture tie-break. - const eligible: { - glyph: Glyph; - tRows: number; - tCols: number; - r: number; - hr: number; - bound: number; - }[] = []; - for (const glyph of set.glyphs) { - const t = glyph.mat; - const tRows = t.rows; - const tCols = t.cols; - if (tRows > regionRows || tCols > regionCols) continue; - const wRatio = tCols / Math.max(segWidth, 1); - if (wRatio < 0.4 || wRatio > 2.5) continue; - const hRatio = tRows / Math.max(seg.height, 1); - // a template sliding freely inside a taller region can score high on a - // fragment of the segment (an 'l' bar inside a 'c'), so reject templates - // much taller or shorter than the segment's ink extent - if (hRatio < 0.5 || hRatio > 1.3) continue; - // ink-coverage penalty: templates should explain the segment's ink - const r = Math.min(glyph.ink, seg.ink) / Math.max(Math.max(glyph.ink, seg.ink), 1); - // height-mismatch penalty: an x-height template sliding on an ascender/ - // descender glyph ('o' on 'b' or 'g') can correlate well on the bowl - // alone; matching heights should outrank it - const hr = Math.min(tRows, seg.height) / Math.max(tRows, Math.max(seg.height, 1)); - eligible.push({ - glyph, - tRows, - tCols, - r, - hr, - bound: (0.7 + 0.3 * r) * (0.85 + 0.15 * hr), - }); - } - eligible.sort((a, b) => b.bound - a.bound); + // Both penalty factors below depend only on the glyph and the segment, and + // NCC <= 1, so their product bounds the score a glyph can reach before any + // matching runs. Matching in descending-bound order lets the loop stop as + // soon as no remaining glyph could come within FIXTURE_TIEBREAK of the + // best — those can neither win nor take part in the fixture tie-break. + const eligible: { + glyph: Glyph; + tRows: number; + tCols: number; + r: number; + hr: number; + bound: number; + }[] = []; + for (const glyph of set.glyphs) { + const t = glyph.mat; + const tRows = t.rows; + const tCols = t.cols; + if (tRows > regionRows || tCols > regionCols) continue; + const wRatio = tCols / Math.max(segWidth, 1); + if (wRatio < 0.4 || wRatio > 2.5) continue; + const hRatio = tRows / Math.max(seg.height, 1); + // a template sliding freely inside a taller region can score high on a + // fragment of the segment (an 'l' bar inside a 'c'), so reject templates + // much taller or shorter than the segment's ink extent + if (hRatio < 0.5 || hRatio > 1.3) continue; + // ink-coverage penalty: templates should explain the segment's ink + const r = + Math.min(glyph.ink, seg.ink) / Math.max(Math.max(glyph.ink, seg.ink), 1); + // height-mismatch penalty: an x-height template sliding on an ascender/ + // descender glyph ('o' on 'b' or 'g') can correlate well on the bowl + // alone; matching heights should outrank it + const hr = + Math.min(tRows, seg.height) / Math.max(tRows, Math.max(seg.height, 1)); + eligible.push({ + glyph, + tRows, + tCols, + r, + hr, + bound: (0.7 + 0.3 * r) * (0.85 + 0.15 * hr), + }); + } + eligible.sort((a, b) => b.bound - a.bound); - const result = new cv.Mat(); - const candidates: { - char: string; - score: number; - ncc: number; - source: "fixture" | "font"; - }[] = []; - // Only consider placements that cover most of the segment: the region - // is padded, so a freely-sliding template can otherwise score a perfect - // match on the *neighboring* glyph inside the pad (an 'l' template next - // to a narrow 'i' segment matches the adjacent real 'l' at 0.97+), or - // on a fragment of the segment (a 't' stem on the left bar of a 'b'). - const minOverlap = 0.7 * segWidth; - // In probe mode only the boolean "can any glyph beat the floor" matters, - // so the loop can stop as soon as either answer is certain: no remaining - // bound reaches the floor, or a computed score already cleared it. - const probeMode = scoreFloor !== -Infinity; - let bestScore = scoreFloor; - for (const { glyph, tRows, tCols, r, hr, bound } of eligible) { - if (bound < bestScore - FIXTURE_TIEBREAK) break; - if (probeMode && (bound <= scoreFloor || bestScore > scoreFloor)) break; - cv.matchTemplate(region, glyph.mat, result, cv.TM_CCOEFF_NORMED); - const rCols = regionCols - tCols + 1; - const rRows = regionRows - tRows + 1; - // overlap(sx) is concave in sx, so the valid placements form one - // contiguous rx interval — find its edges, then scan row-major - const overlapAt = (rx: number) => Math.min(x0 + rx + tCols, seg.x1) - Math.max(x0 + rx, seg.x0); - let lo = 0; - while (lo < rCols && overlapAt(lo) < minOverlap) lo++; - let hi = rCols - 1; - while (hi >= lo && overlapAt(hi) < minOverlap) hi--; - if (hi < lo) continue; - let maxVal = -Infinity; - const scores = result.data32F; - for (let ry = 0, rowBase = 0; ry < rRows; ry++, rowBase += rCols) { - for (let rx = lo; rx <= hi; rx++) { - const v = scores[rowBase + rx]!; - if (v > maxVal) maxVal = v; - } - } - const score = maxVal * (0.7 + 0.3 * r) * (0.85 + 0.15 * hr); - if (Number.isFinite(score)) { - if (score > bestScore) bestScore = score; - candidates.push({ char: glyph.char, score, ncc: maxVal, source: glyph.source }); - } - } - result.delete(); - region.delete(); - candidates.sort((a, b) => b.score - a.score); - const top = candidates[0]; - if (top && top.source === "font") { - const fixture = candidates.find( - (c) => - c.source === "fixture" && - top.score - c.score < FIXTURE_TIEBREAK && - // ...but only when the fixture's raw correlation is also competitive; - // a fixture crop of a *different* char that merely lands near the top - // score via the ink penalty must not displace a well-matching font glyph - top.ncc - c.ncc < FIXTURE_TIEBREAK, - ); - if (fixture && fixture !== top) { - candidates.splice(candidates.indexOf(fixture), 1); - candidates.unshift(fixture); - } - } - return candidates.slice(0, 5); + const result = new cv.Mat(); + const candidates: { + char: string; + score: number; + ncc: number; + source: "fixture" | "font"; + }[] = []; + // Only consider placements that cover most of the segment: the region + // is padded, so a freely-sliding template can otherwise score a perfect + // match on the *neighboring* glyph inside the pad (an 'l' template next + // to a narrow 'i' segment matches the adjacent real 'l' at 0.97+), or + // on a fragment of the segment (a 't' stem on the left bar of a 'b'). + const minOverlap = 0.7 * segWidth; + // In probe mode only the boolean "can any glyph beat the floor" matters, + // so the loop can stop as soon as either answer is certain: no remaining + // bound reaches the floor, or a computed score already cleared it. + const probeMode = scoreFloor !== Number.NEGATIVE_INFINITY; + let bestScore = scoreFloor; + for (const { glyph, tRows, tCols, r, hr, bound } of eligible) { + if (bound < bestScore - FIXTURE_TIEBREAK) break; + if (probeMode && (bound <= scoreFloor || bestScore > scoreFloor)) break; + cv.matchTemplate(region, glyph.mat, result, cv.TM_CCOEFF_NORMED); + const rCols = regionCols - tCols + 1; + const rRows = regionRows - tRows + 1; + // overlap(sx) is concave in sx, so the valid placements form one + // contiguous rx interval — find its edges, then scan row-major + const overlapAt = (rx: number) => + Math.min(x0 + rx + tCols, seg.x1) - Math.max(x0 + rx, seg.x0); + let lo = 0; + while (lo < rCols && overlapAt(lo) < minOverlap) lo++; + let hi = rCols - 1; + while (hi >= lo && overlapAt(hi) < minOverlap) hi--; + if (hi < lo) continue; + let maxVal = Number.NEGATIVE_INFINITY; + const scores = result.data32F; + for (let ry = 0, rowBase = 0; ry < rRows; ry++, rowBase += rCols) { + for (let rx = lo; rx <= hi; rx++) { + const v = scores[rowBase + rx]!; + if (v > maxVal) maxVal = v; + } + } + const score = maxVal * (0.7 + 0.3 * r) * (0.85 + 0.15 * hr); + if (Number.isFinite(score)) { + if (score > bestScore) bestScore = score; + candidates.push({ + char: glyph.char, + score, + ncc: maxVal, + source: glyph.source, + }); + } + } + result.delete(); + region.delete(); + candidates.sort((a, b) => b.score - a.score); + const top = candidates[0]; + if (top && top.source === "font") { + const fixture = candidates.find( + (c) => + c.source === "fixture" && + top.score - c.score < FIXTURE_TIEBREAK && + // ...but only when the fixture's raw correlation is also competitive; + // a fixture crop of a *different* char that merely lands near the top + // score via the ink penalty must not displace a well-matching font glyph + top.ncc - c.ncc < FIXTURE_TIEBREAK, + ); + if (fixture && fixture !== top) { + candidates.splice(candidates.indexOf(fixture), 1); + candidates.unshift(fixture); + } + } + return candidates.slice(0, 5); } interface ClassifiedSegment { - seg: SegmentInfo; - ranked: ReturnType; + seg: SegmentInfo; + ranked: ReturnType; } /** @@ -467,43 +485,46 @@ const MERGE_MAX_GAP_RATIO = 0.45; const MERGE_MARGIN = 0.02; function mergeSplitGlyphs( - items: ClassifiedSegment[], - binary: Mat, - masked: Mat, - set: GlyphSet, + items: ClassifiedSegment[], + binary: Mat, + masked: Mat, + set: GlyphSet, ): void { - const maxGap = Math.max(3, Math.round(set.medianWidth * MERGE_MAX_GAP_RATIO)); - const maxCharWidth = Math.round(set.medianWidth * 1.5); - for (let i = 0; i + 1 < items.length; ) { - const a = items[i]!; - const b = items[i + 1]!; - const gap = b.seg.x0 - a.seg.x1; - const width = b.seg.x1 - a.seg.x0; - if (gap > maxGap || width > maxCharWidth) { - i++; - continue; - } - const seg = measureSegment(binary, { x0: a.seg.x0, x1: b.seg.x1 }); - const fragmentBest = Math.max(a.ranked[0]?.score ?? 0, b.ranked[0]?.score ?? 0); - const floor = fragmentBest + MERGE_MARGIN; - // Probe with the floor first: most neighbor pairs are genuine letter - // pairs whose merge can't win, and the floor lets the bound-sorted - // matching stop almost immediately. The probe's computed scores are - // exact, so "no candidate beats the floor" is a definitive reject. - const probe = classifySegment(masked, seg, set, floor); - let merged = false; - if (probe.some((c) => c.score > floor)) { - // full run (rare): the winning merge's ranked list must also carry - // the sub-floor runner-up candidates downstream consumers see - const ranked = classifySegment(masked, seg, set); - if ((ranked[0]?.score ?? 0) > floor) { - // stay at i: the merged segment may absorb yet another stroke - items.splice(i, 2, { seg, ranked }); - merged = true; - } - } - if (!merged) i++; - } + const maxGap = Math.max(3, Math.round(set.medianWidth * MERGE_MAX_GAP_RATIO)); + const maxCharWidth = Math.round(set.medianWidth * 1.5); + for (let i = 0; i + 1 < items.length; ) { + const a = items[i]!; + const b = items[i + 1]!; + const gap = b.seg.x0 - a.seg.x1; + const width = b.seg.x1 - a.seg.x0; + if (gap > maxGap || width > maxCharWidth) { + i++; + continue; + } + const seg = measureSegment(binary, { x0: a.seg.x0, x1: b.seg.x1 }); + const fragmentBest = Math.max( + a.ranked[0]?.score ?? 0, + b.ranked[0]?.score ?? 0, + ); + const floor = fragmentBest + MERGE_MARGIN; + // Probe with the floor first: most neighbor pairs are genuine letter + // pairs whose merge can't win, and the floor lets the bound-sorted + // matching stop almost immediately. The probe's computed scores are + // exact, so "no candidate beats the floor" is a definitive reject. + const probe = classifySegment(masked, seg, set, floor); + let merged = false; + if (probe.some((c) => c.score > floor)) { + // full run (rare): the winning merge's ranked list must also carry + // the sub-floor runner-up candidates downstream consumers see + const ranked = classifySegment(masked, seg, set); + if ((ranked[0]?.score ?? 0) > floor) { + // stay at i: the merged segment may absorb yet another stroke + items.splice(i, 2, { seg, ranked }); + merged = true; + } + } + if (!merged) i++; + } } /** @@ -511,73 +532,77 @@ function mergeSplitGlyphs( * The crop must be tight vertically (one text line). */ export function recognizeText( - gray: Mat, - set: GlyphSet, - options: RecognizeOptions = {}, + gray: Mat, + set: GlyphSet, + options: RecognizeOptions = {}, ): RecognizedText { - const cv = getCV(); - const { - binThreshold = 150, - minColumnPixels = 1, - spaceGap = Math.max(5, Math.round(set.medianWidth * 0.55)), - minCharScore = 0.4, - } = options; + const cv = getCV(); + const { + binThreshold = 150, + minColumnPixels = 1, + spaceGap = Math.max(5, Math.round(set.medianWidth * 0.55)), + minCharScore = 0.4, + } = options; - const binary = new cv.Mat(); - cv.threshold(gray, binary, binThreshold, 255, cv.THRESH_BINARY); + const binary = new cv.Mat(); + cv.threshold(gray, binary, binThreshold, 255, cv.THRESH_BINARY); - // mask the gray crop so background patterns don't take part in matching, - // dilated so antialiased glyph edges survive - const mask = new cv.Mat(); - const kernel = cv.getStructuringElement(cv.MORPH_RECT, new cv.Size(3, 3)); - cv.dilate(binary, mask, kernel, new cv.Point(-1, -1), 2); - kernel.delete(); - const masked = new cv.Mat(gray.rows, gray.cols, cv.CV_8UC1, new cv.Scalar(0)); - gray.copyTo(masked, mask); - mask.delete(); + // mask the gray crop so background patterns don't take part in matching, + // dilated so antialiased glyph edges survive + const mask = new cv.Mat(); + const kernel = cv.getStructuringElement(cv.MORPH_RECT, new cv.Size(3, 3)); + cv.dilate(binary, mask, kernel, new cv.Point(-1, -1), 2); + kernel.delete(); + const masked = new cv.Mat(gray.rows, gray.cols, cv.CV_8UC1, new cv.Scalar(0)); + gray.copyTo(masked, mask); + mask.delete(); - const profile = columnProfile(binary); - const segments = segmentColumns(profile, minColumnPixels) - .flatMap((s) => splitWideSegment(profile, s, set.medianWidth)) - .map((s) => measureSegment(binary, s)); + const profile = columnProfile(binary); + const segments = segmentColumns(profile, minColumnPixels) + .flatMap((s) => splitWideSegment(profile, s, set.medianWidth)) + .map((s) => measureSegment(binary, s)); - const items: ClassifiedSegment[] = segments.map((seg) => ({ - seg, - ranked: classifySegment(masked, seg, set), - })); - mergeSplitGlyphs(items, binary, masked, set); + const items: ClassifiedSegment[] = segments.map((seg) => ({ + seg, + ranked: classifySegment(masked, seg, set), + })); + mergeSplitGlyphs(items, binary, masked, set); - const chars: RecognizedChar[] = []; - let text = ""; - let prevEnd: number | null = null; - for (const { seg, ranked } of items) { - if (prevEnd !== null && seg.x0 - prevEnd > spaceGap && text.length > 0) { - text += " "; - } - const top = ranked[0]; - if (top && top.score >= minCharScore) { - chars.push({ - char: top.char, - score: top.score, - x0: seg.x0, - x1: seg.x1, - y0: seg.y0, - y1: seg.y1, - candidates: ranked.map((c) => ({ - char: c.char, - score: c.score, - ncc: c.ncc, - source: c.source, - })), - }); - text += top.char; - } - prevEnd = seg.x1; - } - binary.delete(); - masked.delete(); + const chars: RecognizedChar[] = []; + let text = ""; + let prevEnd: number | null = null; + for (const { seg, ranked } of items) { + if (prevEnd !== null && seg.x0 - prevEnd > spaceGap && text.length > 0) { + text += " "; + } + const top = ranked[0]; + if (top && top.score >= minCharScore) { + chars.push({ + char: top.char, + score: top.score, + x0: seg.x0, + x1: seg.x1, + y0: seg.y0, + y1: seg.y1, + candidates: ranked.map((c) => ({ + char: c.char, + score: c.score, + ncc: c.ncc, + source: c.source, + })), + }); + text += top.char; + } + prevEnd = seg.x1; + } + binary.delete(); + masked.delete(); - const confidence = - chars.length > 0 ? Math.min(...chars.map((c) => c.score)) : segments.length > 0 ? 0 : 1; - return { text, chars, confidence }; + const confidence = + chars.length > 0 + ? Math.min(...chars.map((c) => c.score)) + : segments.length > 0 + ? 0 + : 1; + return { text, chars, confidence }; } diff --git a/app/features/cv/core/image.ts b/app/features/cv/core/image.ts index e7db6ff1d..073b23f08 100644 --- a/app/features/cv/core/image.ts +++ b/app/features/cv/core/image.ts @@ -10,16 +10,16 @@ import { getCV, type Mat, meanOf, minMaxLoc } from "./cv"; export type { Roi }; export interface FrameData { - width: number; - height: number; - /** RGBA, 4 bytes per pixel */ - data: Uint8ClampedArray; + width: number; + height: number; + /** RGBA, 4 bytes per pixel */ + data: Uint8ClampedArray; } export function toMat(frame: FrameData): Mat { - const cv = getCV(); - // matFromImageData only reads width/height/data, so FrameData is compatible - return cv.matFromImageData(frame as unknown as ImageData); + const cv = getCV(); + // matFromImageData only reads width/height/data, so FrameData is compatible + return cv.matFromImageData(frame as unknown as ImageData); } /** @@ -27,15 +27,23 @@ export function toMat(frame: FrameData): Mat { * constants are defined against. Returns a new mat; caller owns both. */ export function normalizeFrame(src: Mat): Mat { - const cv = getCV(); - const dst = new cv.Mat(); - if (src.cols === CANONICAL_WIDTH && src.rows === CANONICAL_HEIGHT) { - src.copyTo(dst); - return dst; - } - const interpolation = src.cols > CANONICAL_WIDTH ? cv.INTER_AREA : cv.INTER_CUBIC; - cv.resize(src, dst, new cv.Size(CANONICAL_WIDTH, CANONICAL_HEIGHT), 0, 0, interpolation); - return dst; + const cv = getCV(); + const dst = new cv.Mat(); + if (src.cols === CANONICAL_WIDTH && src.rows === CANONICAL_HEIGHT) { + src.copyTo(dst); + return dst; + } + const interpolation = + src.cols > CANONICAL_WIDTH ? cv.INTER_AREA : cv.INTER_CUBIC; + cv.resize( + src, + dst, + new cv.Size(CANONICAL_WIDTH, CANONICAL_HEIGHT), + 0, + 0, + interpolation, + ); + return dst; } /** @@ -45,17 +53,17 @@ export function normalizeFrame(src: Mat): Mat { * Use copyRoi when pixel access is needed. */ export function cropRoi(src: Mat, roi: Roi): Mat { - const cv = getCV(); - return src.roi(new cv.Rect(roi.x, roi.y, roi.w, roi.h)); + const cv = getCV(); + return src.roi(new cv.Rect(roi.x, roi.y, roi.w, roi.h)); } /** Crop a rect into a fresh continuous mat (safe for `.data` access). */ export function copyRoi(src: Mat, roi: Roi): Mat { - const view = cropRoi(src, roi); - const out = new (getCV().Mat)(); - view.copyTo(out); - view.delete(); - return out; + const view = cropRoi(src, roi); + const out = new (getCV().Mat)(); + view.copyTo(out); + view.delete(); + return out; } /** @@ -64,87 +72,91 @@ export function copyRoi(src: Mat, roi: Roi): Mat { * The shared probe primitive of every detector gate. */ export function meanBrightness(mat: Mat, roi: Roi): number { - const view = cropRoi(mat, roi); - const m = meanOf(view); - view.delete(); - return mat.channels() >= 3 ? (m[0]! + m[1]! + m[2]!) / 3 : m[0]!; + const view = cropRoi(mat, roi); + const m = meanOf(view); + view.delete(); + return mat.channels() >= 3 ? (m[0]! + m[1]! + m[2]!) / 3 : m[0]!; } /** Brightest pixel of a grayscale ROI. */ export function maxBrightness(gray: Mat, roi: Roi): number { - const view = cropRoi(gray, roi); - const { maxVal } = minMaxLoc(view); - view.delete(); - return maxVal; + const view = cropRoi(gray, roi); + const { maxVal } = minMaxLoc(view); + view.delete(); + return maxVal; } -function channelExtreme(mat: Mat, roi: Roi | undefined, op: "min" | "max"): Mat { - const cv = getCV(); - const view = roi ? cropRoi(mat, roi) : null; - const src = view ?? mat; - const channels = new cv.MatVector(); - cv.split(src, channels); - const r = channels.get(0); - const g = channels.get(1); - const b = channels.get(2); - const rg = new cv.Mat(); - const out = new cv.Mat(); - if (op === "max") { - cv.max(r, g, rg); - cv.max(rg, b, out); - } else { - cv.min(r, g, rg); - cv.min(rg, b, out); - } - rg.delete(); - r.delete(); - g.delete(); - b.delete(); - if (mat.channels() === 4) channels.get(3).delete(); - channels.delete(); - view?.delete(); - return out; +function channelExtreme( + mat: Mat, + roi: Roi | undefined, + op: "min" | "max", +): Mat { + const cv = getCV(); + const view = roi ? cropRoi(mat, roi) : null; + const src = view ?? mat; + const channels = new cv.MatVector(); + cv.split(src, channels); + const r = channels.get(0); + const g = channels.get(1); + const b = channels.get(2); + const rg = new cv.Mat(); + const out = new cv.Mat(); + if (op === "max") { + cv.max(r, g, rg); + cv.max(rg, b, out); + } else { + cv.min(r, g, rg); + cv.min(rg, b, out); + } + rg.delete(); + r.delete(); + g.delete(); + b.delete(); + if (mat.channels() === 4) channels.get(3).delete(); + channels.delete(); + view?.delete(); + return out; } /** Brightest channel per pixel, so colored text binarizes like white. */ export function maxChannel(mat: Mat, roi?: Roi): Mat { - return channelExtreme(mat, roi, "max"); + return channelExtreme(mat, roi, "max"); } /** Per-pixel min of R/G/B — drops color-tinted brightness, keeps white. */ export function minChannel(mat: Mat, roi?: Roi): Mat { - return channelExtreme(mat, roi, "min"); + return channelExtreme(mat, roi, "min"); } /** |Laplacian| response of a grayscale mat; caller owns the result. */ export function laplacianAbs(gray: Mat): Mat { - const cv = getCV(); - const lap = new cv.Mat(); - cv.Laplacian(gray, lap, cv.CV_16S, 3, 1, 0, cv.BORDER_DEFAULT); - const abs8 = new cv.Mat(); - cv.convertScaleAbs(lap, abs8); - lap.delete(); - return abs8; + const cv = getCV(); + const lap = new cv.Mat(); + cv.Laplacian(gray, lap, cv.CV_16S, 3, 1, 0, cv.BORDER_DEFAULT); + const abs8 = new cv.Mat(); + cv.convertScaleAbs(lap, abs8); + lap.delete(); + return abs8; } export function matToFrameData(mat: Mat): FrameData { - const cv = getCV(); - const rgba = new cv.Mat(); - if (mat.type() === cv.CV_8UC4) { - mat.copyTo(rgba); - } else if (mat.type() === cv.CV_8UC3) { - cv.cvtColor(mat, rgba, cv.COLOR_RGB2RGBA); - } else if (mat.type() === cv.CV_8UC1) { - cv.cvtColor(mat, rgba, cv.COLOR_GRAY2RGBA); - } else { - rgba.delete(); - throw new Error(`unsupported mat type ${mat.type()}`); - } - const out: FrameData = { - width: rgba.cols, - height: rgba.rows, - data: new Uint8ClampedArray(rgba.data), - }; - rgba.delete(); - return out; + const cv = getCV(); + const rgba = new cv.Mat(); + if (mat.type() === cv.CV_8UC4) { + mat.copyTo(rgba); + } else if (mat.type() === cv.CV_8UC3) { + cv.cvtColor(mat, rgba, cv.COLOR_RGB2RGBA); + } else if (mat.type() === cv.CV_8UC1) { + cv.cvtColor(mat, rgba, cv.COLOR_GRAY2RGBA); + } else { + rgba.delete(); + throw new Error(`unsupported mat type ${mat.type()}`); + } + const out: FrameData = { + width: rgba.cols, + height: rgba.rows, + data: new Uint8ClampedArray(rgba.data), + }; + rgba.delete(); + return out; } diff --git a/app/features/cv/core/localized-entries.ts b/app/features/cv/core/localized-entries.ts index 0875bc692..b0d1b402c 100644 --- a/app/features/cv/core/localized-entries.ts +++ b/app/features/cv/core/localized-entries.ts @@ -9,2163 +9,2163 @@ import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import type { CvLobby } from "../cv-types"; export interface LocalizedLobby { - text: string; - lobby: CvLobby; + text: string; + lobby: CvLobby; } export interface LocalizedMode { - text: string; - mode: ModeShort; + text: string; + mode: ModeShort; } export interface LocalizedStage { - text: string; - stageId: StageId; + text: string; + stageId: StageId; } export interface LanguageEntries { - lang: string; - /** the intro splash's constant "MODE" label */ - modeLabel: string; - /** replay-browser winner/loser panel tags */ - victory: string; - defeat: string; - lobbies: LocalizedLobby[]; - modes: LocalizedMode[]; - /** two-line intro-splash wrap variants ("Muschel-\nchaos"), space-joined */ - modeWraps: LocalizedMode[]; - stages: LocalizedStage[]; + lang: string; + /** the intro splash's constant "MODE" label */ + modeLabel: string; + /** replay-browser winner/loser panel tags */ + victory: string; + defeat: string; + lobbies: LocalizedLobby[]; + modes: LocalizedMode[]; + /** two-line intro-splash wrap variants ("Muschel-\nchaos"), space-joined */ + modeWraps: LocalizedMode[]; + stages: LocalizedStage[]; } export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = [ - { - "lang": "CNzh", - "modeLabel": "规则", - "victory": "WIN!", - "defeat": "LOSE...", - "lobbies": [ - { - "text": "X比赛", - "lobby": "X" - }, - { - "text": "蛮颓比赛(挑战)", - "lobby": "SERIES" - }, - { - "text": "蛮颓比赛(开放)", - "lobby": "OPEN" - }, - { - "text": "私人比赛", - "lobby": "PRIVATE" - } - ], - "modes": [ - { - "text": "占地对战", - "mode": "TW" - }, - { - "text": "真格区域", - "mode": "SZ" - }, - { - "text": "真格塔楼", - "mode": "TC" - }, - { - "text": "真格鱼虎对战", - "mode": "RM" - }, - { - "text": "真格蛤蜊", - "mode": "CB" - } - ], - "modeWraps": [], - "stages": [ - { - "text": "龙宫转运站", - "stageId": 23 - }, - { - "text": "臭鱼干温泉", - "stageId": 13 - }, - { - "text": "醋饭海洋世界", - "stageId": 11 - }, - { - "text": "十路通高架桥下", - "stageId": 24 - }, - { - "text": "贝见亭", - "stageId": 21 - }, - { - "text": "鳗鲶区", - "stageId": 1 - }, - { - "text": "鱼露遗迹", - "stageId": 15 - }, - { - "text": "鬼头刀SPA度假区", - "stageId": 7 - }, - { - "text": "比目鱼住宅区", - "stageId": 12 - }, - { - "text": "真鲭跨海大桥", - "stageId": 5 - }, - { - "text": "座头购物中心", - "stageId": 10 - }, - { - "text": "鲟鱼造船厂", - "stageId": 9 - }, - { - "text": "鬼蝠鲼玛利亚号", - "stageId": 14 - }, - { - "text": "金眼鲷美术馆", - "stageId": 6 - }, - { - "text": "旗鱼机场", - "stageId": 22 - }, - { - "text": "葱鲔鱼煤矿场", - "stageId": 20 - }, - { - "text": "鱼肉碎金属", - "stageId": 4 - }, - { - "text": "塔拉波特购物公园", - "stageId": 17 - }, - { - "text": "高脚经济特区", - "stageId": 18 - }, - { - "text": "竹蛏疏洪道", - "stageId": 3 - }, - { - "text": "大比目鱼海运中心", - "stageId": 19 - }, - { - "text": "海女美术大学", - "stageId": 8 - }, - { - "text": "昆布赛道", - "stageId": 16 - }, - { - "text": "烟管鱼市场", - "stageId": 2 - }, - { - "text": "温泉花大峡谷", - "stageId": 0 - } - ] - }, - { - "lang": "EUde", - "modeLabel": "Kampfart", - "victory": "Sieg!", - "defeat": "Verloren...", - "lobbies": [ - { - "text": "X-Kampf", - "lobby": "X" - }, - { - "text": "Anarchie-Kampf (Serie)", - "lobby": "SERIES" - }, - { - "text": "Anarchie-Kampf (Offen)", - "lobby": "OPEN" - }, - { - "text": "Privater Kampf", - "lobby": "PRIVATE" - } - ], - "modes": [ - { - "text": "Revierkampf", - "mode": "TW" - }, - { - "text": "Herrschaft", - "mode": "SZ" - }, - { - "text": "Turm-Kommando", - "mode": "TC" - }, - { - "text": "Operation Goldfisch", - "mode": "RM" - }, - { - "text": "Muschelchaos", - "mode": "CB" - } - ], - "modeWraps": [ - { - "text": "Revier- kampf", - "mode": "TW" - }, - { - "text": "Muschel- chaos", - "mode": "CB" - } - ], - "stages": [ - { - "text": "Bahnhof Lemuria", - "stageId": 23 - }, - { - "text": "Kusaya-Quellen", - "stageId": 13 - }, - { - "text": "Flunder-Funpark", - "stageId": 11 - }, - { - "text": "Dekabahnstation", - "stageId": 24 - }, - { - "text": "ROM & RAMen", - "stageId": 21 - }, - { - "text": "Streifenaal-Straße", - "stageId": 1 - }, - { - "text": "Um'ami-Ruinen", - "stageId": 15 - }, - { - "text": "Mahi-Mahi-Resort", - "stageId": 7 - }, - { - "text": "Schollensiedlung", - "stageId": 12 - }, - { - "text": "Makrelenbrücke", - "stageId": 5 - }, - { - "text": "Cetacea-Markt", - "stageId": 10 - }, - { - "text": "Störwerft", - "stageId": 9 - }, - { - "text": "Manta Maria", - "stageId": 14 - }, - { - "text": "Pinakoithek", - "stageId": 6 - }, - { - "text": "La Ola Airport", - "stageId": 22 - }, - { - "text": "Blauflossen-Depot", - "stageId": 20 - }, - { - "text": "Aalstahl-Metallwerk", - "stageId": 4 - }, - { - "text": "Talerfisch & Pock", - "stageId": 17 - }, - { - "text": "Seespinnen-Skyline", - "stageId": 18 - }, - { - "text": "Schwertmuschel-Reservoir", - "stageId": 3 - }, - { - "text": "Frachtschiff Schwerfisch", - "stageId": 19 - }, - { - "text": "Perlmutt-Akademie", - "stageId": 8 - }, - { - "text": "Buckelwal-Piste", - "stageId": 16 - }, - { - "text": "Schnapperchen-Basar", - "stageId": 2 - }, - { - "text": "Sengkluft", - "stageId": 0 - } - ] - }, - { - "lang": "EUen", - "modeLabel": "Mode", - "victory": "VICTORY", - "defeat": "DEFEAT", - "lobbies": [ - { - "text": "X Battle", - "lobby": "X" - }, - { - "text": "Anarchy Battle (Series)", - "lobby": "SERIES" - }, - { - "text": "Anarchy Battle (Open)", - "lobby": "OPEN" - }, - { - "text": "Private Battle", - "lobby": "PRIVATE" - } - ], - "modes": [ - { - "text": "Turf War", - "mode": "TW" - }, - { - "text": "Splat Zones", - "mode": "SZ" - }, - { - "text": "Tower Control", - "mode": "TC" - }, - { - "text": "Rainmaker", - "mode": "RM" - }, - { - "text": "Clam Blitz", - "mode": "CB" - } - ], - "modeWraps": [], - "stages": [ - { - "text": "Lemuria Hub", - "stageId": 23 - }, - { - "text": "Brinewater Springs", - "stageId": 13 - }, - { - "text": "Wahoo World", - "stageId": 11 - }, - { - "text": "Urchin Underpass", - "stageId": 24 - }, - { - "text": "Robo ROM-en", - "stageId": 21 - }, - { - "text": "Eeltail Alley", - "stageId": 1 - }, - { - "text": "Um'ami Ruins", - "stageId": 15 - }, - { - "text": "Mahi-Mahi Resort", - "stageId": 7 - }, - { - "text": "Flounder Heights", - "stageId": 12 - }, - { - "text": "Hammerhead Bridge", - "stageId": 5 - }, - { - "text": "MakoMart", - "stageId": 10 - }, - { - "text": "Sturgeon Shipyard", - "stageId": 9 - }, - { - "text": "Manta Maria", - "stageId": 14 - }, - { - "text": "Museum d'Alfonsino", - "stageId": 6 - }, - { - "text": "Marlin Airport", - "stageId": 22 - }, - { - "text": "Bluefin Depot", - "stageId": 20 - }, - { - "text": "Mincemeat Metalworks", - "stageId": 4 - }, - { - "text": "Barnacle & Dime", - "stageId": 17 - }, - { - "text": "Crableg Capital", - "stageId": 18 - }, - { - "text": "Undertow Spillway", - "stageId": 3 - }, - { - "text": "Shipshape Cargo Co.", - "stageId": 19 - }, - { - "text": "Inkblot Art Academy", - "stageId": 8 - }, - { - "text": "Humpback Pump Track", - "stageId": 16 - }, - { - "text": "Hagglefish Market", - "stageId": 2 - }, - { - "text": "Scorch Gorge", - "stageId": 0 - } - ] - }, - { - "lang": "EUes", - "modeLabel": "Estilo", - "victory": "¡Victoria!", - "defeat": "Derrota", - "lobbies": [ - { - "text": "Combate X", - "lobby": "X" - }, - { - "text": "Combate caótico (formal)", - "lobby": "SERIES" - }, - { - "text": "Combate caótico (informal)", - "lobby": "OPEN" - }, - { - "text": "Combate privado", - "lobby": "PRIVATE" - } - ], - "modes": [ - { - "text": "Territorial", - "mode": "TW" - }, - { - "text": "Pintazonas", - "mode": "SZ" - }, - { - "text": "Torre", - "mode": "TC" - }, - { - "text": "Pez dorado", - "mode": "RM" - }, - { - "text": "Asalto almeja", - "mode": "CB" - } - ], - "modeWraps": [ - { - "text": "Terri- torial", - "mode": "TW" - }, - { - "text": "Pinta- zonas", - "mode": "SZ" - } - ], - "stages": [ - { - "text": "Terminal Dragón", - "stageId": 23 - }, - { - "text": "Balneario Aguasal", - "stageId": 13 - }, - { - "text": "Pirañalandia", - "stageId": 11 - }, - { - "text": "Parque Viaducto", - "stageId": 24 - }, - { - "text": "Factoría RAMen", - "stageId": 21 - }, - { - "text": "Callejones Crustáceo", - "stageId": 1 - }, - { - "text": "Ruinas Ceviche", - "stageId": 15 - }, - { - "text": "Spa Cala Bacalao", - "stageId": 7 - }, - { - "text": "Complejo Medusa", - "stageId": 12 - }, - { - "text": "Puente Salmón", - "stageId": 5 - }, - { - "text": "Ultramarinos Orca", - "stageId": 10 - }, - { - "text": "Astillero Beluga", - "stageId": 9 - }, - { - "text": "Corbeta Corvina", - "stageId": 14 - }, - { - "text": "Museo del Pargo", - "stageId": 6 - }, - { - "text": "Aeropuerto Marlín", - "stageId": 22 - }, - { - "text": "Mina costera", - "stageId": 20 - }, - { - "text": "Desguace Mero", - "stageId": 4 - }, - { - "text": "Centro Comercial Fletán", - "stageId": 17 - }, - { - "text": "Distrito Rascalópodo", - "stageId": 18 - }, - { - "text": "Cisterna Navajuela", - "stageId": 3 - }, - { - "text": "Carguero Platija", - "stageId": 19 - }, - { - "text": "Instituto Coralino", - "stageId": 8 - }, - { - "text": "Tiburódromo", - "stageId": 16 - }, - { - "text": "Mercado Lenguado", - "stageId": 2 - }, - { - "text": "Desfiladero Fumarola", - "stageId": 0 - } - ] - }, - { - "lang": "EUfr", - "modeLabel": "Mode", - "victory": "Victoire !", - "defeat": "Défaite...", - "lobbies": [ - { - "text": "Match X", - "lobby": "X" - }, - { - "text": "Match anarchie (série)", - "lobby": "SERIES" - }, - { - "text": "Match anarchie (ouvert)", - "lobby": "OPEN" - }, - { - "text": "Match privé", - "lobby": "PRIVATE" - } - ], - "modes": [ - { - "text": "Guerre de territoire", - "mode": "TW" - }, - { - "text": "Défense de zone", - "mode": "SZ" - }, - { - "text": "Expédition risquée", - "mode": "TC" - }, - { - "text": "Mission Bazookarpe", - "mode": "RM" - }, - { - "text": "Pluie de palourdes", - "mode": "CB" - } - ], - "modeWraps": [], - "stages": [ - { - "text": "Gare Aiguillat", - "stageId": 23 - }, - { - "text": "Sources Sauret", - "stageId": 13 - }, - { - "text": "Parc Carapince", - "stageId": 11 - }, - { - "text": "Passage Turbot", - "stageId": 24 - }, - { - "text": "Arène Méca-ramen", - "stageId": 21 - }, - { - "text": "Banlieue Balibot", - "stageId": 1 - }, - { - "text": "Ruines Uma'mi", - "stageId": 15 - }, - { - "text": "Club Ca$halot", - "stageId": 7 - }, - { - "text": "Lotissement Filament", - "stageId": 12 - }, - { - "text": "Pont Esturgeon", - "stageId": 5 - }, - { - "text": "Supermarché Cétacé", - "stageId": 10 - }, - { - "text": "Chantier Narval", - "stageId": 9 - }, - { - "text": "Manta Maria", - "stageId": 14 - }, - { - "text": "Galeries Guppy", - "stageId": 6 - }, - { - "text": "Terminal Rorqual", - "stageId": 22 - }, - { - "text": "Mine marine", - "stageId": 20 - }, - { - "text": "Casse Rascasse", - "stageId": 4 - }, - { - "text": "Halles de Port-merlu", - "stageId": 17 - }, - { - "text": "Quartier Crabe-ciels", - "stageId": 18 - }, - { - "text": "Réservoir Rigadelle", - "stageId": 3 - }, - { - "text": "Chaland Flétan", - "stageId": 19 - }, - { - "text": "Institut Calam'arts", - "stageId": 8 - }, - { - "text": "Piste Méroule", - "stageId": 16 - }, - { - "text": "Marché Grefin", - "stageId": 2 - }, - { - "text": "Canyon aux colonnes", - "stageId": 0 - } - ] - }, - { - "lang": "EUit", - "modeLabel": "Modalità", - "victory": "Vittoria", - "defeat": "Sconfitta", - "lobbies": [ - { - "text": "Partita X", - "lobby": "X" - }, - { - "text": "Partita anarchica (Serie)", - "lobby": "SERIES" - }, - { - "text": "Partita anarchica (Aperta)", - "lobby": "OPEN" - }, - { - "text": "Partita privata", - "lobby": "PRIVATE" - } - ], - "modes": [ - { - "text": "Mischia mollusca", - "mode": "TW" - }, - { - "text": "Zona splat", - "mode": "SZ" - }, - { - "text": "Torre mobile", - "mode": "TC" - }, - { - "text": "Bazookarp", - "mode": "RM" - }, - { - "text": "Vongol gol", - "mode": "CB" - } - ], - "modeWraps": [], - "stages": [ - { - "text": "Stazione Lemuria", - "stageId": 23 - }, - { - "text": "Fermenterme", - "stageId": 13 - }, - { - "text": "Soglioland", - "stageId": 11 - }, - { - "text": "Periferia urbana", - "stageId": 24 - }, - { - "text": "Roboramen", - "stageId": 21 - }, - { - "text": "Sobborgo Siluriano", - "stageId": 1 - }, - { - "text": "Rovine Salsadipesce", - "stageId": 15 - }, - { - "text": "Villanguilla", - "stageId": 7 - }, - { - "text": "Cime Sogliolose", - "stageId": 12 - }, - { - "text": "Ponte Sgombro", - "stageId": 5 - }, - { - "text": "Mercatotano", - "stageId": 10 - }, - { - "text": "Cantiere Pinnenere", - "stageId": 9 - }, - { - "text": "Manta Maria", - "stageId": 14 - }, - { - "text": "Museo di Cefalò", - "stageId": 6 - }, - { - "text": "Aeroporto Ondabianca", - "stageId": 22 - }, - { - "text": "Molo Mollusco", - "stageId": 20 - }, - { - "text": "Discarica Tritatutto", - "stageId": 4 - }, - { - "text": "Empolporio", - "stageId": 17 - }, - { - "text": "Granchiacieli", - "stageId": 18 - }, - { - "text": "Cisterna Cernia", - "stageId": 3 - }, - { - "text": "Platessa Trasporti S.r.l.", - "stageId": 19 - }, - { - "text": "Campus Hippocampus", - "stageId": 8 - }, - { - "text": "Tintodromo Montecarpa", - "stageId": 16 - }, - { - "text": "Mercato Fruttato", - "stageId": 2 - }, - { - "text": "Grank Canyon", - "stageId": 0 - } - ] - }, - { - "lang": "EUnl", - "modeLabel": "Vechtstijl", - "victory": "Gewonnen!", - "defeat": "Verloren...", - "lobbies": [ - { - "text": "X-gevecht", - "lobby": "X" - }, - { - "text": "Chaosgevecht (sessie)", - "lobby": "SERIES" - }, - { - "text": "Chaosgevecht (open)", - "lobby": "OPEN" - }, - { - "text": "Privégevecht", - "lobby": "PRIVATE" - } - ], - "modes": [ - { - "text": "Grondoorlog", - "mode": "TW" - }, - { - "text": "Spetterzone", - "mode": "SZ" - }, - { - "text": "Torentwist", - "mode": "TC" - }, - { - "text": "Bazookarper", - "mode": "RM" - }, - { - "text": "Schelpenstrijd", - "mode": "CB" - } - ], - "modeWraps": [ - { - "text": "Grond- oorlog", - "mode": "TW" - }, - { - "text": "Spetter- zone", - "mode": "SZ" - }, - { - "text": "Toren- twist", - "mode": "TC" - }, - { - "text": "Bazoo- karper", - "mode": "RM" - }, - { - "text": "Schelpen- strijd", - "mode": "CB" - } - ], - "stages": [ - { - "text": "Lemurië Centraal", - "stageId": 23 - }, - { - "text": "Barbeelbaden", - "stageId": 13 - }, - { - "text": "Waterwonderland", - "stageId": 11 - }, - { - "text": "Knooppunt Kabeljauw", - "stageId": 24 - }, - { - "text": "Bami-Byte", - "stageId": 21 - }, - { - "text": "Forelviaduct", - "stageId": 1 - }, - { - "text": "Ruisvoornruïnes", - "stageId": 15 - }, - { - "text": "El Dorade-resort", - "stageId": 7 - }, - { - "text": "Baleinbuurt", - "stageId": 12 - }, - { - "text": "Brandingbrug", - "stageId": 5 - }, - { - "text": "Bultrugbazaar", - "stageId": 10 - }, - { - "text": "Walruswerf", - "stageId": 9 - }, - { - "text": "Klipvisklipper", - "stageId": 14 - }, - { - "text": "Galerie Le Guppy", - "stageId": 6 - }, - { - "text": "Trekvis Airport", - "stageId": 22 - }, - { - "text": "Blauwvin-depot", - "stageId": 20 - }, - { - "text": "Zilversmelt-hoogovens", - "stageId": 4 - }, - { - "text": "Winkelcentrum Poon & Zeehaan", - "stageId": 17 - }, - { - "text": "Visstickdistrict", - "stageId": 18 - }, - { - "text": "Baarsreservoir", - "stageId": 3 - }, - { - "text": "Tarbot Transport", - "stageId": 19 - }, - { - "text": "Koraalcampus", - "stageId": 8 - }, - { - "text": "Lekkerbektrack", - "stageId": 16 - }, - { - "text": "Boter-bij-de-vismarkt", - "stageId": 2 - }, - { - "text": "Roggentrog", - "stageId": 0 - } - ] - }, - { - "lang": "EUru", - "modeLabel": "Режим", - "victory": "Успех!", - "defeat": "Провал", - "lobbies": [ - { - "text": "Бой X", - "lobby": "X" - }, - { - "text": "Стихийный бой (серия)", - "lobby": "SERIES" - }, - { - "text": "Стихийный бой (открытый)", - "lobby": "OPEN" - }, - { - "text": "Частный бой", - "lobby": "PRIVATE" - } - ], - "modes": [ - { - "text": "Бой за район", - "mode": "TW" - }, - { - "text": "Бой за зоны", - "mode": "SZ" - }, - { - "text": "Бой за башню", - "mode": "TC" - }, - { - "text": "Мегакарп", - "mode": "RM" - }, - { - "text": "Устробол", - "mode": "CB" - } - ], - "modeWraps": [ - { - "text": "Устро- бол", - "mode": "CB" - } - ], - "stages": [ - { - "text": "Вокзал Лемурия", - "stageId": 23 - }, - { - "text": "Тухловодск", - "stageId": 13 - }, - { - "text": "Луна-парк «Язь»", - "stageId": 11 - }, - { - "text": "Район Дека", - "stageId": 24 - }, - { - "text": "«Роборамэн»", - "stageId": 21 - }, - { - "text": "Угрево-Скатово", - "stageId": 1 - }, - { - "text": "Рыбожирные руины", - "stageId": 15 - }, - { - "text": "Спа-курорт «Золотая рыбка»", - "stageId": 7 - }, - { - "text": "Микрорайон «Камбалово»", - "stageId": 12 - }, - { - "text": "Акулий мост", - "stageId": 5 - }, - { - "text": "«Горбуша-Маркет»", - "stageId": 10 - }, - { - "text": "Осетровые верфи", - "stageId": 9 - }, - { - "text": "«Манта-Мария»", - "stageId": 14 - }, - { - "text": "Галерея «Де Берикс»", - "stageId": 6 - }, - { - "text": "Аэропорт Пенково", - "stageId": 22 - }, - { - "text": "Тунцовое депо", - "stageId": 20 - }, - { - "text": "Цех «Тартар»", - "stageId": 4 - }, - { - "text": "УсоногТорг", - "stageId": 17 - }, - { - "text": "Крабхэттен", - "stageId": 18 - }, - { - "text": "Приливослив", - "stageId": 3 - }, - { - "text": "«Палтус-карго»", - "stageId": 19 - }, - { - "text": "Академия «Лепота»", - "stageId": 8 - }, - { - "text": "Велозал «9-й вал»", - "stageId": 16 - }, - { - "text": "Рынок «Свисторыб»", - "stageId": 2 - }, - { - "text": "Опаленное ущелье", - "stageId": 0 - } - ] - }, - { - "lang": "JPja", - "modeLabel": "ルール", - "victory": "WIN!", - "defeat": "LOSE...", - "lobbies": [ - { - "text": "Xマッチ", - "lobby": "X" - }, - { - "text": "バンカラマッチ(チャレンジ)", - "lobby": "SERIES" - }, - { - "text": "バンカラマッチ(オープン)", - "lobby": "OPEN" - }, - { - "text": "プライベートマッチ", - "lobby": "PRIVATE" - } - ], - "modes": [ - { - "text": "ナワバリバトル", - "mode": "TW" - }, - { - "text": "ガチエリア", - "mode": "SZ" - }, - { - "text": "ガチヤグラ", - "mode": "TC" - }, - { - "text": "ガチホコバトル", - "mode": "RM" - }, - { - "text": "ガチアサリ", - "mode": "CB" - } - ], - "modeWraps": [], - "stages": [ - { - "text": "リュウグウターミナル", - "stageId": 23 - }, - { - "text": "クサヤ温泉", - "stageId": 13 - }, - { - "text": "スメーシーワールド", - "stageId": 11 - }, - { - "text": "デカライン高架下", - "stageId": 24 - }, - { - "text": "バイガイ亭", - "stageId": 21 - }, - { - "text": "ゴンズイ地区", - "stageId": 1 - }, - { - "text": "ナンプラー遺跡", - "stageId": 15 - }, - { - "text": "マヒマヒリゾート&スパ", - "stageId": 7 - }, - { - "text": "ヒラメが丘団地", - "stageId": 12 - }, - { - "text": "マサバ海峡大橋", - "stageId": 5 - }, - { - "text": "ザトウマーケット", - "stageId": 10 - }, - { - "text": "チョウザメ造船", - "stageId": 9 - }, - { - "text": "マンタマリア号", - "stageId": 14 - }, - { - "text": "キンメダイ美術館", - "stageId": 6 - }, - { - "text": "カジキ空港", - "stageId": 22 - }, - { - "text": "ネギトロ炭鉱", - "stageId": 20 - }, - { - "text": "ナメロウ金属", - "stageId": 4 - }, - { - "text": "タラポートショッピングパーク", - "stageId": 17 - }, - { - "text": "タカアシ経済特区", - "stageId": 18 - }, - { - "text": "マテガイ放水路", - "stageId": 3 - }, - { - "text": "オヒョウ海運", - "stageId": 19 - }, - { - "text": "海女美術大学", - "stageId": 8 - }, - { - "text": "コンブトラック", - "stageId": 16 - }, - { - "text": "ヤガラ市場", - "stageId": 2 - }, - { - "text": "ユノハナ大渓谷", - "stageId": 0 - } - ] - }, - { - "lang": "KRko", - "modeLabel": "룰", - "victory": "WIN!", - "defeat": "LOSE…", - "lobbies": [ - { - "text": "X 매치", - "lobby": "X" - }, - { - "text": "카오폴리스 매치(챌린지)", - "lobby": "SERIES" - }, - { - "text": "카오폴리스 매치(오픈)", - "lobby": "OPEN" - }, - { - "text": "프라이빗 매치", - "lobby": "PRIVATE" - } - ], - "modes": [ - { - "text": "영역 배틀", - "mode": "TW" - }, - { - "text": "랭크 에어리어", - "mode": "SZ" - }, - { - "text": "랭크 타워", - "mode": "TC" - }, - { - "text": "랭크 피시 배틀", - "mode": "RM" - }, - { - "text": "랭크 바지락", - "mode": "CB" - } - ], - "modeWraps": [], - "stages": [ - { - "text": "용궁 터미널", - "stageId": 23 - }, - { - "text": "과메기 온천", - "stageId": 13 - }, - { - "text": "초밥 월드", - "stageId": 11 - }, - { - "text": "데카 라인 고가 밑", - "stageId": 24 - }, - { - "text": "수랑정", - "stageId": 21 - }, - { - "text": "메기 지구", - "stageId": 1 - }, - { - "text": "남플라 유적", - "stageId": 15 - }, - { - "text": "만새기 리조트&스파", - "stageId": 7 - }, - { - "text": "넙치 언덕 단지", - "stageId": 12 - }, - { - "text": "고등어 해협 대교", - "stageId": 5 - }, - { - "text": "혹등 마켓", - "stageId": 10 - }, - { - "text": "철갑상어 조선소", - "stageId": 9 - }, - { - "text": "만타 마리아호", - "stageId": 14 - }, - { - "text": "도미 미술관", - "stageId": 6 - }, - { - "text": "청새치 공항", - "stageId": 22 - }, - { - "text": "참치 탄광", - "stageId": 20 - }, - { - "text": "나메로우 금속", - "stageId": 4 - }, - { - "text": "대구 포트 쇼핑 파크", - "stageId": 17 - }, - { - "text": "거미게 경제특구", - "stageId": 18 - }, - { - "text": "맛조개 방수로", - "stageId": 3 - }, - { - "text": "광어 해운", - "stageId": 19 - }, - { - "text": "해녀 미술 대학", - "stageId": 8 - }, - { - "text": "다시마 트랙", - "stageId": 16 - }, - { - "text": "대치 시장", - "stageId": 2 - }, - { - "text": "석순 대협곡", - "stageId": 0 - } - ] - }, - { - "lang": "TWzh", - "modeLabel": "規則", - "victory": "WIN!", - "defeat": "LOSE……", - "lobbies": [ - { - "text": "X比賽", - "lobby": "X" - }, - { - "text": "蠻頹比賽(挑戰)", - "lobby": "SERIES" - }, - { - "text": "蠻頹比賽(開放)", - "lobby": "OPEN" - }, - { - "text": "私人比賽", - "lobby": "PRIVATE" - } - ], - "modes": [ - { - "text": "占地對戰", - "mode": "TW" - }, - { - "text": "真格區域", - "mode": "SZ" - }, - { - "text": "真格塔樓", - "mode": "TC" - }, - { - "text": "真格魚虎對戰", - "mode": "RM" - }, - { - "text": "真格蛤蜊", - "mode": "CB" - } - ], - "modeWraps": [], - "stages": [ - { - "text": "龍宮轉運站", - "stageId": 23 - }, - { - "text": "臭魚乾溫泉", - "stageId": 13 - }, - { - "text": "醋飯海洋世界", - "stageId": 11 - }, - { - "text": "十路通高架橋下", - "stageId": 24 - }, - { - "text": "貝見亭", - "stageId": 21 - }, - { - "text": "鰻鯰區", - "stageId": 1 - }, - { - "text": "魚露遺跡", - "stageId": 15 - }, - { - "text": "鬼頭刀SPA度假區", - "stageId": 7 - }, - { - "text": "比目魚住宅區", - "stageId": 12 - }, - { - "text": "真鯖跨海大橋", - "stageId": 5 - }, - { - "text": "座頭購物中心", - "stageId": 10 - }, - { - "text": "鱘魚造船廠", - "stageId": 9 - }, - { - "text": "鬼蝠魟瑪利亞號", - "stageId": 14 - }, - { - "text": "金眼鯛美術館", - "stageId": 6 - }, - { - "text": "旗魚機場", - "stageId": 22 - }, - { - "text": "蔥鮪魚煤礦場", - "stageId": 20 - }, - { - "text": "魚肉碎金屬", - "stageId": 4 - }, - { - "text": "塔拉波特購物公園", - "stageId": 17 - }, - { - "text": "高腳經濟特區", - "stageId": 18 - }, - { - "text": "竹蟶疏洪道", - "stageId": 3 - }, - { - "text": "大比目魚海運中心", - "stageId": 19 - }, - { - "text": "海女美術大學", - "stageId": 8 - }, - { - "text": "昆布賽道", - "stageId": 16 - }, - { - "text": "煙管魚市場", - "stageId": 2 - }, - { - "text": "溫泉花大峽谷", - "stageId": 0 - } - ] - }, - { - "lang": "USen", - "modeLabel": "Mode", - "victory": "VICTORY", - "defeat": "DEFEAT", - "lobbies": [ - { - "text": "X Battle", - "lobby": "X" - }, - { - "text": "Anarchy Battle (Series)", - "lobby": "SERIES" - }, - { - "text": "Anarchy Battle (Open)", - "lobby": "OPEN" - }, - { - "text": "Private Battle", - "lobby": "PRIVATE" - } - ], - "modes": [ - { - "text": "Turf War", - "mode": "TW" - }, - { - "text": "Splat Zones", - "mode": "SZ" - }, - { - "text": "Tower Control", - "mode": "TC" - }, - { - "text": "Rainmaker", - "mode": "RM" - }, - { - "text": "Clam Blitz", - "mode": "CB" - } - ], - "modeWraps": [], - "stages": [ - { - "text": "Lemuria Hub", - "stageId": 23 - }, - { - "text": "Brinewater Springs", - "stageId": 13 - }, - { - "text": "Wahoo World", - "stageId": 11 - }, - { - "text": "Urchin Underpass", - "stageId": 24 - }, - { - "text": "Robo ROM-en", - "stageId": 21 - }, - { - "text": "Eeltail Alley", - "stageId": 1 - }, - { - "text": "Um'ami Ruins", - "stageId": 15 - }, - { - "text": "Mahi-Mahi Resort", - "stageId": 7 - }, - { - "text": "Flounder Heights", - "stageId": 12 - }, - { - "text": "Hammerhead Bridge", - "stageId": 5 - }, - { - "text": "MakoMart", - "stageId": 10 - }, - { - "text": "Sturgeon Shipyard", - "stageId": 9 - }, - { - "text": "Manta Maria", - "stageId": 14 - }, - { - "text": "Museum d'Alfonsino", - "stageId": 6 - }, - { - "text": "Marlin Airport", - "stageId": 22 - }, - { - "text": "Bluefin Depot", - "stageId": 20 - }, - { - "text": "Mincemeat Metalworks", - "stageId": 4 - }, - { - "text": "Barnacle & Dime", - "stageId": 17 - }, - { - "text": "Crableg Capital", - "stageId": 18 - }, - { - "text": "Undertow Spillway", - "stageId": 3 - }, - { - "text": "Shipshape Cargo Co.", - "stageId": 19 - }, - { - "text": "Inkblot Art Academy", - "stageId": 8 - }, - { - "text": "Humpback Pump Track", - "stageId": 16 - }, - { - "text": "Hagglefish Market", - "stageId": 2 - }, - { - "text": "Scorch Gorge", - "stageId": 0 - } - ] - }, - { - "lang": "USes", - "modeLabel": "Estilo", - "victory": "¡Victoria!", - "defeat": "Derrota", - "lobbies": [ - { - "text": "Combate X", - "lobby": "X" - }, - { - "text": "Combate caótico (formal)", - "lobby": "SERIES" - }, - { - "text": "Combate caótico (informal)", - "lobby": "OPEN" - }, - { - "text": "Combate privado", - "lobby": "PRIVATE" - } - ], - "modes": [ - { - "text": "Territorial", - "mode": "TW" - }, - { - "text": "Pintazonas", - "mode": "SZ" - }, - { - "text": "Torreón", - "mode": "TC" - }, - { - "text": "Pez dorado", - "mode": "RM" - }, - { - "text": "Asalto almeja", - "mode": "CB" - } - ], - "modeWraps": [], - "stages": [ - { - "text": "Terminal Dragón", - "stageId": 23 - }, - { - "text": "Balneario Aguasal", - "stageId": 13 - }, - { - "text": "Pirañalandia", - "stageId": 11 - }, - { - "text": "Parque Viaducto", - "stageId": 24 - }, - { - "text": "Factoría RAMen", - "stageId": 21 - }, - { - "text": "Callejones Crustáceo", - "stageId": 1 - }, - { - "text": "Ruinas Ceviche", - "stageId": 15 - }, - { - "text": "Spa Cala Bacalao", - "stageId": 7 - }, - { - "text": "Complejo Medusa", - "stageId": 12 - }, - { - "text": "Puente Salmón", - "stageId": 5 - }, - { - "text": "Ultramarinos Orca", - "stageId": 10 - }, - { - "text": "Astillero Beluga", - "stageId": 9 - }, - { - "text": "Corbeta Corvina", - "stageId": 14 - }, - { - "text": "Museo del Pargo", - "stageId": 6 - }, - { - "text": "Aeropuerto Marlín", - "stageId": 22 - }, - { - "text": "Mina costera", - "stageId": 20 - }, - { - "text": "Desguace Mero", - "stageId": 4 - }, - { - "text": "Unicentro Carite", - "stageId": 17 - }, - { - "text": "Distrito Rascalópodo", - "stageId": 18 - }, - { - "text": "Desagüe Navajuela", - "stageId": 3 - }, - { - "text": "Carguero Platija", - "stageId": 19 - }, - { - "text": "Instituto Coralino", - "stageId": 8 - }, - { - "text": "Tiburódromo", - "stageId": 16 - }, - { - "text": "Mercado Lenguado", - "stageId": 2 - }, - { - "text": "Desfiladero Fumarola", - "stageId": 0 - } - ] - }, - { - "lang": "USfr", - "modeLabel": "Mode", - "victory": "Victoire!", - "defeat": "Défaite...", - "lobbies": [ - { - "text": "Match X", - "lobby": "X" - }, - { - "text": "Match anarchie (série)", - "lobby": "SERIES" - }, - { - "text": "Match anarchie (ouvert)", - "lobby": "OPEN" - }, - { - "text": "Match privé", - "lobby": "PRIVATE" - } - ], - "modes": [ - { - "text": "Guerre de territoire", - "mode": "TW" - }, - { - "text": "Défense de zone", - "mode": "SZ" - }, - { - "text": "Expédition risquée", - "mode": "TC" - }, - { - "text": "Mission Bazookarpe", - "mode": "RM" - }, - { - "text": "Pluie de palourdes", - "mode": "CB" - } - ], - "modeWraps": [], - "stages": [ - { - "text": "Gare Aiguillat", - "stageId": 23 - }, - { - "text": "Sources Sauret", - "stageId": 13 - }, - { - "text": "Parc Carapince", - "stageId": 11 - }, - { - "text": "Passage Turbot", - "stageId": 24 - }, - { - "text": "Arène Méca-ramen", - "stageId": 21 - }, - { - "text": "Banlieue Balibot", - "stageId": 1 - }, - { - "text": "Ruines Uma'mi", - "stageId": 15 - }, - { - "text": "Spa C-ta-C", - "stageId": 7 - }, - { - "text": "Appartements Filament", - "stageId": 12 - }, - { - "text": "Pont Esturgeon", - "stageId": 5 - }, - { - "text": "Supermarché Cétacé", - "stageId": 10 - }, - { - "text": "Chantier Narval", - "stageId": 9 - }, - { - "text": "Manta Maria", - "stageId": 14 - }, - { - "text": "Galeries Guppy", - "stageId": 6 - }, - { - "text": "Terminal Rorqual", - "stageId": 22 - }, - { - "text": "Mine marine", - "stageId": 20 - }, - { - "text": "Casse Rascasse", - "stageId": 4 - }, - { - "text": "Halles de Port-merlu", - "stageId": 17 - }, - { - "text": "Quartier Crabe-ciels", - "stageId": 18 - }, - { - "text": "Réservoir Rigadelle", - "stageId": 3 - }, - { - "text": "Cargo Flétan", - "stageId": 19 - }, - { - "text": "Institut Calm'arts", - "stageId": 8 - }, - { - "text": "Piste Méroule", - "stageId": 16 - }, - { - "text": "Marché Grefin", - "stageId": 2 - }, - { - "text": "Canyon aux colonnes", - "stageId": 0 - } - ] - } + { + lang: "CNzh", + modeLabel: "规则", + victory: "WIN!", + defeat: "LOSE...", + lobbies: [ + { + text: "X比赛", + lobby: "X", + }, + { + text: "蛮颓比赛(挑战)", + lobby: "SERIES", + }, + { + text: "蛮颓比赛(开放)", + lobby: "OPEN", + }, + { + text: "私人比赛", + lobby: "PRIVATE", + }, + ], + modes: [ + { + text: "占地对战", + mode: "TW", + }, + { + text: "真格区域", + mode: "SZ", + }, + { + text: "真格塔楼", + mode: "TC", + }, + { + text: "真格鱼虎对战", + mode: "RM", + }, + { + text: "真格蛤蜊", + mode: "CB", + }, + ], + modeWraps: [], + stages: [ + { + text: "龙宫转运站", + stageId: 23, + }, + { + text: "臭鱼干温泉", + stageId: 13, + }, + { + text: "醋饭海洋世界", + stageId: 11, + }, + { + text: "十路通高架桥下", + stageId: 24, + }, + { + text: "贝见亭", + stageId: 21, + }, + { + text: "鳗鲶区", + stageId: 1, + }, + { + text: "鱼露遗迹", + stageId: 15, + }, + { + text: "鬼头刀SPA度假区", + stageId: 7, + }, + { + text: "比目鱼住宅区", + stageId: 12, + }, + { + text: "真鲭跨海大桥", + stageId: 5, + }, + { + text: "座头购物中心", + stageId: 10, + }, + { + text: "鲟鱼造船厂", + stageId: 9, + }, + { + text: "鬼蝠鲼玛利亚号", + stageId: 14, + }, + { + text: "金眼鲷美术馆", + stageId: 6, + }, + { + text: "旗鱼机场", + stageId: 22, + }, + { + text: "葱鲔鱼煤矿场", + stageId: 20, + }, + { + text: "鱼肉碎金属", + stageId: 4, + }, + { + text: "塔拉波特购物公园", + stageId: 17, + }, + { + text: "高脚经济特区", + stageId: 18, + }, + { + text: "竹蛏疏洪道", + stageId: 3, + }, + { + text: "大比目鱼海运中心", + stageId: 19, + }, + { + text: "海女美术大学", + stageId: 8, + }, + { + text: "昆布赛道", + stageId: 16, + }, + { + text: "烟管鱼市场", + stageId: 2, + }, + { + text: "温泉花大峡谷", + stageId: 0, + }, + ], + }, + { + lang: "EUde", + modeLabel: "Kampfart", + victory: "Sieg!", + defeat: "Verloren...", + lobbies: [ + { + text: "X-Kampf", + lobby: "X", + }, + { + text: "Anarchie-Kampf (Serie)", + lobby: "SERIES", + }, + { + text: "Anarchie-Kampf (Offen)", + lobby: "OPEN", + }, + { + text: "Privater Kampf", + lobby: "PRIVATE", + }, + ], + modes: [ + { + text: "Revierkampf", + mode: "TW", + }, + { + text: "Herrschaft", + mode: "SZ", + }, + { + text: "Turm-Kommando", + mode: "TC", + }, + { + text: "Operation Goldfisch", + mode: "RM", + }, + { + text: "Muschelchaos", + mode: "CB", + }, + ], + modeWraps: [ + { + text: "Revier- kampf", + mode: "TW", + }, + { + text: "Muschel- chaos", + mode: "CB", + }, + ], + stages: [ + { + text: "Bahnhof Lemuria", + stageId: 23, + }, + { + text: "Kusaya-Quellen", + stageId: 13, + }, + { + text: "Flunder-Funpark", + stageId: 11, + }, + { + text: "Dekabahnstation", + stageId: 24, + }, + { + text: "ROM & RAMen", + stageId: 21, + }, + { + text: "Streifenaal-Straße", + stageId: 1, + }, + { + text: "Um'ami-Ruinen", + stageId: 15, + }, + { + text: "Mahi-Mahi-Resort", + stageId: 7, + }, + { + text: "Schollensiedlung", + stageId: 12, + }, + { + text: "Makrelenbrücke", + stageId: 5, + }, + { + text: "Cetacea-Markt", + stageId: 10, + }, + { + text: "Störwerft", + stageId: 9, + }, + { + text: "Manta Maria", + stageId: 14, + }, + { + text: "Pinakoithek", + stageId: 6, + }, + { + text: "La Ola Airport", + stageId: 22, + }, + { + text: "Blauflossen-Depot", + stageId: 20, + }, + { + text: "Aalstahl-Metallwerk", + stageId: 4, + }, + { + text: "Talerfisch & Pock", + stageId: 17, + }, + { + text: "Seespinnen-Skyline", + stageId: 18, + }, + { + text: "Schwertmuschel-Reservoir", + stageId: 3, + }, + { + text: "Frachtschiff Schwerfisch", + stageId: 19, + }, + { + text: "Perlmutt-Akademie", + stageId: 8, + }, + { + text: "Buckelwal-Piste", + stageId: 16, + }, + { + text: "Schnapperchen-Basar", + stageId: 2, + }, + { + text: "Sengkluft", + stageId: 0, + }, + ], + }, + { + lang: "EUen", + modeLabel: "Mode", + victory: "VICTORY", + defeat: "DEFEAT", + lobbies: [ + { + text: "X Battle", + lobby: "X", + }, + { + text: "Anarchy Battle (Series)", + lobby: "SERIES", + }, + { + text: "Anarchy Battle (Open)", + lobby: "OPEN", + }, + { + text: "Private Battle", + lobby: "PRIVATE", + }, + ], + modes: [ + { + text: "Turf War", + mode: "TW", + }, + { + text: "Splat Zones", + mode: "SZ", + }, + { + text: "Tower Control", + mode: "TC", + }, + { + text: "Rainmaker", + mode: "RM", + }, + { + text: "Clam Blitz", + mode: "CB", + }, + ], + modeWraps: [], + stages: [ + { + text: "Lemuria Hub", + stageId: 23, + }, + { + text: "Brinewater Springs", + stageId: 13, + }, + { + text: "Wahoo World", + stageId: 11, + }, + { + text: "Urchin Underpass", + stageId: 24, + }, + { + text: "Robo ROM-en", + stageId: 21, + }, + { + text: "Eeltail Alley", + stageId: 1, + }, + { + text: "Um'ami Ruins", + stageId: 15, + }, + { + text: "Mahi-Mahi Resort", + stageId: 7, + }, + { + text: "Flounder Heights", + stageId: 12, + }, + { + text: "Hammerhead Bridge", + stageId: 5, + }, + { + text: "MakoMart", + stageId: 10, + }, + { + text: "Sturgeon Shipyard", + stageId: 9, + }, + { + text: "Manta Maria", + stageId: 14, + }, + { + text: "Museum d'Alfonsino", + stageId: 6, + }, + { + text: "Marlin Airport", + stageId: 22, + }, + { + text: "Bluefin Depot", + stageId: 20, + }, + { + text: "Mincemeat Metalworks", + stageId: 4, + }, + { + text: "Barnacle & Dime", + stageId: 17, + }, + { + text: "Crableg Capital", + stageId: 18, + }, + { + text: "Undertow Spillway", + stageId: 3, + }, + { + text: "Shipshape Cargo Co.", + stageId: 19, + }, + { + text: "Inkblot Art Academy", + stageId: 8, + }, + { + text: "Humpback Pump Track", + stageId: 16, + }, + { + text: "Hagglefish Market", + stageId: 2, + }, + { + text: "Scorch Gorge", + stageId: 0, + }, + ], + }, + { + lang: "EUes", + modeLabel: "Estilo", + victory: "¡Victoria!", + defeat: "Derrota", + lobbies: [ + { + text: "Combate X", + lobby: "X", + }, + { + text: "Combate caótico (formal)", + lobby: "SERIES", + }, + { + text: "Combate caótico (informal)", + lobby: "OPEN", + }, + { + text: "Combate privado", + lobby: "PRIVATE", + }, + ], + modes: [ + { + text: "Territorial", + mode: "TW", + }, + { + text: "Pintazonas", + mode: "SZ", + }, + { + text: "Torre", + mode: "TC", + }, + { + text: "Pez dorado", + mode: "RM", + }, + { + text: "Asalto almeja", + mode: "CB", + }, + ], + modeWraps: [ + { + text: "Terri- torial", + mode: "TW", + }, + { + text: "Pinta- zonas", + mode: "SZ", + }, + ], + stages: [ + { + text: "Terminal Dragón", + stageId: 23, + }, + { + text: "Balneario Aguasal", + stageId: 13, + }, + { + text: "Pirañalandia", + stageId: 11, + }, + { + text: "Parque Viaducto", + stageId: 24, + }, + { + text: "Factoría RAMen", + stageId: 21, + }, + { + text: "Callejones Crustáceo", + stageId: 1, + }, + { + text: "Ruinas Ceviche", + stageId: 15, + }, + { + text: "Spa Cala Bacalao", + stageId: 7, + }, + { + text: "Complejo Medusa", + stageId: 12, + }, + { + text: "Puente Salmón", + stageId: 5, + }, + { + text: "Ultramarinos Orca", + stageId: 10, + }, + { + text: "Astillero Beluga", + stageId: 9, + }, + { + text: "Corbeta Corvina", + stageId: 14, + }, + { + text: "Museo del Pargo", + stageId: 6, + }, + { + text: "Aeropuerto Marlín", + stageId: 22, + }, + { + text: "Mina costera", + stageId: 20, + }, + { + text: "Desguace Mero", + stageId: 4, + }, + { + text: "Centro Comercial Fletán", + stageId: 17, + }, + { + text: "Distrito Rascalópodo", + stageId: 18, + }, + { + text: "Cisterna Navajuela", + stageId: 3, + }, + { + text: "Carguero Platija", + stageId: 19, + }, + { + text: "Instituto Coralino", + stageId: 8, + }, + { + text: "Tiburódromo", + stageId: 16, + }, + { + text: "Mercado Lenguado", + stageId: 2, + }, + { + text: "Desfiladero Fumarola", + stageId: 0, + }, + ], + }, + { + lang: "EUfr", + modeLabel: "Mode", + victory: "Victoire !", + defeat: "Défaite...", + lobbies: [ + { + text: "Match X", + lobby: "X", + }, + { + text: "Match anarchie (série)", + lobby: "SERIES", + }, + { + text: "Match anarchie (ouvert)", + lobby: "OPEN", + }, + { + text: "Match privé", + lobby: "PRIVATE", + }, + ], + modes: [ + { + text: "Guerre de territoire", + mode: "TW", + }, + { + text: "Défense de zone", + mode: "SZ", + }, + { + text: "Expédition risquée", + mode: "TC", + }, + { + text: "Mission Bazookarpe", + mode: "RM", + }, + { + text: "Pluie de palourdes", + mode: "CB", + }, + ], + modeWraps: [], + stages: [ + { + text: "Gare Aiguillat", + stageId: 23, + }, + { + text: "Sources Sauret", + stageId: 13, + }, + { + text: "Parc Carapince", + stageId: 11, + }, + { + text: "Passage Turbot", + stageId: 24, + }, + { + text: "Arène Méca-ramen", + stageId: 21, + }, + { + text: "Banlieue Balibot", + stageId: 1, + }, + { + text: "Ruines Uma'mi", + stageId: 15, + }, + { + text: "Club Ca$halot", + stageId: 7, + }, + { + text: "Lotissement Filament", + stageId: 12, + }, + { + text: "Pont Esturgeon", + stageId: 5, + }, + { + text: "Supermarché Cétacé", + stageId: 10, + }, + { + text: "Chantier Narval", + stageId: 9, + }, + { + text: "Manta Maria", + stageId: 14, + }, + { + text: "Galeries Guppy", + stageId: 6, + }, + { + text: "Terminal Rorqual", + stageId: 22, + }, + { + text: "Mine marine", + stageId: 20, + }, + { + text: "Casse Rascasse", + stageId: 4, + }, + { + text: "Halles de Port-merlu", + stageId: 17, + }, + { + text: "Quartier Crabe-ciels", + stageId: 18, + }, + { + text: "Réservoir Rigadelle", + stageId: 3, + }, + { + text: "Chaland Flétan", + stageId: 19, + }, + { + text: "Institut Calam'arts", + stageId: 8, + }, + { + text: "Piste Méroule", + stageId: 16, + }, + { + text: "Marché Grefin", + stageId: 2, + }, + { + text: "Canyon aux colonnes", + stageId: 0, + }, + ], + }, + { + lang: "EUit", + modeLabel: "Modalità", + victory: "Vittoria", + defeat: "Sconfitta", + lobbies: [ + { + text: "Partita X", + lobby: "X", + }, + { + text: "Partita anarchica (Serie)", + lobby: "SERIES", + }, + { + text: "Partita anarchica (Aperta)", + lobby: "OPEN", + }, + { + text: "Partita privata", + lobby: "PRIVATE", + }, + ], + modes: [ + { + text: "Mischia mollusca", + mode: "TW", + }, + { + text: "Zona splat", + mode: "SZ", + }, + { + text: "Torre mobile", + mode: "TC", + }, + { + text: "Bazookarp", + mode: "RM", + }, + { + text: "Vongol gol", + mode: "CB", + }, + ], + modeWraps: [], + stages: [ + { + text: "Stazione Lemuria", + stageId: 23, + }, + { + text: "Fermenterme", + stageId: 13, + }, + { + text: "Soglioland", + stageId: 11, + }, + { + text: "Periferia urbana", + stageId: 24, + }, + { + text: "Roboramen", + stageId: 21, + }, + { + text: "Sobborgo Siluriano", + stageId: 1, + }, + { + text: "Rovine Salsadipesce", + stageId: 15, + }, + { + text: "Villanguilla", + stageId: 7, + }, + { + text: "Cime Sogliolose", + stageId: 12, + }, + { + text: "Ponte Sgombro", + stageId: 5, + }, + { + text: "Mercatotano", + stageId: 10, + }, + { + text: "Cantiere Pinnenere", + stageId: 9, + }, + { + text: "Manta Maria", + stageId: 14, + }, + { + text: "Museo di Cefalò", + stageId: 6, + }, + { + text: "Aeroporto Ondabianca", + stageId: 22, + }, + { + text: "Molo Mollusco", + stageId: 20, + }, + { + text: "Discarica Tritatutto", + stageId: 4, + }, + { + text: "Empolporio", + stageId: 17, + }, + { + text: "Granchiacieli", + stageId: 18, + }, + { + text: "Cisterna Cernia", + stageId: 3, + }, + { + text: "Platessa Trasporti S.r.l.", + stageId: 19, + }, + { + text: "Campus Hippocampus", + stageId: 8, + }, + { + text: "Tintodromo Montecarpa", + stageId: 16, + }, + { + text: "Mercato Fruttato", + stageId: 2, + }, + { + text: "Grank Canyon", + stageId: 0, + }, + ], + }, + { + lang: "EUnl", + modeLabel: "Vechtstijl", + victory: "Gewonnen!", + defeat: "Verloren...", + lobbies: [ + { + text: "X-gevecht", + lobby: "X", + }, + { + text: "Chaosgevecht (sessie)", + lobby: "SERIES", + }, + { + text: "Chaosgevecht (open)", + lobby: "OPEN", + }, + { + text: "Privégevecht", + lobby: "PRIVATE", + }, + ], + modes: [ + { + text: "Grondoorlog", + mode: "TW", + }, + { + text: "Spetterzone", + mode: "SZ", + }, + { + text: "Torentwist", + mode: "TC", + }, + { + text: "Bazookarper", + mode: "RM", + }, + { + text: "Schelpenstrijd", + mode: "CB", + }, + ], + modeWraps: [ + { + text: "Grond- oorlog", + mode: "TW", + }, + { + text: "Spetter- zone", + mode: "SZ", + }, + { + text: "Toren- twist", + mode: "TC", + }, + { + text: "Bazoo- karper", + mode: "RM", + }, + { + text: "Schelpen- strijd", + mode: "CB", + }, + ], + stages: [ + { + text: "Lemurië Centraal", + stageId: 23, + }, + { + text: "Barbeelbaden", + stageId: 13, + }, + { + text: "Waterwonderland", + stageId: 11, + }, + { + text: "Knooppunt Kabeljauw", + stageId: 24, + }, + { + text: "Bami-Byte", + stageId: 21, + }, + { + text: "Forelviaduct", + stageId: 1, + }, + { + text: "Ruisvoornruïnes", + stageId: 15, + }, + { + text: "El Dorade-resort", + stageId: 7, + }, + { + text: "Baleinbuurt", + stageId: 12, + }, + { + text: "Brandingbrug", + stageId: 5, + }, + { + text: "Bultrugbazaar", + stageId: 10, + }, + { + text: "Walruswerf", + stageId: 9, + }, + { + text: "Klipvisklipper", + stageId: 14, + }, + { + text: "Galerie Le Guppy", + stageId: 6, + }, + { + text: "Trekvis Airport", + stageId: 22, + }, + { + text: "Blauwvin-depot", + stageId: 20, + }, + { + text: "Zilversmelt-hoogovens", + stageId: 4, + }, + { + text: "Winkelcentrum Poon & Zeehaan", + stageId: 17, + }, + { + text: "Visstickdistrict", + stageId: 18, + }, + { + text: "Baarsreservoir", + stageId: 3, + }, + { + text: "Tarbot Transport", + stageId: 19, + }, + { + text: "Koraalcampus", + stageId: 8, + }, + { + text: "Lekkerbektrack", + stageId: 16, + }, + { + text: "Boter-bij-de-vismarkt", + stageId: 2, + }, + { + text: "Roggentrog", + stageId: 0, + }, + ], + }, + { + lang: "EUru", + modeLabel: "Режим", + victory: "Успех!", + defeat: "Провал", + lobbies: [ + { + text: "Бой X", + lobby: "X", + }, + { + text: "Стихийный бой (серия)", + lobby: "SERIES", + }, + { + text: "Стихийный бой (открытый)", + lobby: "OPEN", + }, + { + text: "Частный бой", + lobby: "PRIVATE", + }, + ], + modes: [ + { + text: "Бой за район", + mode: "TW", + }, + { + text: "Бой за зоны", + mode: "SZ", + }, + { + text: "Бой за башню", + mode: "TC", + }, + { + text: "Мегакарп", + mode: "RM", + }, + { + text: "Устробол", + mode: "CB", + }, + ], + modeWraps: [ + { + text: "Устро- бол", + mode: "CB", + }, + ], + stages: [ + { + text: "Вокзал Лемурия", + stageId: 23, + }, + { + text: "Тухловодск", + stageId: 13, + }, + { + text: "Луна-парк «Язь»", + stageId: 11, + }, + { + text: "Район Дека", + stageId: 24, + }, + { + text: "«Роборамэн»", + stageId: 21, + }, + { + text: "Угрево-Скатово", + stageId: 1, + }, + { + text: "Рыбожирные руины", + stageId: 15, + }, + { + text: "Спа-курорт «Золотая рыбка»", + stageId: 7, + }, + { + text: "Микрорайон «Камбалово»", + stageId: 12, + }, + { + text: "Акулий мост", + stageId: 5, + }, + { + text: "«Горбуша-Маркет»", + stageId: 10, + }, + { + text: "Осетровые верфи", + stageId: 9, + }, + { + text: "«Манта-Мария»", + stageId: 14, + }, + { + text: "Галерея «Де Берикс»", + stageId: 6, + }, + { + text: "Аэропорт Пенково", + stageId: 22, + }, + { + text: "Тунцовое депо", + stageId: 20, + }, + { + text: "Цех «Тартар»", + stageId: 4, + }, + { + text: "УсоногТорг", + stageId: 17, + }, + { + text: "Крабхэттен", + stageId: 18, + }, + { + text: "Приливослив", + stageId: 3, + }, + { + text: "«Палтус-карго»", + stageId: 19, + }, + { + text: "Академия «Лепота»", + stageId: 8, + }, + { + text: "Велозал «9-й вал»", + stageId: 16, + }, + { + text: "Рынок «Свисторыб»", + stageId: 2, + }, + { + text: "Опаленное ущелье", + stageId: 0, + }, + ], + }, + { + lang: "JPja", + modeLabel: "ルール", + victory: "WIN!", + defeat: "LOSE...", + lobbies: [ + { + text: "Xマッチ", + lobby: "X", + }, + { + text: "バンカラマッチ(チャレンジ)", + lobby: "SERIES", + }, + { + text: "バンカラマッチ(オープン)", + lobby: "OPEN", + }, + { + text: "プライベートマッチ", + lobby: "PRIVATE", + }, + ], + modes: [ + { + text: "ナワバリバトル", + mode: "TW", + }, + { + text: "ガチエリア", + mode: "SZ", + }, + { + text: "ガチヤグラ", + mode: "TC", + }, + { + text: "ガチホコバトル", + mode: "RM", + }, + { + text: "ガチアサリ", + mode: "CB", + }, + ], + modeWraps: [], + stages: [ + { + text: "リュウグウターミナル", + stageId: 23, + }, + { + text: "クサヤ温泉", + stageId: 13, + }, + { + text: "スメーシーワールド", + stageId: 11, + }, + { + text: "デカライン高架下", + stageId: 24, + }, + { + text: "バイガイ亭", + stageId: 21, + }, + { + text: "ゴンズイ地区", + stageId: 1, + }, + { + text: "ナンプラー遺跡", + stageId: 15, + }, + { + text: "マヒマヒリゾート&スパ", + stageId: 7, + }, + { + text: "ヒラメが丘団地", + stageId: 12, + }, + { + text: "マサバ海峡大橋", + stageId: 5, + }, + { + text: "ザトウマーケット", + stageId: 10, + }, + { + text: "チョウザメ造船", + stageId: 9, + }, + { + text: "マンタマリア号", + stageId: 14, + }, + { + text: "キンメダイ美術館", + stageId: 6, + }, + { + text: "カジキ空港", + stageId: 22, + }, + { + text: "ネギトロ炭鉱", + stageId: 20, + }, + { + text: "ナメロウ金属", + stageId: 4, + }, + { + text: "タラポートショッピングパーク", + stageId: 17, + }, + { + text: "タカアシ経済特区", + stageId: 18, + }, + { + text: "マテガイ放水路", + stageId: 3, + }, + { + text: "オヒョウ海運", + stageId: 19, + }, + { + text: "海女美術大学", + stageId: 8, + }, + { + text: "コンブトラック", + stageId: 16, + }, + { + text: "ヤガラ市場", + stageId: 2, + }, + { + text: "ユノハナ大渓谷", + stageId: 0, + }, + ], + }, + { + lang: "KRko", + modeLabel: "룰", + victory: "WIN!", + defeat: "LOSE…", + lobbies: [ + { + text: "X 매치", + lobby: "X", + }, + { + text: "카오폴리스 매치(챌린지)", + lobby: "SERIES", + }, + { + text: "카오폴리스 매치(오픈)", + lobby: "OPEN", + }, + { + text: "프라이빗 매치", + lobby: "PRIVATE", + }, + ], + modes: [ + { + text: "영역 배틀", + mode: "TW", + }, + { + text: "랭크 에어리어", + mode: "SZ", + }, + { + text: "랭크 타워", + mode: "TC", + }, + { + text: "랭크 피시 배틀", + mode: "RM", + }, + { + text: "랭크 바지락", + mode: "CB", + }, + ], + modeWraps: [], + stages: [ + { + text: "용궁 터미널", + stageId: 23, + }, + { + text: "과메기 온천", + stageId: 13, + }, + { + text: "초밥 월드", + stageId: 11, + }, + { + text: "데카 라인 고가 밑", + stageId: 24, + }, + { + text: "수랑정", + stageId: 21, + }, + { + text: "메기 지구", + stageId: 1, + }, + { + text: "남플라 유적", + stageId: 15, + }, + { + text: "만새기 리조트&스파", + stageId: 7, + }, + { + text: "넙치 언덕 단지", + stageId: 12, + }, + { + text: "고등어 해협 대교", + stageId: 5, + }, + { + text: "혹등 마켓", + stageId: 10, + }, + { + text: "철갑상어 조선소", + stageId: 9, + }, + { + text: "만타 마리아호", + stageId: 14, + }, + { + text: "도미 미술관", + stageId: 6, + }, + { + text: "청새치 공항", + stageId: 22, + }, + { + text: "참치 탄광", + stageId: 20, + }, + { + text: "나메로우 금속", + stageId: 4, + }, + { + text: "대구 포트 쇼핑 파크", + stageId: 17, + }, + { + text: "거미게 경제특구", + stageId: 18, + }, + { + text: "맛조개 방수로", + stageId: 3, + }, + { + text: "광어 해운", + stageId: 19, + }, + { + text: "해녀 미술 대학", + stageId: 8, + }, + { + text: "다시마 트랙", + stageId: 16, + }, + { + text: "대치 시장", + stageId: 2, + }, + { + text: "석순 대협곡", + stageId: 0, + }, + ], + }, + { + lang: "TWzh", + modeLabel: "規則", + victory: "WIN!", + defeat: "LOSE……", + lobbies: [ + { + text: "X比賽", + lobby: "X", + }, + { + text: "蠻頹比賽(挑戰)", + lobby: "SERIES", + }, + { + text: "蠻頹比賽(開放)", + lobby: "OPEN", + }, + { + text: "私人比賽", + lobby: "PRIVATE", + }, + ], + modes: [ + { + text: "占地對戰", + mode: "TW", + }, + { + text: "真格區域", + mode: "SZ", + }, + { + text: "真格塔樓", + mode: "TC", + }, + { + text: "真格魚虎對戰", + mode: "RM", + }, + { + text: "真格蛤蜊", + mode: "CB", + }, + ], + modeWraps: [], + stages: [ + { + text: "龍宮轉運站", + stageId: 23, + }, + { + text: "臭魚乾溫泉", + stageId: 13, + }, + { + text: "醋飯海洋世界", + stageId: 11, + }, + { + text: "十路通高架橋下", + stageId: 24, + }, + { + text: "貝見亭", + stageId: 21, + }, + { + text: "鰻鯰區", + stageId: 1, + }, + { + text: "魚露遺跡", + stageId: 15, + }, + { + text: "鬼頭刀SPA度假區", + stageId: 7, + }, + { + text: "比目魚住宅區", + stageId: 12, + }, + { + text: "真鯖跨海大橋", + stageId: 5, + }, + { + text: "座頭購物中心", + stageId: 10, + }, + { + text: "鱘魚造船廠", + stageId: 9, + }, + { + text: "鬼蝠魟瑪利亞號", + stageId: 14, + }, + { + text: "金眼鯛美術館", + stageId: 6, + }, + { + text: "旗魚機場", + stageId: 22, + }, + { + text: "蔥鮪魚煤礦場", + stageId: 20, + }, + { + text: "魚肉碎金屬", + stageId: 4, + }, + { + text: "塔拉波特購物公園", + stageId: 17, + }, + { + text: "高腳經濟特區", + stageId: 18, + }, + { + text: "竹蟶疏洪道", + stageId: 3, + }, + { + text: "大比目魚海運中心", + stageId: 19, + }, + { + text: "海女美術大學", + stageId: 8, + }, + { + text: "昆布賽道", + stageId: 16, + }, + { + text: "煙管魚市場", + stageId: 2, + }, + { + text: "溫泉花大峽谷", + stageId: 0, + }, + ], + }, + { + lang: "USen", + modeLabel: "Mode", + victory: "VICTORY", + defeat: "DEFEAT", + lobbies: [ + { + text: "X Battle", + lobby: "X", + }, + { + text: "Anarchy Battle (Series)", + lobby: "SERIES", + }, + { + text: "Anarchy Battle (Open)", + lobby: "OPEN", + }, + { + text: "Private Battle", + lobby: "PRIVATE", + }, + ], + modes: [ + { + text: "Turf War", + mode: "TW", + }, + { + text: "Splat Zones", + mode: "SZ", + }, + { + text: "Tower Control", + mode: "TC", + }, + { + text: "Rainmaker", + mode: "RM", + }, + { + text: "Clam Blitz", + mode: "CB", + }, + ], + modeWraps: [], + stages: [ + { + text: "Lemuria Hub", + stageId: 23, + }, + { + text: "Brinewater Springs", + stageId: 13, + }, + { + text: "Wahoo World", + stageId: 11, + }, + { + text: "Urchin Underpass", + stageId: 24, + }, + { + text: "Robo ROM-en", + stageId: 21, + }, + { + text: "Eeltail Alley", + stageId: 1, + }, + { + text: "Um'ami Ruins", + stageId: 15, + }, + { + text: "Mahi-Mahi Resort", + stageId: 7, + }, + { + text: "Flounder Heights", + stageId: 12, + }, + { + text: "Hammerhead Bridge", + stageId: 5, + }, + { + text: "MakoMart", + stageId: 10, + }, + { + text: "Sturgeon Shipyard", + stageId: 9, + }, + { + text: "Manta Maria", + stageId: 14, + }, + { + text: "Museum d'Alfonsino", + stageId: 6, + }, + { + text: "Marlin Airport", + stageId: 22, + }, + { + text: "Bluefin Depot", + stageId: 20, + }, + { + text: "Mincemeat Metalworks", + stageId: 4, + }, + { + text: "Barnacle & Dime", + stageId: 17, + }, + { + text: "Crableg Capital", + stageId: 18, + }, + { + text: "Undertow Spillway", + stageId: 3, + }, + { + text: "Shipshape Cargo Co.", + stageId: 19, + }, + { + text: "Inkblot Art Academy", + stageId: 8, + }, + { + text: "Humpback Pump Track", + stageId: 16, + }, + { + text: "Hagglefish Market", + stageId: 2, + }, + { + text: "Scorch Gorge", + stageId: 0, + }, + ], + }, + { + lang: "USes", + modeLabel: "Estilo", + victory: "¡Victoria!", + defeat: "Derrota", + lobbies: [ + { + text: "Combate X", + lobby: "X", + }, + { + text: "Combate caótico (formal)", + lobby: "SERIES", + }, + { + text: "Combate caótico (informal)", + lobby: "OPEN", + }, + { + text: "Combate privado", + lobby: "PRIVATE", + }, + ], + modes: [ + { + text: "Territorial", + mode: "TW", + }, + { + text: "Pintazonas", + mode: "SZ", + }, + { + text: "Torreón", + mode: "TC", + }, + { + text: "Pez dorado", + mode: "RM", + }, + { + text: "Asalto almeja", + mode: "CB", + }, + ], + modeWraps: [], + stages: [ + { + text: "Terminal Dragón", + stageId: 23, + }, + { + text: "Balneario Aguasal", + stageId: 13, + }, + { + text: "Pirañalandia", + stageId: 11, + }, + { + text: "Parque Viaducto", + stageId: 24, + }, + { + text: "Factoría RAMen", + stageId: 21, + }, + { + text: "Callejones Crustáceo", + stageId: 1, + }, + { + text: "Ruinas Ceviche", + stageId: 15, + }, + { + text: "Spa Cala Bacalao", + stageId: 7, + }, + { + text: "Complejo Medusa", + stageId: 12, + }, + { + text: "Puente Salmón", + stageId: 5, + }, + { + text: "Ultramarinos Orca", + stageId: 10, + }, + { + text: "Astillero Beluga", + stageId: 9, + }, + { + text: "Corbeta Corvina", + stageId: 14, + }, + { + text: "Museo del Pargo", + stageId: 6, + }, + { + text: "Aeropuerto Marlín", + stageId: 22, + }, + { + text: "Mina costera", + stageId: 20, + }, + { + text: "Desguace Mero", + stageId: 4, + }, + { + text: "Unicentro Carite", + stageId: 17, + }, + { + text: "Distrito Rascalópodo", + stageId: 18, + }, + { + text: "Desagüe Navajuela", + stageId: 3, + }, + { + text: "Carguero Platija", + stageId: 19, + }, + { + text: "Instituto Coralino", + stageId: 8, + }, + { + text: "Tiburódromo", + stageId: 16, + }, + { + text: "Mercado Lenguado", + stageId: 2, + }, + { + text: "Desfiladero Fumarola", + stageId: 0, + }, + ], + }, + { + lang: "USfr", + modeLabel: "Mode", + victory: "Victoire!", + defeat: "Défaite...", + lobbies: [ + { + text: "Match X", + lobby: "X", + }, + { + text: "Match anarchie (série)", + lobby: "SERIES", + }, + { + text: "Match anarchie (ouvert)", + lobby: "OPEN", + }, + { + text: "Match privé", + lobby: "PRIVATE", + }, + ], + modes: [ + { + text: "Guerre de territoire", + mode: "TW", + }, + { + text: "Défense de zone", + mode: "SZ", + }, + { + text: "Expédition risquée", + mode: "TC", + }, + { + text: "Mission Bazookarpe", + mode: "RM", + }, + { + text: "Pluie de palourdes", + mode: "CB", + }, + ], + modeWraps: [], + stages: [ + { + text: "Gare Aiguillat", + stageId: 23, + }, + { + text: "Sources Sauret", + stageId: 13, + }, + { + text: "Parc Carapince", + stageId: 11, + }, + { + text: "Passage Turbot", + stageId: 24, + }, + { + text: "Arène Méca-ramen", + stageId: 21, + }, + { + text: "Banlieue Balibot", + stageId: 1, + }, + { + text: "Ruines Uma'mi", + stageId: 15, + }, + { + text: "Spa C-ta-C", + stageId: 7, + }, + { + text: "Appartements Filament", + stageId: 12, + }, + { + text: "Pont Esturgeon", + stageId: 5, + }, + { + text: "Supermarché Cétacé", + stageId: 10, + }, + { + text: "Chantier Narval", + stageId: 9, + }, + { + text: "Manta Maria", + stageId: 14, + }, + { + text: "Galeries Guppy", + stageId: 6, + }, + { + text: "Terminal Rorqual", + stageId: 22, + }, + { + text: "Mine marine", + stageId: 20, + }, + { + text: "Casse Rascasse", + stageId: 4, + }, + { + text: "Halles de Port-merlu", + stageId: 17, + }, + { + text: "Quartier Crabe-ciels", + stageId: 18, + }, + { + text: "Réservoir Rigadelle", + stageId: 3, + }, + { + text: "Cargo Flétan", + stageId: 19, + }, + { + text: "Institut Calm'arts", + stageId: 8, + }, + { + text: "Piste Méroule", + stageId: 16, + }, + { + text: "Marché Grefin", + stageId: 2, + }, + { + text: "Canyon aux colonnes", + stageId: 0, + }, + ], + }, ]; diff --git a/app/features/cv/core/localized.ts b/app/features/cv/core/localized.ts index 625777a8e..66dd6feb0 100644 --- a/app/features/cv/core/localized.ts +++ b/app/features/cv/core/localized.ts @@ -10,92 +10,95 @@ import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import type { CvLobby } from "../cv-types"; import { - LANGUAGE_ENTRIES, - type LocalizedLobby, - type LocalizedMode, - type LocalizedStage, + LANGUAGE_ENTRIES, + type LocalizedLobby, + type LocalizedMode, + type LocalizedStage, } from "./localized-entries"; import { matchKey } from "./text"; function dedupe(items: T[], keyOf: (item: T) => string): T[] { - const seen = new Set(); - return items.filter((item) => { - const k = keyOf(item); - if (seen.has(k)) return false; - seen.add(k); - return true; - }); + const seen = new Set(); + return items.filter((item) => { + const k = keyOf(item); + if (seen.has(k)) return false; + seen.add(k); + return true; + }); } const byText = (e: { text: string }) => matchKey(e.text); export const ALL_LOBBY_ENTRIES: readonly LocalizedLobby[] = dedupe( - LANGUAGE_ENTRIES.flatMap((l) => l.lobbies), - byText, + LANGUAGE_ENTRIES.flatMap((l) => l.lobbies), + byText, ); /** Single-line mode names plus the intro splash's two-line wrap variants. */ export const ALL_MODE_ENTRIES: readonly LocalizedMode[] = dedupe( - LANGUAGE_ENTRIES.flatMap((l) => [...l.modes, ...l.modeWraps]), - byText, + LANGUAGE_ENTRIES.flatMap((l) => [...l.modes, ...l.modeWraps]), + byText, ); export const ALL_STAGE_ENTRIES: readonly LocalizedStage[] = dedupe( - LANGUAGE_ENTRIES.flatMap((l) => l.stages), - byText, + LANGUAGE_ENTRIES.flatMap((l) => l.stages), + byText, ); /** Every language's constant "MODE" intro-splash label. */ export const ALL_MODE_LABELS: readonly string[] = dedupe( - LANGUAGE_ENTRIES.map((l) => l.modeLabel), - matchKey, + LANGUAGE_ENTRIES.map((l) => l.modeLabel), + matchKey, ); /** Replay-browser panel tags; canonical is "VICTORY" or "DEFEAT". */ -export const RESULT_TAG_ENTRIES: readonly { text: string; canonical: string }[] = dedupe( - LANGUAGE_ENTRIES.flatMap((l) => [ - { text: l.victory, canonical: "VICTORY" }, - { text: l.defeat, canonical: "DEFEAT" }, - ]), - byText, +export const RESULT_TAG_ENTRIES: readonly { + text: string; + canonical: string; +}[] = dedupe( + LANGUAGE_ENTRIES.flatMap((l) => [ + { text: l.victory, canonical: "VICTORY" }, + { text: l.defeat, canonical: "DEFEAT" }, + ]), + byText, ); export interface ModeStageCombo { - text: string; - mode: ModeShort; - stageId: StageId; + text: string; + mode: ModeShort; + stageId: StageId; } /** The scoreboard header's " " line, per language. */ export const MODE_STAGE_COMBOS: readonly ModeStageCombo[] = dedupe( - LANGUAGE_ENTRIES.flatMap((l) => - l.modes.flatMap((mode) => - l.stages.map((stage) => ({ - text: `${mode.text} ${stage.text}`, - mode: mode.mode, - stageId: stage.stageId, - })), - ), - ), - byText, + LANGUAGE_ENTRIES.flatMap((l) => + l.modes.flatMap((mode) => + l.stages.map((stage) => ({ + text: `${mode.text} ${stage.text}`, + mode: mode.mode, + stageId: stage.stageId, + })), + ), + ), + byText, ); export interface LobbyModeCombo { - text: string; - lobby: CvLobby; - mode: ModeShort; + text: string; + lobby: CvLobby; + mode: ModeShort; } /** The replay-browser header's " " line, per language. */ export const LOBBY_MODE_COMBOS: readonly LobbyModeCombo[] = dedupe( - LANGUAGE_ENTRIES.flatMap((l) => - l.lobbies.flatMap((lobby) => - l.modes.map((mode) => ({ - text: `${lobby.text} ${mode.text}`, - lobby: lobby.lobby, - mode: mode.mode, - })), - ), - ), - byText, + LANGUAGE_ENTRIES.flatMap((l) => + l.lobbies.flatMap((lobby) => + l.modes.map((mode) => ({ + text: `${lobby.text} ${mode.text}`, + lobby: lobby.lobby, + mode: mode.mode, + })), + ), + ), + byText, ); diff --git a/app/features/cv/core/replay-time.ts b/app/features/cv/core/replay-time.ts index 7b2e3e3db..a50d77aa0 100644 --- a/app/features/cv/core/replay-time.ts +++ b/app/features/cv/core/replay-time.ts @@ -15,7 +15,8 @@ * wins. */ -const TIMESTAMP_RE = /^(\d{1,4})[./-](\d{1,4})[./-](\d{1,4})\s+(\d{1,2}):(\d{2})$/; +const TIMESTAMP_RE = + /^(\d{1,4})[./-](\d{1,4})[./-](\d{1,4})\s+(\d{1,2}):(\d{2})$/; /** How far in the past a reading may land and still count as "recent". */ const RECENT_PAST_MS = 30 * 24 * 60 * 60 * 1000; @@ -31,47 +32,62 @@ type Ymd = { year: number; month: number; day: number }; * moment the replay screen was on-screen, not the send time. */ export function parseReplayTimestamp( - raw: string, - { locale, now }: { locale?: string; now?: number } = {}, + raw: string, + { locale, now }: { locale?: string; now?: number } = {}, ): number | null { - const m = TIMESTAMP_RE.exec(raw.trim()); - if (!m) return null; - const dateParts = [Number(m[1]!), Number(m[2]!), Number(m[3]!)]; - const hours = Number(m[4]!); - const minutes = Number(m[5]!); - if (hours > 23 || minutes > 59) return null; + const m = TIMESTAMP_RE.exec(raw.trim()); + if (!m) return null; + const dateParts = [Number(m[1]!), Number(m[2]!), Number(m[3]!)]; + const hours = Number(m[4]!); + const minutes = Number(m[5]!); + if (hours > 23 || minutes > 59) return null; - // hour 0 or ≥13 only occurs on a 24h clock, and 24h locales are - // near-universally day-first; only an ambiguous hour falls back to the - // browser locale, which may not match the console's - const is24hClock = hours === 0 || hours >= 13; - const resolved = resolveDateParts(dateParts, is24hClock ? true : dayBeforeMonth(locale)); - if (!resolved) return null; + // hour 0 or ≥13 only occurs on a 24h clock, and 24h locales are + // near-universally day-first; only an ambiguous hour falls back to the + // browser locale, which may not match the console's + const is24hClock = hours === 0 || hours >= 13; + const resolved = resolveDateParts( + dateParts, + is24hClock ? true : dayBeforeMonth(locale), + ); + if (!resolved) return null; - const preferred = toEpoch(resolved.preferred, hours, minutes); - if (preferred === null) return null; + const preferred = toEpoch(resolved.preferred, hours, minutes); + if (preferred === null) return null; - if (resolved.swapped) { - const swapped = toEpoch(resolved.swapped, hours, minutes); - const ref = now ?? Date.now(); - if (swapped !== null && isRecent(swapped, ref) && !isRecent(preferred, ref)) { - return swapped; - } - } - return preferred; + if (resolved.swapped) { + const swapped = toEpoch(resolved.swapped, hours, minutes); + const ref = now ?? Date.now(); + if ( + swapped !== null && + isRecent(swapped, ref) && + !isRecent(preferred, ref) + ) { + return swapped; + } + } + return preferred; } function isRecent(t: number, ref: number): boolean { - return t >= ref - RECENT_PAST_MS && t <= ref + FUTURE_SLACK_MS; + return t >= ref - RECENT_PAST_MS && t <= ref + FUTURE_SLACK_MS; } -function toEpoch({ year, month, day }: Ymd, hours: number, minutes: number): number | null { - const date = new Date(year, month - 1, day, hours, minutes); - // the Date constructor rolls invalid dates over (31/2 → 2/3); reject those - if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { - return null; - } - return date.getTime(); +function toEpoch( + { year, month, day }: Ymd, + hours: number, + minutes: number, +): number | null { + const date = new Date(year, month - 1, day, hours, minutes); + // the Date constructor rolls invalid dates over (31/2 → 2/3); reject those + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return null; + } + return date.getTime(); } /** @@ -83,46 +99,48 @@ function toEpoch({ year, month, day }: Ymd, hours: number, minutes: number): num * a guessed split also reports the swapped reading for the recency check. */ function resolveDateParts( - parts: number[], - guessDayFirst: boolean, + parts: number[], + guessDayFirst: boolean, ): { preferred: Ymd; swapped: Ymd | null } | null { - let year: number; - let rest: [number, number]; - let dayFirst: boolean; - let orderKnown = false; - if (parts[0]! >= 100) { - year = parts[0]!; - rest = [parts[1]!, parts[2]!]; - dayFirst = false; - orderKnown = true; - } else { - year = parts[2]! >= 100 ? parts[2]! : 2000 + parts[2]!; - rest = [parts[0]!, parts[1]!]; - dayFirst = guessDayFirst; - } + let year: number; + let rest: [number, number]; + let dayFirst: boolean; + let orderKnown = false; + if (parts[0]! >= 100) { + year = parts[0]!; + rest = [parts[1]!, parts[2]!]; + dayFirst = false; + orderKnown = true; + } else { + year = parts[2]! >= 100 ? parts[2]! : 2000 + parts[2]!; + rest = [parts[0]!, parts[1]!]; + dayFirst = guessDayFirst; + } - const [a, b] = rest; - let day: number; - let month: number; - let guessed = false; - if (a > 12 && b > 12) return null; - if (a > 12) [day, month] = [a, b]; - else if (b > 12) [month, day] = [a, b]; - else { - [day, month] = dayFirst ? [a, b] : [b, a]; - guessed = !orderKnown && a !== b; - } + const [a, b] = rest; + let day: number; + let month: number; + let guessed = false; + if (a > 12 && b > 12) return null; + if (a > 12) [day, month] = [a, b]; + else if (b > 12) [month, day] = [a, b]; + else { + [day, month] = dayFirst ? [a, b] : [b, a]; + guessed = !orderKnown && a !== b; + } - if (month < 1 || month > 12 || day < 1 || day > 31) return null; - return { - preferred: { year, month, day }, - swapped: guessed ? { year, month: day, day: month } : null, - }; + if (month < 1 || month > 12 || day < 1 || day > 31) return null; + return { + preferred: { year, month, day }, + swapped: guessed ? { year, month: day, day: month } : null, + }; } function dayBeforeMonth(locale: string | undefined): boolean { - const parts = new Intl.DateTimeFormat(locale).formatToParts(new Date(2000, 0, 2)); - const dayIndex = parts.findIndex((p) => p.type === "day"); - const monthIndex = parts.findIndex((p) => p.type === "month"); - return dayIndex !== -1 && monthIndex !== -1 && dayIndex < monthIndex; + const parts = new Intl.DateTimeFormat(locale).formatToParts( + new Date(2000, 0, 2), + ); + const dayIndex = parts.findIndex((p) => p.type === "day"); + const monthIndex = parts.findIndex((p) => p.type === "month"); + return dayIndex !== -1 && monthIndex !== -1 && dayIndex < monthIndex; } diff --git a/app/features/cv/core/resources.ts b/app/features/cv/core/resources.ts index 5e68ac0d0..f09be5fb2 100644 --- a/app/features/cv/core/resources.ts +++ b/app/features/cv/core/resources.ts @@ -15,12 +15,12 @@ import { prepareAbilityTemplates } from "./detectors/death/abilities"; import { BURST_ICON_TEMPLATE_SIZES } from "./detectors/death/rois"; import { prepareMinimapAbilityTemplates } from "./detectors/minimap/abilities"; import { - CARD_WEAPON_BACKGROUND, - MINIMAP_WEAPON_INK_THRESHOLD, - MINIMAP_WEAPON_TEMPLATE_SIZES, - SPECIAL_READY_BACKGROUND, - SPECIAL_READY_INK_THRESHOLD, - SUB_TILE_TEMPLATE_SIZES, + CARD_WEAPON_BACKGROUND, + MINIMAP_WEAPON_INK_THRESHOLD, + MINIMAP_WEAPON_TEMPLATE_SIZES, + SPECIAL_READY_BACKGROUND, + SPECIAL_READY_INK_THRESHOLD, + SUB_TILE_TEMPLATE_SIZES, } from "./detectors/minimap/rois"; import type { PlannerStage } from "./detectors/minimap/stage"; import type { ScoreboardResources } from "./detectors/scoreboard/index"; @@ -31,164 +31,178 @@ import type { GlyphSet } from "./glyphs"; import type { FrameData } from "./image"; export interface ResourceIO { - /** ids listed in //manifest.json */ - readManifest(dir: string): Promise; - /** decoded RGBA of //.png */ - readIcon(dir: string, id: string): Promise; - /** glyph atlas by name as a (possibly lazy) getter; () => null when absent */ - loadAtlas(name: string): Promise<() => GlyphSet | null>; - /** planner signature atlas as a getter; () => null when absent */ - loadPlannerStages(): Promise<() => PlannerStage[] | null>; + /** ids listed in //manifest.json */ + readManifest(dir: string): Promise; + /** decoded RGBA of //.png */ + readIcon(dir: string, id: string): Promise; + /** glyph atlas by name as a (possibly lazy) getter; () => null when absent */ + loadAtlas(name: string): Promise<() => GlyphSet | null>; + /** planner signature atlas as a getter; () => null when absent */ + loadPlannerStages(): Promise<() => PlannerStage[] | null>; } /** resource key → atlas name under assets/cv/glyphs/ */ const ATLASES = { - paintDigits: "scoreboard-paint-digits", - statDigits: "scoreboard-stat-digits", - teamDigits: "scoreboard-team-digits", - nameGlyphs: "scoreboard-names", - headerLobbyGlyphs: "scoreboard-header-lobby", - headerLineGlyphs: "scoreboard-header-line", - replayCodeGlyphs: "scoreboard-replay-code", - replayResultGlyphs: "scoreboard-replay-result", - deathWeaponGlyphs: "death-weapon", - deathWeaponJaGlyphs: "death-weapon-ja", - deathTagNameGlyphs: "death-tag-name", - mapStartModeGlyphs: "map-start-mode", - mapStartStageGlyphs: "map-start-stage", + paintDigits: "scoreboard-paint-digits", + statDigits: "scoreboard-stat-digits", + teamDigits: "scoreboard-team-digits", + nameGlyphs: "scoreboard-names", + headerLobbyGlyphs: "scoreboard-header-lobby", + headerLineGlyphs: "scoreboard-header-line", + replayCodeGlyphs: "scoreboard-replay-code", + replayResultGlyphs: "scoreboard-replay-result", + deathWeaponGlyphs: "death-weapon", + deathWeaponJaGlyphs: "death-weapon-ja", + deathTagNameGlyphs: "death-tag-name", + mapStartModeGlyphs: "map-start-mode", + mapStartStageGlyphs: "map-start-stage", } as const; /** Memoize an expensive template/atlas build for the lazy resource getters. */ function lazy(build: () => T): () => T { - let value: T; - let built = false; - return () => { - if (!built) { - value = build(); - built = true; - } - return value; - }; + let value: T; + let built = false; + return () => { + if (!built) { + value = build(); + built = true; + } + return value; + }; } /** Requires loadOpenCV() to have resolved. */ -export async function assembleScoreboardResources(io: ResourceIO): Promise { - const icons = async (dir: string) => { - const ids = await io.readManifest(dir); - return Promise.all(ids.map(async (id) => ({ id, image: await io.readIcon(dir, id) }))); - }; +export async function assembleScoreboardResources( + io: ResourceIO, +): Promise { + const icons = async (dir: string) => { + const ids = await io.readManifest(dir); + return Promise.all( + ids.map(async (id) => ({ id, image: await io.readIcon(dir, id) })), + ); + }; - const [weaponIcons, specialIcons, subIcons, abilityIcons, plannerStages, atlasEntries] = - await Promise.all([ - icons("main-weapons"), - icons("specials"), - icons("sub-weapons"), - icons("abilities"), - io.loadPlannerStages(), - Promise.all( - (Object.entries(ATLASES) as [keyof typeof ATLASES, string][]).map( - async ([key, name]) => [key, await io.loadAtlas(name)] as const, - ), - ), - ]); - const atlas = Object.fromEntries(atlasEntries) as Record< - keyof typeof ATLASES, - () => GlyphSet | null - >; + const [ + weaponIcons, + specialIcons, + subIcons, + abilityIcons, + plannerStages, + atlasEntries, + ] = await Promise.all([ + icons("main-weapons"), + icons("specials"), + icons("sub-weapons"), + icons("abilities"), + io.loadPlannerStages(), + Promise.all( + (Object.entries(ATLASES) as [keyof typeof ATLASES, string][]).map( + async ([key, name]) => [key, await io.loadAtlas(name)] as const, + ), + ), + ]); + const atlas = Object.fromEntries(atlasEntries) as Record< + keyof typeof ATLASES, + () => GlyphSet | null + >; - const weapons = lazy(() => prepareWeaponTemplates(weaponIcons)); - const deathBurstWeapons = lazy(() => - prepareWeaponTemplates(weaponIcons, BURST_ICON_TEMPLATE_SIZES), - ); - const minimapCardWeapons = lazy(() => - prepareWeaponTemplates(weaponIcons, MINIMAP_WEAPON_TEMPLATE_SIZES, { - background: CARD_WEAPON_BACKGROUND, - inkThreshold: MINIMAP_WEAPON_INK_THRESHOLD, - cropToArt: true, - }), - ); - const minimapLightWeapons = lazy(() => - prepareWeaponTemplates(weaponIcons, MINIMAP_WEAPON_TEMPLATE_SIZES, { - background: SPECIAL_READY_BACKGROUND, - inkThreshold: SPECIAL_READY_INK_THRESHOLD, - cropToArt: true, - }), - ); - const specials = lazy(() => prepareSpecialTemplates(specialIcons)); - const minimapSubWeapons = lazy(() => prepareSpecialTemplates(subIcons, SUB_TILE_TEMPLATE_SIZES)); - const abilities = lazy(() => prepareAbilityTemplates(abilityIcons)); - const ownAbilities = lazy(() => prepareOwnAbilityTemplates(abilityIcons)); - const minimapAbilities = lazy(() => prepareMinimapAbilityTemplates(abilityIcons)); + const weapons = lazy(() => prepareWeaponTemplates(weaponIcons)); + const deathBurstWeapons = lazy(() => + prepareWeaponTemplates(weaponIcons, BURST_ICON_TEMPLATE_SIZES), + ); + const minimapCardWeapons = lazy(() => + prepareWeaponTemplates(weaponIcons, MINIMAP_WEAPON_TEMPLATE_SIZES, { + background: CARD_WEAPON_BACKGROUND, + inkThreshold: MINIMAP_WEAPON_INK_THRESHOLD, + cropToArt: true, + }), + ); + const minimapLightWeapons = lazy(() => + prepareWeaponTemplates(weaponIcons, MINIMAP_WEAPON_TEMPLATE_SIZES, { + background: SPECIAL_READY_BACKGROUND, + inkThreshold: SPECIAL_READY_INK_THRESHOLD, + cropToArt: true, + }), + ); + const specials = lazy(() => prepareSpecialTemplates(specialIcons)); + const minimapSubWeapons = lazy(() => + prepareSpecialTemplates(subIcons, SUB_TILE_TEMPLATE_SIZES), + ); + const abilities = lazy(() => prepareAbilityTemplates(abilityIcons)); + const ownAbilities = lazy(() => prepareOwnAbilityTemplates(abilityIcons)); + const minimapAbilities = lazy(() => + prepareMinimapAbilityTemplates(abilityIcons), + ); - return { - get weapons() { - return weapons(); - }, - get deathBurstWeapons() { - return deathBurstWeapons(); - }, - get minimapCardWeapons() { - return minimapCardWeapons(); - }, - get minimapLightWeapons() { - return minimapLightWeapons(); - }, - get specials() { - return specials(); - }, - get minimapSubWeapons() { - return minimapSubWeapons(); - }, - get abilities() { - return abilities(); - }, - get ownAbilities() { - return ownAbilities(); - }, - get minimapAbilities() { - return minimapAbilities(); - }, - get plannerStages() { - return plannerStages(); - }, - get paintDigits() { - return atlas.paintDigits(); - }, - get statDigits() { - return atlas.statDigits(); - }, - get teamDigits() { - return atlas.teamDigits(); - }, - get nameGlyphs() { - return atlas.nameGlyphs(); - }, - get headerLobbyGlyphs() { - return atlas.headerLobbyGlyphs(); - }, - get headerLineGlyphs() { - return atlas.headerLineGlyphs(); - }, - get replayCodeGlyphs() { - return atlas.replayCodeGlyphs(); - }, - get replayResultGlyphs() { - return atlas.replayResultGlyphs(); - }, - get deathWeaponGlyphs() { - return atlas.deathWeaponGlyphs(); - }, - get deathWeaponJaGlyphs() { - return atlas.deathWeaponJaGlyphs(); - }, - get deathTagNameGlyphs() { - return atlas.deathTagNameGlyphs(); - }, - get mapStartModeGlyphs() { - return atlas.mapStartModeGlyphs(); - }, - get mapStartStageGlyphs() { - return atlas.mapStartStageGlyphs(); - }, - }; + return { + get weapons() { + return weapons(); + }, + get deathBurstWeapons() { + return deathBurstWeapons(); + }, + get minimapCardWeapons() { + return minimapCardWeapons(); + }, + get minimapLightWeapons() { + return minimapLightWeapons(); + }, + get specials() { + return specials(); + }, + get minimapSubWeapons() { + return minimapSubWeapons(); + }, + get abilities() { + return abilities(); + }, + get ownAbilities() { + return ownAbilities(); + }, + get minimapAbilities() { + return minimapAbilities(); + }, + get plannerStages() { + return plannerStages(); + }, + get paintDigits() { + return atlas.paintDigits(); + }, + get statDigits() { + return atlas.statDigits(); + }, + get teamDigits() { + return atlas.teamDigits(); + }, + get nameGlyphs() { + return atlas.nameGlyphs(); + }, + get headerLobbyGlyphs() { + return atlas.headerLobbyGlyphs(); + }, + get headerLineGlyphs() { + return atlas.headerLineGlyphs(); + }, + get replayCodeGlyphs() { + return atlas.replayCodeGlyphs(); + }, + get replayResultGlyphs() { + return atlas.replayResultGlyphs(); + }, + get deathWeaponGlyphs() { + return atlas.deathWeaponGlyphs(); + }, + get deathWeaponJaGlyphs() { + return atlas.deathWeaponJaGlyphs(); + }, + get deathTagNameGlyphs() { + return atlas.deathTagNameGlyphs(); + }, + get mapStartModeGlyphs() { + return atlas.mapStartModeGlyphs(); + }, + get mapStartStageGlyphs() { + return atlas.mapStartStageGlyphs(); + }, + }; } diff --git a/app/features/cv/core/text.ts b/app/features/cv/core/text.ts index cc54495a9..dfc05c5ab 100644 --- a/app/features/cv/core/text.ts +++ b/app/features/cv/core/text.ts @@ -3,22 +3,22 @@ */ function editDistance(a: string, b: string): number { - const dp = Array.from({ length: a.length + 1 }, (_, i) => { - const row = new Array(b.length + 1).fill(0); - row[0] = i; - return row; - }); - for (let j = 0; j <= b.length; j++) dp[0]![j] = j; - for (let i = 1; i <= a.length; i++) { - for (let j = 1; j <= b.length; j++) { - dp[i]![j] = Math.min( - dp[i - 1]![j]! + 1, - dp[i]![j - 1]! + 1, - dp[i - 1]![j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1), - ); - } - } - return dp[a.length]![b.length]!; + const dp = Array.from({ length: a.length + 1 }, (_, i) => { + const row = new Array(b.length + 1).fill(0); + row[0] = i; + return row; + }); + for (let j = 0; j <= b.length; j++) dp[0]![j] = j; + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + dp[i]![j] = Math.min( + dp[i - 1]![j]! + 1, + dp[i]![j - 1]! + 1, + dp[i - 1]![j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1), + ); + } + } + return dp[a.length]![b.length]!; } /** @@ -27,58 +27,58 @@ function editDistance(a: string, b: string): number { * the localized closed sets. */ export function matchKey(s: string): string { - return s - .toLowerCase() - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/\s+/g, ""); + return s + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/\s+/g, ""); } export interface ClosestMatch { - entry: T; - /** 1 = exact (ignoring case/spaces/diacritics), 0 = nothing in common */ - score: number; + entry: T; + /** 1 = exact (ignoring case/spaces/diacritics), 0 = nothing in common */ + score: number; } /** Snap an OCR reading to the closest of a closed set of arbitrary entries. */ export function closestBy( - reading: string, - entries: readonly T[], - textOf: (entry: T) => string, + reading: string, + entries: readonly T[], + textOf: (entry: T) => string, ): ClosestMatch | null { - const r = matchKey(reading); - let best: ClosestMatch | null = null; - for (const entry of entries) { - const e = matchKey(textOf(entry)); - const d = editDistance(r, e); - const score = 1 - d / Math.max(r.length, e.length, 1); - if (!best || score > best.score) best = { entry, score }; - } - return best; + const r = matchKey(reading); + let best: ClosestMatch | null = null; + for (const entry of entries) { + const e = matchKey(textOf(entry)); + const d = editDistance(r, e); + const score = 1 - d / Math.max(r.length, e.length, 1); + if (!best || score > best.score) best = { entry, score }; + } + return best; } /** Snap an OCR reading to the closest entry of a closed set of strings. */ export function closestEntry( - reading: string, - entries: readonly T[], + reading: string, + entries: readonly T[], ): ClosestMatch | null { - return closestBy(reading, entries, (e) => e); + return closestBy(reading, entries, (e) => e); } /** Rank every entry of a closed set against an OCR reading, best first. */ export function rankBy( - reading: string, - entries: readonly T[], - textOf: (entry: T) => string, + reading: string, + entries: readonly T[], + textOf: (entry: T) => string, ): ClosestMatch[] { - const r = matchKey(reading); - return entries - .map((entry) => { - const e = matchKey(textOf(entry)); - const d = editDistance(r, e); - return { entry, score: 1 - d / Math.max(r.length, e.length, 1) }; - }) - .sort((a, b) => b.score - a.score); + const r = matchKey(reading); + return entries + .map((entry) => { + const e = matchKey(textOf(entry)); + const d = editDistance(r, e); + return { entry, score: 1 - d / Math.max(r.length, e.length, 1) }; + }) + .sort((a, b) => b.score - a.score); } /** @@ -87,7 +87,7 @@ export function rankBy( * utilities stay decoupled from the recognition module. */ export interface ReadSegment { - candidates?: readonly { char: string; score: number }[]; + candidates?: readonly { char: string; score: number }[]; } /** @@ -102,36 +102,42 @@ export interface ReadSegment { * 1 - templateScore), so the two scales must not share thresholds. */ export function rankByRead( - segments: readonly ReadSegment[], - entries: readonly T[], - textOf: (entry: T) => string, + segments: readonly ReadSegment[], + entries: readonly T[], + textOf: (entry: T) => string, ): ClosestMatch[] { - // per-segment candidate score by match key (max wins when keys collide, - // e.g. dakuten variants folding onto one base kana) - const segScores = segments.map((seg) => { - const m = new Map(); - for (const c of seg.candidates ?? []) { - const k = matchKey(c.char); - const s = Math.max(0, Math.min(1, c.score)); - if (s > (m.get(k) ?? 0)) m.set(k, s); - } - return m; - }); - const n = segScores.length; - return entries - .map((entry) => { - const t = matchKey(textOf(entry)); - const m = t.length; - const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); - for (let i = 1; i <= n; i++) dp[i]![0] = i; - for (let j = 1; j <= m; j++) dp[0]![j] = j; - for (let i = 1; i <= n; i++) { - for (let j = 1; j <= m; j++) { - const sub = 1 - (segScores[i - 1]!.get(t[j - 1]!) ?? 0); - dp[i]![j] = Math.min(dp[i - 1]![j]! + 1, dp[i]![j - 1]! + 1, dp[i - 1]![j - 1]! + sub); - } - } - return { entry, score: 1 - dp[n]![m]! / Math.max(n, m, 1) }; - }) - .sort((a, b) => b.score - a.score); + // per-segment candidate score by match key (max wins when keys collide, + // e.g. dakuten variants folding onto one base kana) + const segScores = segments.map((seg) => { + const m = new Map(); + for (const c of seg.candidates ?? []) { + const k = matchKey(c.char); + const s = Math.max(0, Math.min(1, c.score)); + if (s > (m.get(k) ?? 0)) m.set(k, s); + } + return m; + }); + const n = segScores.length; + return entries + .map((entry) => { + const t = matchKey(textOf(entry)); + const m = t.length; + const dp = Array.from({ length: n + 1 }, () => + new Array(m + 1).fill(0), + ); + for (let i = 1; i <= n; i++) dp[i]![0] = i; + for (let j = 1; j <= m; j++) dp[0]![j] = j; + for (let i = 1; i <= n; i++) { + for (let j = 1; j <= m; j++) { + const sub = 1 - (segScores[i - 1]!.get(t[j - 1]!) ?? 0); + dp[i]![j] = Math.min( + dp[i - 1]![j]! + 1, + dp[i]![j - 1]! + 1, + dp[i - 1]![j - 1]! + sub, + ); + } + } + return { entry, score: 1 - dp[n]![m]! / Math.max(n, m, 1) }; + }) + .sort((a, b) => b.score - a.score); } diff --git a/app/features/cv/core/timeline/index.ts b/app/features/cv/core/timeline/index.ts index deb40723e..a4feabbf3 100644 --- a/app/features/cv/core/timeline/index.ts +++ b/app/features/cv/core/timeline/index.ts @@ -9,79 +9,80 @@ import type { DetectedEvent } from "../detectors/types"; import { sameScoreboardMatch } from "./same-scoreboard"; export interface TimelineOptions { - /** same-type events closer than this (seconds) merge */ - mergeWindow: number; - /** - * per-type mergeWindow overrides: repeatable events need a window shorter - * than the minimum spacing between two real occurrences - */ - mergeWindowByType: Record; - /** - * per-type content guard: same-type events inside the window merge only - * when this returns true for their data — screens whose distinct real - * occurrences can appear seconds apart (replay browsing) need content, - * not time, to tell them apart. Absent = merge on time alone. - */ - sameEventDataByType: Record boolean>; - /** events below this confidence are dropped */ - minConfidence: number; + /** same-type events closer than this (seconds) merge */ + mergeWindow: number; + /** + * per-type mergeWindow overrides: repeatable events need a window shorter + * than the minimum spacing between two real occurrences + */ + mergeWindowByType: Record; + /** + * per-type content guard: same-type events inside the window merge only + * when this returns true for their data — screens whose distinct real + * occurrences can appear seconds apart (replay browsing) need content, + * not time, to tell them apart. Absent = merge on time alone. + */ + sameEventDataByType: Record boolean>; + /** events below this confidence are dropped */ + minConfidence: number; } const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = { - mergeWindow: 30, - // the death screen shows for ~5s and respawn takes ~8.5s, so repeat frames - // of one death land within the window while consecutive deaths are outside; - // players flick the map open for 1-3s and each open is a fresh sample - // (slots read differently across opens), so minimap frames merge only - // within one open - mergeWindowByType: { Death: 8, Minimap: 5 }, - sameEventDataByType: { - [SCOREBOARD_EVENT_TYPE]: sameScoreboardMatch, - [SCOREBOARD_REPLAY_EVENT_TYPE]: sameScoreboardMatch, - }, - minConfidence: 0.6, + mergeWindow: 30, + // the death screen shows for ~5s and respawn takes ~8.5s, so repeat frames + // of one death land within the window while consecutive deaths are outside; + // players flick the map open for 1-3s and each open is a fresh sample + // (slots read differently across opens), so minimap frames merge only + // within one open + mergeWindowByType: { Death: 8, Minimap: 5 }, + sameEventDataByType: { + [SCOREBOARD_EVENT_TYPE]: sameScoreboardMatch, + [SCOREBOARD_REPLAY_EVENT_TYPE]: sameScoreboardMatch, + }, + minConfidence: 0.6, }; export type TimelineAction = - | { action: "added"; event: DetectedEvent } - | { action: "replaced"; event: DetectedEvent; replaced: DetectedEvent } - | { action: "merged"; into: DetectedEvent } - | { action: "dropped"; reason: "low-confidence" }; + | { action: "added"; event: DetectedEvent } + | { action: "replaced"; event: DetectedEvent; replaced: DetectedEvent } + | { action: "merged"; into: DetectedEvent } + | { action: "dropped"; reason: "low-confidence" }; export class TimelineBuilder { - #events: DetectedEvent[] = []; - #options: TimelineOptions; + #events: DetectedEvent[] = []; + #options: TimelineOptions; - constructor(options: Partial = {}) { - this.#options = { ...DEFAULT_TIMELINE_OPTIONS, ...options }; - } + constructor(options: Partial = {}) { + this.#options = { ...DEFAULT_TIMELINE_OPTIONS, ...options }; + } - get events(): readonly DetectedEvent[] { - return this.#events; - } + get events(): readonly DetectedEvent[] { + return this.#events; + } - push(event: DetectedEvent): TimelineAction { - if (event.confidence < this.#options.minConfidence) { - return { action: "dropped", reason: "low-confidence" }; - } - const window = this.#options.mergeWindowByType[event.type] ?? this.#options.mergeWindow; - const same = this.#options.sameEventDataByType[event.type]; - const near = this.#events.find( - (e) => - e.type === event.type && - Math.abs(e.t - event.t) <= window && - (same?.(e.data, event.data) ?? true), - ); - if (!near) { - this.#events.push(event); - this.#events.sort((a, b) => a.t - b.t); - return { action: "added", event }; - } - if (event.confidence > near.confidence) { - this.#events[this.#events.indexOf(near)] = event; - this.#events.sort((a, b) => a.t - b.t); - return { action: "replaced", event, replaced: near }; - } - return { action: "merged", into: near }; - } + push(event: DetectedEvent): TimelineAction { + if (event.confidence < this.#options.minConfidence) { + return { action: "dropped", reason: "low-confidence" }; + } + const window = + this.#options.mergeWindowByType[event.type] ?? this.#options.mergeWindow; + const same = this.#options.sameEventDataByType[event.type]; + const near = this.#events.find( + (e) => + e.type === event.type && + Math.abs(e.t - event.t) <= window && + (same?.(e.data, event.data) ?? true), + ); + if (!near) { + this.#events.push(event); + this.#events.sort((a, b) => a.t - b.t); + return { action: "added", event }; + } + if (event.confidence > near.confidence) { + this.#events[this.#events.indexOf(near)] = event; + this.#events.sort((a, b) => a.t - b.t); + return { action: "replaced", event, replaced: near }; + } + return { action: "merged", into: near }; + } } diff --git a/app/features/cv/core/timeline/same-scoreboard.ts b/app/features/cv/core/timeline/same-scoreboard.ts index 1f2d25c9d..985162541 100644 --- a/app/features/cv/core/timeline/same-scoreboard.ts +++ b/app/features/cv/core/timeline/same-scoreboard.ts @@ -28,30 +28,32 @@ const PAINT_MIN_READ = 6; /** Positions where two equal-length strings disagree (∞ on length mismatch). */ function charDiff(a: string, b: string): number { - if (a.length !== b.length) return Number.POSITIVE_INFINITY; - let n = 0; - for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) n++; - return n; + if (a.length !== b.length) return Number.POSITIVE_INFINITY; + let n = 0; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) n++; + return n; } /** Size of the multiset intersection of two number lists. */ function multisetOverlap(a: number[], b: number[]): number { - const counts = new Map(); - for (const v of a) counts.set(v, (counts.get(v) ?? 0) + 1); - let n = 0; - for (const v of b) { - const c = counts.get(v) ?? 0; - if (c > 0) { - counts.set(v, c - 1); - n++; - } - } - return n; + const counts = new Map(); + for (const v of a) counts.set(v, (counts.get(v) ?? 0) + 1); + let n = 0; + for (const v of b) { + const c = counts.get(v) ?? 0; + if (c > 0) { + counts.set(v, c - 1); + n++; + } + } + return n; } /** The non-null paint values, order-free (winner side can flip between reads). */ function paints(data: Partial): number[] { - return (data.players ?? []).map((p) => p.paint).filter((p): p is number => p !== null); + return (data.players ?? []) + .map((p) => p.paint) + .filter((p): p is number => p !== null); } /** @@ -59,30 +61,36 @@ function paints(data: Partial): number[] { * only compared when both sides read them, so a null read never splits. */ export function sameScoreboardMatch(a: unknown, b: unknown): boolean { - const da = a as Partial & Partial; - const db = b as Partial & Partial; + const da = a as Partial & Partial; + const db = b as Partial & Partial; - const stageA = da.stage ?? null; - const stageB = db.stage ?? null; - if (stageA !== null && stageB !== null && stageA !== stageB) return false; + const stageA = da.stage ?? null; + const stageB = db.stage ?? null; + if (stageA !== null && stageB !== null && stageA !== stageB) return false; - // replay detail screens: the recording timestamp and the replay code - // both identify the replay (timestamp reads are shape-validated, so a - // garbled read comes back null rather than as a different valid time) - const tsA = da.timestamp ?? null; - const tsB = db.timestamp ?? null; - if (tsA !== null && tsB !== null && tsA !== tsB) return false; - const codeA = da.replayCode ?? null; - const codeB = db.replayCode ?? null; - if (codeA !== null && codeB !== null && charDiff(codeA, codeB) >= CODE_DIFF_MIN) return false; + // replay detail screens: the recording timestamp and the replay code + // both identify the replay (timestamp reads are shape-validated, so a + // garbled read comes back null rather than as a different valid time) + const tsA = da.timestamp ?? null; + const tsB = db.timestamp ?? null; + if (tsA !== null && tsB !== null && tsA !== tsB) return false; + const codeA = da.replayCode ?? null; + const codeB = db.replayCode ?? null; + if ( + codeA !== null && + codeB !== null && + charDiff(codeA, codeB) >= CODE_DIFF_MIN + ) + return false; - // same lobby, different map: names stay identical, but the paint totals - // are distinctive per match — near-zero overlap means a different board - const pa = paints(da); - const pb = paints(db); - if (pa.length >= PAINT_MIN_READ && pb.length >= PAINT_MIN_READ) { - if (multisetOverlap(pa, pb) < Math.min(pa.length, pb.length) / 2) return false; - } + // same lobby, different map: names stay identical, but the paint totals + // are distinctive per match — near-zero overlap means a different board + const pa = paints(da); + const pb = paints(db); + if (pa.length >= PAINT_MIN_READ && pb.length >= PAINT_MIN_READ) { + if (multisetOverlap(pa, pb) < Math.min(pa.length, pb.length) / 2) + return false; + } - return true; + return true; } diff --git a/app/features/cv/core/vod-matches.ts b/app/features/cv/core/vod-matches.ts index aa531ad6e..1ef0bc159 100644 --- a/app/features/cv/core/vod-matches.ts +++ b/app/features/cv/core/vod-matches.ts @@ -25,9 +25,19 @@ * stage, or a full set of weapons. */ -import type { MainWeaponId, ModeShort, StageId } from "~/modules/in-game-lists/types"; -import { MAP_START_EVENT_TYPE, type MapStartData } from "./detectors/map-start/index"; -import { MINIMAP_EVENT_TYPE, type MinimapData } from "./detectors/minimap/index"; +import type { + MainWeaponId, + ModeShort, + StageId, +} from "~/modules/in-game-lists/types"; +import { + MAP_START_EVENT_TYPE, + type MapStartData, +} from "./detectors/map-start/index"; +import { + MINIMAP_EVENT_TYPE, + type MinimapData, +} from "./detectors/minimap/index"; import { SCOREBOARD_EVENT_TYPES } from "./detectors/registry"; import type { ScoreboardData } from "./detectors/scoreboard/index"; import type { DetectedEvent } from "./detectors/types"; @@ -49,51 +59,51 @@ const MATCH_GAP_SECONDS = 300; /** One VoD match as prefilled into sendou.ink's /vods/new form. */ export interface VodMatch { - /** whole seconds into the video the match starts at */ - startsAt: number; - /** null when no source read it */ - mode: ModeShort | null; - /** - * true when `mode` is the fabricated PoC default rather than a real - * read — lets the endpoint/form treat it as a guess, not a detection - */ - modeAssumed: boolean; - /** null when no source read it */ - stage: StageId | null; - /** - * the match's weapons, alpha team then bravo team: sendou main-weapon - * ids, or null for a slot that never read - */ - weapons: (MainWeaponId | null)[]; + /** whole seconds into the video the match starts at */ + startsAt: number; + /** null when no source read it */ + mode: ModeShort | null; + /** + * true when `mode` is the fabricated PoC default rather than a real + * read — lets the endpoint/form treat it as a guess, not a detection + */ + modeAssumed: boolean; + /** null when no source read it */ + stage: StageId | null; + /** + * the match's weapons, alpha team then bravo team: sendou main-weapon + * ids, or null for a slot that never read + */ + weapons: (MainWeaponId | null)[]; } /** A match being accumulated as the timeline is walked. */ interface OpenMatch { - mapStart: DetectedEvent | null; - firstMinimap: DetectedEvent | null; - minimaps: DetectedEvent[]; - scoreboard: DetectedEvent | null; - /** - * per-stage read counts across the match's minimaps (a MapStart's stage - * seeds it); the plurality winner delimits same-vs-next map and is the - * reported stage, so one misread frame can't poison the whole match - */ - stageVotes: Map; - /** t of the last minimap added, for the gap check */ - lastMinimapT: number | null; + mapStart: DetectedEvent | null; + firstMinimap: DetectedEvent | null; + minimaps: DetectedEvent[]; + scoreboard: DetectedEvent | null; + /** + * per-stage read counts across the match's minimaps (a MapStart's stage + * seeds it); the plurality winner delimits same-vs-next map and is the + * reported stage, so one misread frame can't poison the whole match + */ + stageVotes: Map; + /** t of the last minimap added, for the gap check */ + lastMinimapT: number | null; } /** Plurality stage of the reads so far; insertion order breaks ties. */ function leadingStage(votes: Map): StageId | null { - let winner: StageId | null = null; - let best = 0; - for (const [stage, count] of votes) { - if (count > best) { - winner = stage; - best = count; - } - } - return winner; + let winner: StageId | null = null; + let best = 0; + for (const [stage, count] of votes) { + if (count > best) { + winner = stage; + best = count; + } + } + return winner; } /** @@ -102,98 +112,100 @@ function leadingStage(votes: Map): StageId | null { * stage and time gap. */ export function buildVodMatches(events: readonly DetectedEvent[]): VodMatch[] { - const sorted = [...events].sort((a, b) => a.t - b.t); - const matches: VodMatch[] = []; + const sorted = [...events].sort((a, b) => a.t - b.t); + const matches: VodMatch[] = []; - // For each minimap event, the next minimap's non-null stage read (walked - // backwards). A stage change only splits when the next read doesn't refute - // it: a lone frame disagreeing with both its match's running stage and the - // following read is a misread to fold in as a minority vote, not a match - // boundary. With no later read the change stands. - const nextStage = new Map(); - let carry: StageId | null = null; - for (let i = sorted.length - 1; i >= 0; i--) { - const event = sorted[i]!; - if (event.type !== MINIMAP_EVENT_TYPE) continue; - nextStage.set(event, carry); - carry = (event.data as MinimapData).stage ?? carry; - } + // For each minimap event, the next minimap's non-null stage read (walked + // backwards). A stage change only splits when the next read doesn't refute + // it: a lone frame disagreeing with both its match's running stage and the + // following read is a misread to fold in as a minority vote, not a match + // boundary. With no later read the change stands. + const nextStage = new Map(); + let carry: StageId | null = null; + for (let i = sorted.length - 1; i >= 0; i--) { + const event = sorted[i]!; + if (event.type !== MINIMAP_EVENT_TYPE) continue; + nextStage.set(event, carry); + carry = (event.data as MinimapData).stage ?? carry; + } - let open: OpenMatch | null = null; - const start = (): OpenMatch => ({ - mapStart: null, - firstMinimap: null, - minimaps: [], - scoreboard: null, - stageVotes: new Map(), - lastMinimapT: null, - }); - const vote = (votes: Map, stage: StageId | null): void => { - if (stage !== null) votes.set(stage, (votes.get(stage) ?? 0) + 1); - }; - const finalize = (): void => { - if (!open) return; - const match = toVodMatch(open); - if (match) matches.push(match); - open = null; - }; + let open: OpenMatch | null = null; + const start = (): OpenMatch => ({ + mapStart: null, + firstMinimap: null, + minimaps: [], + scoreboard: null, + stageVotes: new Map(), + lastMinimapT: null, + }); + const vote = (votes: Map, stage: StageId | null): void => { + if (stage !== null) votes.set(stage, (votes.get(stage) ?? 0) + 1); + }; + const finalize = (): void => { + if (!open) return; + const match = toVodMatch(open); + if (match) matches.push(match); + open = null; + }; - for (const event of sorted) { - if (event.type === MAP_START_EVENT_TYPE) { - finalize(); - open = start(); - open.mapStart = event; - vote(open.stageVotes, (event.data as MapStartData).stage ?? null); - } else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) { - open ??= start(); - open.scoreboard = event; - vote(open.stageVotes, (event.data as ScoreboardData).stage ?? null); - finalize(); - } else if (event.type === MINIMAP_EVENT_TYPE) { - const stage = (event.data as MinimapData).stage; - if (open) { - const current = leadingStage(open.stageVotes); - const stageChanged = - current !== null && - stage !== null && - stage !== current && - (nextStage.get(event) ?? stage) === stage; - const gapTooBig = - open.lastMinimapT !== null && event.t - open.lastMinimapT > MATCH_GAP_SECONDS; - if (stageChanged || gapTooBig) finalize(); - } - open ??= start(); - open.minimaps.push(event); - open.firstMinimap ??= event; - open.lastMinimapT = event.t; - vote(open.stageVotes, stage); - } - } - finalize(); + for (const event of sorted) { + if (event.type === MAP_START_EVENT_TYPE) { + finalize(); + open = start(); + open.mapStart = event; + vote(open.stageVotes, (event.data as MapStartData).stage ?? null); + } else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) { + open ??= start(); + open.scoreboard = event; + vote(open.stageVotes, (event.data as ScoreboardData).stage ?? null); + finalize(); + } else if (event.type === MINIMAP_EVENT_TYPE) { + const stage = (event.data as MinimapData).stage; + if (open) { + const current = leadingStage(open.stageVotes); + const stageChanged = + current !== null && + stage !== null && + stage !== current && + (nextStage.get(event) ?? stage) === stage; + const gapTooBig = + open.lastMinimapT !== null && + event.t - open.lastMinimapT > MATCH_GAP_SECONDS; + if (stageChanged || gapTooBig) finalize(); + } + open ??= start(); + open.minimaps.push(event); + open.firstMinimap ??= event; + open.lastMinimapT = event.t; + vote(open.stageVotes, stage); + } + } + finalize(); - return matches; + return matches; } /** Builds a match, or null when it carries no weapons to show. */ function toVodMatch(open: OpenMatch): VodMatch | null { - const start = open.mapStart?.data as MapStartData | undefined; - const board = open.scoreboard?.data as ScoreboardData | undefined; + const start = open.mapStart?.data as MapStartData | undefined; + const board = open.scoreboard?.data as ScoreboardData | undefined; - const weapons = board - ? board.players.map((player) => player.weaponId) - : weaponsFromMinimaps(open.minimaps); - if (weapons.length === 0) return null; + const weapons = board + ? board.players.map((player) => player.weaponId) + : weaponsFromMinimaps(open.minimaps); + if (weapons.length === 0) return null; - const anchorT = open.mapStart?.t ?? open.firstMinimap?.t ?? open.scoreboard?.t ?? 0; + const anchorT = + open.mapStart?.t ?? open.firstMinimap?.t ?? open.scoreboard?.t ?? 0; - const readMode = start?.mode ?? board?.mode ?? null; - return { - startsAt: Math.max(0, Math.floor(anchorT)), - mode: readMode ?? DEFAULT_MODE, - modeAssumed: readMode === null, - stage: start?.stage ?? board?.stage ?? leadingStage(open.stageVotes), - weapons, - }; + const readMode = start?.mode ?? board?.mode ?? null; + return { + startsAt: Math.max(0, Math.floor(anchorT)), + mode: readMode ?? DEFAULT_MODE, + modeAssumed: readMode === null, + stage: start?.stage ?? board?.stage ?? leadingStage(open.stageVotes), + weapons, + }; } /** @@ -202,20 +214,26 @@ function toVodMatch(open: OpenMatch): VodMatch | null { * a match, so a slot missed in one frame is filled from another. Empty when * there were no minimaps. */ -function weaponsFromMinimaps(minimaps: DetectedEvent[]): (MainWeaponId | null)[] { - if (minimaps.length === 0) return []; - const datas = minimaps.map((event) => event.data as MinimapData); - const alpha = mergeSlots(datas.map((d) => d.teammates.map((t) => t.weaponId))); - const bravo = mergeSlots(datas.map((d) => d.enemies.map((e) => e.weaponId))); - return [...alpha, ...bravo]; +function weaponsFromMinimaps( + minimaps: DetectedEvent[], +): (MainWeaponId | null)[] { + if (minimaps.length === 0) return []; + const datas = minimaps.map((event) => event.data as MinimapData); + const alpha = mergeSlots( + datas.map((d) => d.teammates.map((t) => t.weaponId)), + ); + const bravo = mergeSlots(datas.map((d) => d.enemies.map((e) => e.weaponId))); + return [...alpha, ...bravo]; } /** For each slot index, the first non-null id across frames, else null. */ -function mergeSlots(frames: (MainWeaponId | null)[][]): (MainWeaponId | null)[] { - const width = Math.max(0, ...frames.map((frame) => frame.length)); - const out: (MainWeaponId | null)[] = []; - for (let i = 0; i < width; i++) { - out.push(frames.map((frame) => frame[i]).find((id) => id != null) ?? null); - } - return out; +function mergeSlots( + frames: (MainWeaponId | null)[][], +): (MainWeaponId | null)[] { + const width = Math.max(0, ...frames.map((frame) => frame.length)); + const out: (MainWeaponId | null)[] = []; + for (let i = 0; i < width; i++) { + out.push(frames.map((frame) => frame[i]).find((id) => id != null) ?? null); + } + return out; } diff --git a/app/features/cv/cv-schemas.ts b/app/features/cv/cv-schemas.ts index fbf157977..81f4b2568 100644 --- a/app/features/cv/cv-schemas.ts +++ b/app/features/cv/cv-schemas.ts @@ -21,22 +21,28 @@ import { } from "~/modules/in-game-lists/weapon-ids"; import type { DeathData } from "./core/detectors/death/index"; import type { MapStartData } from "./core/detectors/map-start/index"; -import type { ScoreboardData, ScoreboardPlayer } from "./core/detectors/scoreboard/index"; +import type { + ScoreboardData, + ScoreboardPlayer, +} from "./core/detectors/scoreboard/index"; import type { ScoreboardReplayData } from "./core/detectors/scoreboard-replay/index"; import { CV_LOBBIES } from "./cv-types"; const detectionText = z.string().max(500); -export const cvLobbySchema = z.enum(CV_LOBBIES); +const cvLobbySchema = z.enum(CV_LOBBIES); export const modeShortSchema = z.enum(modesShort); export const stageIdSchema = z.literal(stageIds); export const mainWeaponIdSchema = z.literal(mainWeaponIds); -export const subWeaponIdSchema = z.literal(subWeaponIds); -export const specialWeaponIdSchema = z.literal(specialWeaponIds); +const subWeaponIdSchema = z.literal(subWeaponIds); +const specialWeaponIdSchema = z.literal(specialWeaponIds); const abilityNames = abilities.map((ability) => ability.name) as Ability[]; /** a sendou ability id, or the detectors' explicit unrecognized marker */ -export const cvAbilitySchema = z.union([z.literal(abilityNames), z.literal("UNKNOWN")]); +export const cvAbilitySchema = z.union([ + z.literal(abilityNames), + z.literal("UNKNOWN"), +]); export const cvScoreboardPlayerSchema = z.object({ name: detectionText, diff --git a/app/features/cv/cv-types.ts b/app/features/cv/cv-types.ts index 96631d5ae..92ecfec9f 100644 --- a/app/features/cv/cv-types.ts +++ b/app/features/cv/cv-types.ts @@ -5,17 +5,8 @@ * layer and never leave a detector. */ import { abilities } from "~/modules/in-game-lists/abilities"; -import { - mainWeaponIds, - specialWeaponIds, - subWeaponIds, -} from "~/modules/in-game-lists/weapon-ids"; -import type { - Ability, - MainWeaponId, - SpecialWeaponId, - SubWeaponId, -} from "~/modules/in-game-lists/types"; +import type { Ability, MainWeaponId } from "~/modules/in-game-lists/types"; +import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids"; /** The scoreboard header's lobby tag. PRIVATE marks tournament games. */ export const CV_LOBBIES = ["X", "SERIES", "OPEN", "PRIVATE"] as const; @@ -29,8 +20,6 @@ export type CvLobby = (typeof CV_LOBBIES)[number]; export type CvAbility = Ability | "UNKNOWN"; const MAIN_WEAPON_ID_SET: ReadonlySet = new Set(mainWeaponIds); -const SUB_WEAPON_ID_SET: ReadonlySet = new Set(subWeaponIds); -const SPECIAL_WEAPON_ID_SET: ReadonlySet = new Set(specialWeaponIds); const ABILITY_SET: ReadonlySet = new Set(abilities.map((a) => a.name)); /** Narrow a template/manifest id to a MainWeaponId; null when unknown. */ @@ -39,16 +28,6 @@ export function toMainWeaponId(id: number | string): MainWeaponId | null { return MAIN_WEAPON_ID_SET.has(n) ? (n as MainWeaponId) : null; } -export function toSubWeaponId(id: number | string): SubWeaponId | null { - const n = Number(id); - return SUB_WEAPON_ID_SET.has(n) ? (n as SubWeaponId) : null; -} - -export function toSpecialWeaponId(id: number | string): SpecialWeaponId | null { - const n = Number(id); - return SPECIAL_WEAPON_ID_SET.has(n) ? (n as SpecialWeaponId) : null; -} - /** Narrow an ability-template id to a CvAbility; null when unknown. */ export function toCvAbility(id: string): CvAbility | null { if (id === "UNKNOWN") return id; diff --git a/app/features/cv/node/assets-dir.ts b/app/features/cv/node/assets-dir.ts index c0a0daba1..ef1afa3d0 100644 --- a/app/features/cv/node/assets-dir.ts +++ b/app/features/cv/node/assets-dir.ts @@ -5,5 +5,5 @@ * version segment must match worker-side CV_ASSETS_URL (app/utils/urls.ts). */ export const CV_ASSETS_DIR = - process.env.CV_ASSETS_DIR ?? - new URL("../../../../../assets/assets/cv/v1", import.meta.url).pathname; + process.env.CV_ASSETS_DIR ?? + new URL("../../../../../assets/assets/cv/v1", import.meta.url).pathname; diff --git a/app/features/cv/node/fixtures.ts b/app/features/cv/node/fixtures.ts index c1735068b..ed229c4b6 100644 --- a/app/features/cv/node/fixtures.ts +++ b/app/features/cv/node/fixtures.ts @@ -7,148 +7,157 @@ */ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; -import type { MainWeaponId, ModeShort, StageId } from "~/modules/in-game-lists/types"; -import type { CvAbility, CvLobby } from "../cv-types"; +import type { + MainWeaponId, + ModeShort, + StageId, +} from "~/modules/in-game-lists/types"; import type { ScoreboardData } from "../core/detectors/scoreboard/index"; -import type { DetectedEvent, Detector, GateResult } from "../core/detectors/types"; +import type { + DetectedEvent, + Detector, + GateResult, +} from "../core/detectors/types"; import { normalizeFrame, toMat } from "../core/image"; +import type { CvAbility, CvLobby } from "../cv-types"; import { readImage } from "./image-io"; -export const FIXTURES_DIR = new URL("../tests/fixtures", import.meta.url).pathname; +export const FIXTURES_DIR = new URL("../tests/fixtures", import.meta.url) + .pathname; interface ExpectedPlayer { - name?: string; - weaponId?: MainWeaponId | null; - paint?: number; - ka?: number; - d?: number; - s?: number; + name?: string; + weaponId?: MainWeaponId | null; + paint?: number; + ka?: number; + d?: number; + s?: number; } interface ExpectedMinimapTeammate { - slot?: "up" | "left" | "right" | "self"; - name?: string | null; - /** informational for the human corrector; tests compare weaponId */ - weaponLabel?: string | null; - weaponId?: MainWeaponId | null; - abilities?: (CvAbility | null)[]; + slot?: "up" | "left" | "right" | "self"; + name?: string | null; + /** informational for the human corrector; tests compare weaponId */ + weaponLabel?: string | null; + weaponId?: MainWeaponId | null; + abilities?: (CvAbility | null)[]; } interface ExpectedMinimapEnemy { - /** spectator frames only: the screen shows bravo-team names */ - name?: string | null; - /** informational for the human corrector; tests compare weaponId */ - weaponLabel?: string | null; - weaponId?: MainWeaponId | null; - abilities?: (CvAbility | null)[]; + /** spectator frames only: the screen shows bravo-team names */ + name?: string | null; + /** informational for the human corrector; tests compare weaponId */ + weaponLabel?: string | null; + weaponId?: MainWeaponId | null; + abilities?: (CvAbility | null)[]; } interface ExpectedScoreboard { - event: - | "Scoreboard" - | "ScoreboardReplay" - | "ScoreboardOwn" - | "Death" - | "MapStart" - | "Minimap" - | "none"; - data?: { - lobby?: CvLobby; - mode?: ModeShort; - stage?: StageId; - /** informational for the human corrector; tests compare `stage` */ - stageLabel?: string; - /** ScoreboardReplay only */ - timestamp?: string; - /** ScoreboardReplay only */ - replayCode?: string; - scores?: [number, number]; - /** ScoreboardReplay only: the "Score:" banner values */ - matchScores?: [number, number]; - players?: ExpectedPlayer[]; - /** index of the yellow POV-arrow row in `players`; null = no arrow */ - povIndex?: number | null; - /** - * Death: killer's weapon id and kind (ids are unique per kind). - * ScoreboardOwn: the player's own main weapon (weaponType unused). - * weaponLabel is informational for the human corrector. - */ - weaponLabel?: string; - weaponId?: number | null; - weaponType?: "MAIN" | "SUB" | "SPECIAL"; - /** Death + ScoreboardOwn: 3 gear rows of [main, sub, sub, sub] ability ids */ - abilities?: CvAbility[][]; - /** Death only: killer's splash-tag name */ - name?: string; - /** Minimap only: casted 8-player spectator map screen (not parsed yet) */ - spectator?: boolean; - /** Minimap only: own-team callout cards in slot order */ - teammates?: ExpectedMinimapTeammate[]; - /** Minimap only: enemy panel rows, top to bottom */ - enemies?: ExpectedMinimapEnemy[]; - }; - options?: { - /** glob-ish field paths to skip, e.g. "players.*.name", "scores" */ - skipFields?: string[]; - /** free-form context for humans (why fields are skipped, capture quirks) */ - notes?: string; - }; + event: + | "Scoreboard" + | "ScoreboardReplay" + | "ScoreboardOwn" + | "Death" + | "MapStart" + | "Minimap" + | "none"; + data?: { + lobby?: CvLobby; + mode?: ModeShort; + stage?: StageId; + /** informational for the human corrector; tests compare `stage` */ + stageLabel?: string; + /** ScoreboardReplay only */ + timestamp?: string; + /** ScoreboardReplay only */ + replayCode?: string; + scores?: [number, number]; + /** ScoreboardReplay only: the "Score:" banner values */ + matchScores?: [number, number]; + players?: ExpectedPlayer[]; + /** index of the yellow POV-arrow row in `players`; null = no arrow */ + povIndex?: number | null; + /** + * Death: killer's weapon id and kind (ids are unique per kind). + * ScoreboardOwn: the player's own main weapon (weaponType unused). + * weaponLabel is informational for the human corrector. + */ + weaponLabel?: string; + weaponId?: number | null; + weaponType?: "MAIN" | "SUB" | "SPECIAL"; + /** Death + ScoreboardOwn: 3 gear rows of [main, sub, sub, sub] ability ids */ + abilities?: CvAbility[][]; + /** Death only: killer's splash-tag name */ + name?: string; + /** Minimap only: casted 8-player spectator map screen (not parsed yet) */ + spectator?: boolean; + /** Minimap only: own-team callout cards in slot order */ + teammates?: ExpectedMinimapTeammate[]; + /** Minimap only: enemy panel rows, top to bottom */ + enemies?: ExpectedMinimapEnemy[]; + }; + options?: { + /** glob-ish field paths to skip, e.g. "players.*.name", "scores" */ + skipFields?: string[]; + /** free-form context for humans (why fields are skipped, capture quirks) */ + notes?: string; + }; } export interface Fixture { - name: string; - dir: string; - framePath: string; - expected: ExpectedScoreboard; + name: string; + dir: string; + framePath: string; + expected: ExpectedScoreboard; } export function loadFixtures(detector: string): Fixture[] { - const root = join(FIXTURES_DIR, detector); - if (!existsSync(root)) return []; - return readdirSync(root, { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .map((e) => { - const dir = join(root, e.name); - const framePath = ["frame.png", "frame.jpg", "frame.jpeg"] - .map((f) => join(dir, f)) - .find(existsSync); - if (!framePath) throw new Error(`fixture ${e.name}: no frame.png/jpg`); - const expected = JSON.parse( - readFileSync(join(dir, "expected.json"), "utf8"), - ) as ExpectedScoreboard; - return { name: e.name, dir, framePath, expected }; - }) - .sort((a, b) => a.name.localeCompare(b.name)); + const root = join(FIXTURES_DIR, detector); + if (!existsSync(root)) return []; + return readdirSync(root, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => { + const dir = join(root, e.name); + const framePath = ["frame.png", "frame.jpg", "frame.jpeg"] + .map((f) => join(dir, f)) + .find(existsSync); + if (!framePath) throw new Error(`fixture ${e.name}: no frame.png/jpg`); + const expected = JSON.parse( + readFileSync(join(dir, "expected.json"), "utf8"), + ) as ExpectedScoreboard; + return { name: e.name, dir, framePath, expected }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); } export function isFieldSkipped(fixture: Fixture, field: string): boolean { - const skips = fixture.expected.options?.skipFields ?? []; - return skips.some((pattern) => { - const re = new RegExp( - `^${pattern - .split("*") - .map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")) - .join("[^.]*")}$`, - ); - return re.test(field); - }); + const skips = fixture.expected.options?.skipFields ?? []; + return skips.some((pattern) => { + const re = new RegExp( + `^${pattern + .split("*") + .map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")) + .join("[^.]*")}$`, + ); + return re.test(field); + }); } export interface FixtureRun { - gate: GateResult; - events: DetectedEvent[]; + gate: GateResult; + events: DetectedEvent[]; } /** Run gate+parse the way the live pipeline would. Caller must have loaded OpenCV. */ export async function runDetectorOnFixture( - detector: Detector, - fixture: Fixture, + detector: Detector, + fixture: Fixture, ): Promise> { - const src = toMat(await readImage(fixture.framePath)); - const frame = normalizeFrame(src); - src.delete(); - const gate = detector.gate(frame); - const events = gate.pass ? detector.parse(frame, 0, gate) : []; - frame.delete(); - return { gate, events }; + const src = toMat(await readImage(fixture.framePath)); + const frame = normalizeFrame(src); + src.delete(); + const gate = detector.gate(frame); + const events = gate.pass ? detector.parse(frame, 0, gate) : []; + frame.delete(); + return { gate, events }; } diff --git a/app/features/cv/node/image-io.ts b/app/features/cv/node/image-io.ts index 1b71086be..dcb207b93 100644 --- a/app/features/cv/node/image-io.ts +++ b/app/features/cv/node/image-io.ts @@ -11,19 +11,19 @@ import { createCanvas, Image, ImageData } from "@napi-rs/canvas"; import type { FrameData } from "../core/image"; export async function readImage(path: string): Promise { - const img = new Image(); - img.src = readFileSync(path); - await img.decode(); - const canvas = createCanvas(img.width, img.height); - const ctx = canvas.getContext("2d"); - ctx.drawImage(img, 0, 0); - const { width, height, data } = ctx.getImageData(0, 0, img.width, img.height); - return { width, height, data }; + const img = new Image(); + img.src = readFileSync(path); + await img.decode(); + const canvas = createCanvas(img.width, img.height); + const ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0); + const { width, height, data } = ctx.getImageData(0, 0, img.width, img.height); + return { width, height, data }; } export function writePng(path: string, frame: FrameData): void { - const canvas = createCanvas(frame.width, frame.height); - const ctx = canvas.getContext("2d"); - ctx.putImageData(new ImageData(frame.data, frame.width, frame.height), 0, 0); - writeFileSync(path, canvas.toBuffer("image/png")); + const canvas = createCanvas(frame.width, frame.height); + const ctx = canvas.getContext("2d"); + ctx.putImageData(new ImageData(frame.data, frame.width, frame.height), 0, 0); + writeFileSync(path, canvas.toBuffer("image/png")); } diff --git a/app/features/cv/node/resources.ts b/app/features/cv/node/resources.ts index 8ae120378..bc44aff17 100644 --- a/app/features/cv/node/resources.ts +++ b/app/features/cv/node/resources.ts @@ -8,48 +8,49 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { - loadPlannerStages, - type PlannerManifest, - type PlannerStage, + loadPlannerStages, + type PlannerManifest, + type PlannerStage, } from "../core/detectors/minimap/stage"; import type { ScoreboardResources } from "../core/detectors/scoreboard/index"; import { type AtlasMeta, type GlyphSet, loadGlyphSet } from "../core/glyphs"; import { assembleScoreboardResources } from "../core/resources"; -import { readImage } from "./image-io"; - import { CV_ASSETS_DIR as ASSETS_DIR } from "./assets-dir"; +import { readImage } from "./image-io"; /** Decode eagerly, defer the (CPU-heavy) glyph slicing to first access. */ async function loadAtlasLazy(name: string): Promise<() => GlyphSet | null> { - const png = join(ASSETS_DIR, "glyphs", `${name}.png`); - const json = join(ASSETS_DIR, "glyphs", `${name}.json`); - if (!existsSync(png) || !existsSync(json)) return () => null; - const meta = JSON.parse(readFileSync(json, "utf8")) as AtlasMeta; - const image = await readImage(png); - let set: GlyphSet | null = null; - return () => (set ??= loadGlyphSet(image, meta)); + const png = join(ASSETS_DIR, "glyphs", `${name}.png`); + const json = join(ASSETS_DIR, "glyphs", `${name}.json`); + if (!existsSync(png) || !existsSync(json)) return () => null; + const meta = JSON.parse(readFileSync(json, "utf8")) as AtlasMeta; + const image = await readImage(png); + let set: GlyphSet | null = null; + return () => (set ??= loadGlyphSet(image, meta)); } /** Planner stage signatures; the (CPU) tile slicing runs on first access. */ async function loadPlannerStagesLazy(): Promise<() => PlannerStage[] | null> { - const png = join(ASSETS_DIR, "planner", "signatures.png"); - const json = join(ASSETS_DIR, "planner", "manifest.json"); - if (!existsSync(png) || !existsSync(json)) return () => null; - const manifest = JSON.parse(readFileSync(json, "utf8")) as PlannerManifest; - const atlas = await readImage(png); - let stages: PlannerStage[] | null = null; - return () => (stages ??= loadPlannerStages(atlas, manifest)); + const png = join(ASSETS_DIR, "planner", "signatures.png"); + const json = join(ASSETS_DIR, "planner", "manifest.json"); + if (!existsSync(png) || !existsSync(json)) return () => null; + const manifest = JSON.parse(readFileSync(json, "utf8")) as PlannerManifest; + const atlas = await readImage(png); + let stages: PlannerStage[] | null = null; + return () => (stages ??= loadPlannerStages(atlas, manifest)); } /** Requires loadOpenCV() to have resolved. */ export function loadScoreboardResources(): Promise { - return assembleScoreboardResources({ - readManifest: (dir) => - Promise.resolve( - JSON.parse(readFileSync(join(ASSETS_DIR, dir, "manifest.json"), "utf8")) as string[], - ), - readIcon: (dir, id) => readImage(join(ASSETS_DIR, dir, `${id}.png`)), - loadAtlas: loadAtlasLazy, - loadPlannerStages: loadPlannerStagesLazy, - }); + return assembleScoreboardResources({ + readManifest: (dir) => + Promise.resolve( + JSON.parse( + readFileSync(join(ASSETS_DIR, dir, "manifest.json"), "utf8"), + ) as string[], + ), + readIcon: (dir, id) => readImage(join(ASSETS_DIR, dir, `${id}.png`)), + loadAtlas: loadAtlasLazy, + loadPlannerStages: loadPlannerStagesLazy, + }); } diff --git a/app/features/cv/routes/cv.tsx b/app/features/cv/routes/cv.tsx index d2a05b523..5b027ec26 100644 --- a/app/features/cv/routes/cv.tsx +++ b/app/features/cv/routes/cv.tsx @@ -17,7 +17,9 @@ export const meta: MetaFunction = (args) => { // IndexedDB, WebCodecs, getUserMedia. Nothing from core/worker/capture/store // may be imported at route-module top level — only from inside this lazily // imported client component tree, after hydration. -const CvApp = lazy(() => import("~/features/cv/components/App").then((m) => ({ default: m.App }))); +const CvApp = lazy(() => + import("~/features/cv/components/App").then((m) => ({ default: m.App })), +); export default function CvPage() { const isHydrated = useHydrated(); diff --git a/app/features/cv/store/db.ts b/app/features/cv/store/db.ts index b2f89b9a1..75342c081 100644 --- a/app/features/cv/store/db.ts +++ b/app/features/cv/store/db.ts @@ -21,19 +21,19 @@ export const VOD_FRAMES_STORE = "vod-frames"; * migration), stamping `hasFrame` on the source records. */ function extractFrames(source: IDBObjectStore, frames: IDBObjectStore): void { - const req = source.openCursor(); - req.onsuccess = () => { - const cursor = req.result; - if (!cursor) return; - const record = cursor.value as { frame?: Blob; hasFrame?: boolean }; - if (record.frame) { - frames.put(record.frame, cursor.primaryKey); - record.hasFrame = true; - delete record.frame; - cursor.update(record); - } - cursor.continue(); - }; + const req = source.openCursor(); + req.onsuccess = () => { + const cursor = req.result; + if (!cursor) return; + const record = cursor.value as { frame?: Blob; hasFrame?: boolean }; + if (record.frame) { + frames.put(record.frame, cursor.primaryKey); + record.hasFrame = true; + delete record.frame; + cursor.update(record); + } + cursor.continue(); + }; } /** @@ -43,68 +43,72 @@ function extractFrames(source: IDBObjectStore, frames: IDBObjectStore): void { * block plus a DB_VERSION bump, never an edit to an old block: databases that * already ran the old block would silently skip the change otherwise. */ -function migrate(database: IDBDatabase, transaction: IDBTransaction, oldVersion: number): void { - if (oldVersion < 2) { - // v1/v2 era stores; contains() guards absorb the pre-versioned scheme, - // where every creation was unconditionally contains()-gated - if (!database.objectStoreNames.contains(EVENTS_STORE)) { - const store = database.createObjectStore(EVENTS_STORE, { - keyPath: "id", - autoIncrement: true, - }); - store.createIndex("t", "t"); - store.createIndex("detectedAt", "detectedAt"); - } - if (!database.objectStoreNames.contains(VODS_STORE)) { - database.createObjectStore(VODS_STORE, { keyPath: "name" }); - } - if (!database.objectStoreNames.contains(VOD_EVENTS_STORE)) { - const store = database.createObjectStore(VOD_EVENTS_STORE, { - keyPath: "id", - autoIncrement: true, - }); - store.createIndex("vod", "vod"); - } - } - if (oldVersion < 3) { - // frame blobs move out of the event records into keyed frame stores - const frames = database.createObjectStore(FRAMES_STORE); - const vodFrames = database.createObjectStore(VOD_FRAMES_STORE); - extractFrames(transaction.objectStore(EVENTS_STORE), frames); - extractFrames(transaction.objectStore(VOD_EVENTS_STORE), vodFrames); - } +function migrate( + database: IDBDatabase, + transaction: IDBTransaction, + oldVersion: number, +): void { + if (oldVersion < 2) { + // v1/v2 era stores; contains() guards absorb the pre-versioned scheme, + // where every creation was unconditionally contains()-gated + if (!database.objectStoreNames.contains(EVENTS_STORE)) { + const store = database.createObjectStore(EVENTS_STORE, { + keyPath: "id", + autoIncrement: true, + }); + store.createIndex("t", "t"); + store.createIndex("detectedAt", "detectedAt"); + } + if (!database.objectStoreNames.contains(VODS_STORE)) { + database.createObjectStore(VODS_STORE, { keyPath: "name" }); + } + if (!database.objectStoreNames.contains(VOD_EVENTS_STORE)) { + const store = database.createObjectStore(VOD_EVENTS_STORE, { + keyPath: "id", + autoIncrement: true, + }); + store.createIndex("vod", "vod"); + } + } + if (oldVersion < 3) { + // frame blobs move out of the event records into keyed frame stores + const frames = database.createObjectStore(FRAMES_STORE); + const vodFrames = database.createObjectStore(VOD_FRAMES_STORE); + extractFrames(transaction.objectStore(EVENTS_STORE), frames); + extractFrames(transaction.objectStore(VOD_EVENTS_STORE), vodFrames); + } } function openDb(): Promise { - return new Promise((resolve, reject) => { - const req = indexedDB.open(DB_NAME, DB_VERSION); - req.onupgradeneeded = (event) => { - migrate(req.result, req.transaction!, event.oldVersion); - }; - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = (event) => { + migrate(req.result, req.transaction!, event.oldVersion); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); } let dbPromise: Promise | null = null; export function db(): Promise { - dbPromise ??= openDb(); - return dbPromise; + dbPromise ??= openDb(); + return dbPromise; } /** Single-request convenience wrapper over one object store. */ export function tx( - storeName: string, - mode: IDBTransactionMode, - run: (store: IDBObjectStore) => IDBRequest, + storeName: string, + mode: IDBTransactionMode, + run: (store: IDBObjectStore) => IDBRequest, ): Promise { - return db().then( - (database) => - new Promise((resolve, reject) => { - const transaction = database.transaction(storeName, mode); - const req = run(transaction.objectStore(storeName)); - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }), - ); + return db().then( + (database) => + new Promise((resolve, reject) => { + const transaction = database.transaction(storeName, mode); + const req = run(transaction.objectStore(storeName)); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }), + ); } diff --git a/app/features/cv/store/events.ts b/app/features/cv/store/events.ts index 0a9f98866..59533b92b 100644 --- a/app/features/cv/store/events.ts +++ b/app/features/cv/store/events.ts @@ -14,129 +14,153 @@ const MAX_EVENTS = 1000; /** Where an event stands with sendou.ink /ingest; absent = never attempted. */ export interface SendStatus { - state: "queued" | "sending" | "sent" | "failed"; - /** wall-clock time of the last state change */ - at: number; - /** failure detail, set when state is "failed" */ - error?: string; + state: "queued" | "sending" | "sent" | "failed"; + /** wall-clock time of the last state change */ + at: number; + /** failure detail, set when state is "failed" */ + error?: string; } export interface StoredEvent { - id?: number; - type: string; - t: number; - /** wall-clock time of detection */ - detectedAt: number; - confidence: number; - data: unknown; - /** small JPEG data URL of the source frame */ - thumbnail?: string; - /** whether a full-res frame exists in the `frames` store under this id */ - hasFrame?: boolean; - send?: SendStatus; + id?: number; + type: string; + t: number; + /** wall-clock time of detection */ + detectedAt: number; + confidence: number; + data: unknown; + /** small JPEG data URL of the source frame */ + thumbnail?: string; + /** whether a full-res frame exists in the `frames` store under this id */ + hasFrame?: boolean; + send?: SendStatus; } -export function saveEvent(event: DetectedEvent, thumbnail?: string, frame?: Blob): Promise { - const record: StoredEvent = { - type: event.type, - t: event.t, - detectedAt: Date.now(), - confidence: event.confidence, - data: event.data, - thumbnail, - hasFrame: frame !== undefined, - }; - return db().then( - (database) => - new Promise((resolve, reject) => { - const transaction = database.transaction([EVENTS_STORE, FRAMES_STORE], "readwrite"); - const events = transaction.objectStore(EVENTS_STORE); - const frames = transaction.objectStore(FRAMES_STORE); - let id: number; - const add = events.add(record) as IDBRequest; - add.onsuccess = () => { - id = add.result; - if (frame) frames.put(frame, id); - evictOldest(events, frames); - }; - transaction.oncomplete = () => resolve(id); - transaction.onerror = () => reject(transaction.error); - }), - ); +export function saveEvent( + event: DetectedEvent, + thumbnail?: string, + frame?: Blob, +): Promise { + const record: StoredEvent = { + type: event.type, + t: event.t, + detectedAt: Date.now(), + confidence: event.confidence, + data: event.data, + thumbnail, + hasFrame: frame !== undefined, + }; + return db().then( + (database) => + new Promise((resolve, reject) => { + const transaction = database.transaction( + [EVENTS_STORE, FRAMES_STORE], + "readwrite", + ); + const events = transaction.objectStore(EVENTS_STORE); + const frames = transaction.objectStore(FRAMES_STORE); + let id: number; + const add = events.add(record) as IDBRequest; + add.onsuccess = () => { + id = add.result; + if (frame) frames.put(frame, id); + evictOldest(events, frames); + }; + transaction.oncomplete = () => resolve(id); + transaction.onerror = () => reject(transaction.error); + }), + ); } /** Delete records (and frames) beyond MAX_EVENTS, oldest ids first. */ function evictOldest(events: IDBObjectStore, frames: IDBObjectStore): void { - const count = events.count(); - count.onsuccess = () => { - let excess = count.result - MAX_EVENTS; - if (excess <= 0) return; - const cursor = events.openCursor(); // ascending id = oldest first - cursor.onsuccess = () => { - const c = cursor.result; - if (!c || excess <= 0) return; - frames.delete(c.primaryKey); - c.delete(); - excess--; - if (excess > 0) c.continue(); - }; - }; + const count = events.count(); + count.onsuccess = () => { + let excess = count.result - MAX_EVENTS; + if (excess <= 0) return; + const cursor = events.openCursor(); // ascending id = oldest first + cursor.onsuccess = () => { + const c = cursor.result; + if (!c || excess <= 0) return; + frames.delete(c.primaryKey); + c.delete(); + excess--; + if (excess > 0) c.continue(); + }; + }; } /** Sets (or clears) the send status of the given events in one transaction. */ -export function updateEventsSend(ids: number[], send: SendStatus | undefined): Promise { - return db().then( - (database) => - new Promise((resolve, reject) => { - const transaction = database.transaction(EVENTS_STORE, "readwrite"); - const events = transaction.objectStore(EVENTS_STORE); - for (const id of ids) { - const get = events.get(id) as IDBRequest; - get.onsuccess = () => { - const record = get.result; - if (!record) return; // evicted meanwhile - if (send) record.send = send; - else delete record.send; - events.put(record); - }; - } - transaction.oncomplete = () => resolve(); - transaction.onerror = () => reject(transaction.error); - }), - ); +export function updateEventsSend( + ids: number[], + send: SendStatus | undefined, +): Promise { + return db().then( + (database) => + new Promise((resolve, reject) => { + const transaction = database.transaction(EVENTS_STORE, "readwrite"); + const events = transaction.objectStore(EVENTS_STORE); + for (const id of ids) { + const get = events.get(id) as IDBRequest; + get.onsuccess = () => { + const record = get.result; + if (!record) return; // evicted meanwhile + if (send) record.send = send; + else delete record.send; + events.put(record); + }; + } + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + }), + ); } export function deleteEvent(id: number): Promise { - return db().then( - (database) => - new Promise((resolve, reject) => { - const transaction = database.transaction([EVENTS_STORE, FRAMES_STORE], "readwrite"); - transaction.objectStore(EVENTS_STORE).delete(id); - transaction.objectStore(FRAMES_STORE).delete(id); - transaction.oncomplete = () => resolve(); - transaction.onerror = () => reject(transaction.error); - }), - ); + return db().then( + (database) => + new Promise((resolve, reject) => { + const transaction = database.transaction( + [EVENTS_STORE, FRAMES_STORE], + "readwrite", + ); + transaction.objectStore(EVENTS_STORE).delete(id); + transaction.objectStore(FRAMES_STORE).delete(id); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + }), + ); } export function listEvents(): Promise { - return tx(EVENTS_STORE, "readonly", (store) => store.getAll() as IDBRequest); + return tx( + EVENTS_STORE, + "readonly", + (store) => store.getAll() as IDBRequest, + ); } /** The event's full-res analyzed PNG, or undefined when none was stored. */ export function loadEventFrame(id: number): Promise { - return tx(FRAMES_STORE, "readonly", (store) => store.get(id) as IDBRequest); + return tx( + FRAMES_STORE, + "readonly", + (store) => store.get(id) as IDBRequest, + ); } export function clearEvents(): Promise { - return db().then( - (database) => - new Promise((resolve, reject) => { - const transaction = database.transaction([EVENTS_STORE, FRAMES_STORE], "readwrite"); - transaction.objectStore(EVENTS_STORE).clear(); - transaction.objectStore(FRAMES_STORE).clear(); - transaction.oncomplete = () => resolve(); - transaction.onerror = () => reject(transaction.error); - }), - ); + return db().then( + (database) => + new Promise((resolve, reject) => { + const transaction = database.transaction( + [EVENTS_STORE, FRAMES_STORE], + "readwrite", + ); + transaction.objectStore(EVENTS_STORE).clear(); + transaction.objectStore(FRAMES_STORE).clear(); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + }), + ); } diff --git a/app/features/cv/store/vods.ts b/app/features/cv/store/vods.ts index 2eca79997..65c363c49 100644 --- a/app/features/cv/store/vods.ts +++ b/app/features/cv/store/vods.ts @@ -10,120 +10,129 @@ import { db, tx, VOD_EVENTS_STORE, VOD_FRAMES_STORE, VODS_STORE } from "./db"; export interface VodSummary { - /** VoD file name — primary key */ - name: string; - /** wall-clock time the scan finished */ - savedAt: number; - /** video duration in seconds */ - duration: number; - eventCount: number; + /** VoD file name — primary key */ + name: string; + /** wall-clock time the scan finished */ + savedAt: number; + /** video duration in seconds */ + duration: number; + eventCount: number; } export interface StoredVodEvent { - id?: number; - /** owning VoD name (indexed) */ - vod: string; - type: string; - t: number; - confidence: number; - data: unknown; - /** small JPEG data URL of the source frame */ - thumbnail?: string; - /** whether a full-res frame exists in `vod-frames` under this id */ - hasFrame?: boolean; + id?: number; + /** owning VoD name (indexed) */ + vod: string; + type: string; + t: number; + confidence: number; + data: unknown; + /** small JPEG data URL of the source frame */ + thumbnail?: string; + /** whether a full-res frame exists in `vod-frames` under this id */ + hasFrame?: boolean; } /** A vod-event to persist, with its (separately stored) frame attached. */ export type VodEventToSave = Omit & { - frame?: Blob; + frame?: Blob; }; /** Delete every vod-event (and frame) of `name` via the index, then run `next`. */ function clearVodEvents( - events: IDBObjectStore, - frames: IDBObjectStore, - name: string, - next: () => void, + events: IDBObjectStore, + frames: IDBObjectStore, + name: string, + next: () => void, ): void { - const req = events.index("vod").openCursor(IDBKeyRange.only(name)); - req.onsuccess = () => { - const cursor = req.result; - if (cursor) { - frames.delete(cursor.primaryKey); - cursor.delete(); - cursor.continue(); - } else { - next(); - } - }; + const req = events.index("vod").openCursor(IDBKeyRange.only(name)); + req.onsuccess = () => { + const cursor = req.result; + if (cursor) { + frames.delete(cursor.primaryKey); + cursor.delete(); + cursor.continue(); + } else { + next(); + } + }; } export async function saveVod( - meta: Omit, - events: VodEventToSave[], + meta: Omit, + events: VodEventToSave[], ): Promise { - const database = await db(); - return new Promise((resolve, reject) => { - const transaction = database.transaction( - [VODS_STORE, VOD_EVENTS_STORE, VOD_FRAMES_STORE], - "readwrite", - ); - const eventStore = transaction.objectStore(VOD_EVENTS_STORE); - const frameStore = transaction.objectStore(VOD_FRAMES_STORE); - clearVodEvents(eventStore, frameStore, meta.name, () => { - for (const { frame, ...event } of events) { - const add = eventStore.add({ - ...event, - vod: meta.name, - hasFrame: frame !== undefined, - }) as IDBRequest; - if (frame) add.onsuccess = () => frameStore.put(frame, add.result); - } - transaction.objectStore(VODS_STORE).put({ ...meta, eventCount: events.length }); - }); - transaction.oncomplete = () => resolve(); - transaction.onerror = () => reject(transaction.error); - }); + const database = await db(); + return new Promise((resolve, reject) => { + const transaction = database.transaction( + [VODS_STORE, VOD_EVENTS_STORE, VOD_FRAMES_STORE], + "readwrite", + ); + const eventStore = transaction.objectStore(VOD_EVENTS_STORE); + const frameStore = transaction.objectStore(VOD_FRAMES_STORE); + clearVodEvents(eventStore, frameStore, meta.name, () => { + for (const { frame, ...event } of events) { + const add = eventStore.add({ + ...event, + vod: meta.name, + hasFrame: frame !== undefined, + }) as IDBRequest; + if (frame) add.onsuccess = () => frameStore.put(frame, add.result); + } + transaction + .objectStore(VODS_STORE) + .put({ ...meta, eventCount: events.length }); + }); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + }); } export async function listVods(): Promise { - const vods = await tx( - VODS_STORE, - "readonly", - (store) => store.getAll() as IDBRequest, - ); - return vods.sort((a, b) => b.savedAt - a.savedAt); + const vods = await tx( + VODS_STORE, + "readonly", + (store) => store.getAll() as IDBRequest, + ); + return vods.sort((a, b) => b.savedAt - a.savedAt); } export async function loadVodEvents(name: string): Promise { - const events = await tx( - VOD_EVENTS_STORE, - "readonly", - (store) => store.index("vod").getAll(IDBKeyRange.only(name)) as IDBRequest, - ); - return events.sort((a, b) => a.t - b.t); + const events = await tx( + VOD_EVENTS_STORE, + "readonly", + (store) => + store.index("vod").getAll(IDBKeyRange.only(name)) as IDBRequest< + StoredVodEvent[] + >, + ); + return events.sort((a, b) => a.t - b.t); } /** The vod-event's full-res analyzed PNG, or undefined when none was stored. */ export function loadVodEventFrame(id: number): Promise { - return tx(VOD_FRAMES_STORE, "readonly", (store) => store.get(id) as IDBRequest); + return tx( + VOD_FRAMES_STORE, + "readonly", + (store) => store.get(id) as IDBRequest, + ); } export async function deleteVod(name: string): Promise { - const database = await db(); - return new Promise((resolve, reject) => { - const transaction = database.transaction( - [VODS_STORE, VOD_EVENTS_STORE, VOD_FRAMES_STORE], - "readwrite", - ); - transaction.objectStore(VODS_STORE).delete(name); - clearVodEvents( - transaction.objectStore(VOD_EVENTS_STORE), - transaction.objectStore(VOD_FRAMES_STORE), - name, - () => {}, - ); - transaction.oncomplete = () => resolve(); - transaction.onerror = () => reject(transaction.error); - }); + const database = await db(); + return new Promise((resolve, reject) => { + const transaction = database.transaction( + [VODS_STORE, VOD_EVENTS_STORE, VOD_FRAMES_STORE], + "readwrite", + ); + transaction.objectStore(VODS_STORE).delete(name); + clearVodEvents( + transaction.objectStore(VOD_EVENTS_STORE), + transaction.objectStore(VOD_FRAMES_STORE), + name, + () => {}, + ); + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + }); } diff --git a/app/features/cv/tests/ability-harvest.test.ts b/app/features/cv/tests/ability-harvest.test.ts index 6a4477f71..214651cb1 100644 --- a/app/features/cv/tests/ability-harvest.test.ts +++ b/app/features/cv/tests/ability-harvest.test.ts @@ -7,113 +7,125 @@ import assert from "node:assert/strict"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; -import type { CvAbility } from "../cv-types"; import { connectAbilities } from "../core/ability-harvest"; -import { DEATH_EVENT_TYPE, type DeathData } from "../core/detectors/death/index"; +import { + DEATH_EVENT_TYPE, + type DeathData, +} from "../core/detectors/death/index"; import { SCOREBOARD_EVENT_TYPE } from "../core/detectors/scoreboard/index"; import type { DetectedEvent } from "../core/detectors/types"; +import type { CvAbility } from "../cv-types"; import test from "./node-test-compat"; const GRID_A: CvAbility[][] = [ - ["ISM", "ISM", "ISM", "ISM"], - ["RSU", "RSU", "RSU", "RSU"], - ["SSU", "SSU", "SSU", "SSU"], + ["ISM", "ISM", "ISM", "ISM"], + ["RSU", "RSU", "RSU", "RSU"], + ["SSU", "SSU", "SSU", "SSU"], ]; const GRID_B: CvAbility[][] = [ - ["QR", "QR", "QR", "QR"], - ["QSJ", "QSJ", "QSJ", "QSJ"], - ["IRU", "IRU", "IRU", "IRU"], + ["QR", "QR", "QR", "QR"], + ["QSJ", "QSJ", "QSJ", "QSJ"], + ["IRU", "IRU", "IRU", "IRU"], ]; function player(name: string, weaponId: MainWeaponId) { - return { name, weaponId, paint: null, ka: null, d: null, s: null }; + return { name, weaponId, paint: null, ka: null, d: null, s: null }; } function death( - t: number, - name: string | null, - weaponId: MainWeaponId | null, - abilities: CvAbility[][] = GRID_A, + t: number, + name: string | null, + weaponId: MainWeaponId | null, + abilities: CvAbility[][] = GRID_A, ): DetectedEvent { - return { - type: DEATH_EVENT_TYPE, - t, - confidence: 0.9, - data: { - weaponId, - weaponType: weaponId !== null ? "MAIN" : null, - abilities, - name, - }, - }; + return { + type: DEATH_EVENT_TYPE, + t, + confidence: 0.9, + data: { + weaponId, + weaponType: weaponId !== null ? "MAIN" : null, + abilities, + name, + }, + }; } -function scoreboard(t: number, players: ReturnType[]): DetectedEvent { - return { - type: SCOREBOARD_EVENT_TYPE, - t, - confidence: 0.9, - data: { lobby: null, mode: null, stage: null, scores: [null, null], players }, - }; +function scoreboard( + t: number, + players: ReturnType[], +): DetectedEvent { + return { + type: SCOREBOARD_EVENT_TYPE, + t, + confidence: 0.9, + data: { + lobby: null, + mode: null, + stage: null, + scores: [null, null], + players, + }, + }; } const PLAYERS = [ - player("Alpha", 40), - player("Bravo", 50), - player("Charlie", 60), - player("Delta", 50), - player("Echo", 70), - player("Foxtrot", 80), - player("Golf", 90), - player("Hotel", 100), + player("Alpha", 40), + player("Bravo", 50), + player("Charlie", 60), + player("Delta", 50), + player("Echo", 70), + player("Foxtrot", 80), + player("Golf", 90), + player("Hotel", 100), ]; test("matches by name+weapon, unique name, and unique weapon", () => { - const board = scoreboard(100, PLAYERS); - const map = connectAbilities([ - death(10, "Bravo", 50), // name+weapon → index 1, not the other 50 at index 3 - death(20, "charlie ", null, GRID_B), // unique name alone (case/space-insensitive) → 2 - death(30, "garbled", 70), // unique weapon despite misread name → 4 - board, - ]); - const abilities = map.get(board); - assert.ok(abilities); - assert.deepEqual(abilities.get(1), GRID_A); - assert.deepEqual(abilities.get(2), GRID_B); - assert.deepEqual(abilities.get(4), GRID_A); - assert.equal(abilities.size, 3); + const board = scoreboard(100, PLAYERS); + const map = connectAbilities([ + death(10, "Bravo", 50), // name+weapon → index 1, not the other 50 at index 3 + death(20, "charlie ", null, GRID_B), // unique name alone (case/space-insensitive) → 2 + death(30, "garbled", 70), // unique weapon despite misread name → 4 + board, + ]); + const abilities = map.get(board); + assert.ok(abilities); + assert.deepEqual(abilities.get(1), GRID_A); + assert.deepEqual(abilities.get(2), GRID_B); + assert.deepEqual(abilities.get(4), GRID_A); + assert.equal(abilities.size, 3); }); test("ambiguous weapon with unknown name stays unattributed", () => { - const board = scoreboard(100, PLAYERS); - const map = connectAbilities([death(10, "garbled", 50), board]); - assert.equal(map.get(board), undefined); + const board = scoreboard(100, PLAYERS); + const map = connectAbilities([death(10, "garbled", 50), board]); + assert.equal(map.get(board), undefined); }); test("deaths only attach to the next scoreboard", () => { - const board1 = scoreboard(100, PLAYERS); - const board2 = scoreboard(400, PLAYERS); - const map = connectAbilities([ - death(10, "Alpha", 40), - board1, - death(200, "Echo", 70, GRID_B), - board2, - ]); - assert.deepEqual(map.get(board1)?.get(0), GRID_A); - assert.equal(map.get(board1)?.get(4), undefined); - assert.deepEqual(map.get(board2)?.get(4), GRID_B); - assert.equal(map.get(board2)?.get(0), undefined); + const board1 = scoreboard(100, PLAYERS); + const board2 = scoreboard(400, PLAYERS); + const map = connectAbilities([ + death(10, "Alpha", 40), + board1, + death(200, "Echo", 70, GRID_B), + board2, + ]); + assert.deepEqual(map.get(board1)?.get(0), GRID_A); + assert.equal(map.get(board1)?.get(4), undefined); + assert.deepEqual(map.get(board2)?.get(4), GRID_B); + assert.equal(map.get(board2)?.get(0), undefined); }); test("unsorted input is handled and trailing deaths are dropped", () => { - const board = scoreboard(100, PLAYERS); - const map = connectAbilities([ - death(150, "Alpha", 40), // after the scoreboard — belongs to a match never finished - board, - death(10, "Hotel", 100, GRID_B), - ]); - const abilities = map.get(board); - assert.ok(abilities); - assert.deepEqual(abilities.get(7), GRID_B); - assert.equal(abilities.size, 1); + const board = scoreboard(100, PLAYERS); + const map = connectAbilities([ + death(150, "Alpha", 40), // after the scoreboard — belongs to a match never finished + board, + death(10, "Hotel", 100, GRID_B), + ]); + const abilities = map.get(board); + assert.ok(abilities); + assert.deepEqual(abilities.get(7), GRID_B); + assert.equal(abilities.size, 1); }); diff --git a/app/features/cv/tests/batches.test.ts b/app/features/cv/tests/batches.test.ts index 0c769741e..0297fb1bb 100644 --- a/app/features/cv/tests/batches.test.ts +++ b/app/features/cv/tests/batches.test.ts @@ -1,227 +1,235 @@ import assert from "node:assert/strict"; -import type { CvAbility, CvLobby } from "../cv-types"; -import test from "./node-test-compat"; import { - buildIngestBatches, - chunkIngestBatches, - type IngestScoreboardPlayer, + buildIngestBatches, + chunkIngestBatches, + type IngestScoreboardPlayer, } from "../core/batches"; import type { DeathData } from "../core/detectors/death/index"; import type { ScoreboardData } from "../core/detectors/scoreboard/index"; import type { DetectedEvent } from "../core/detectors/types"; +import type { CvAbility, CvLobby } from "../cv-types"; +import test from "./node-test-compat"; const NAMES = ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"]; function mapStart(t: number): DetectedEvent { - return { - type: "MapStart", - t, - confidence: 0.9, - data: { mode: "SZ", stage: 0 }, - }; + return { + type: "MapStart", + t, + confidence: 0.9, + data: { mode: "SZ", stage: 0 }, + }; } function death( - t: number, - name: string, - abilities: CvAbility[][] = [["ISM", "ISS", "ISS", "ISS"]], + t: number, + name: string, + abilities: CvAbility[][] = [["ISM", "ISS", "ISS", "ISS"]], ): DetectedEvent { - const data: DeathData = { - weaponId: null, - weaponType: "MAIN", - abilities, - name, - }; - return { type: "Death", t, confidence: 0.9, data }; + const data: DeathData = { + weaponId: null, + weaponType: "MAIN", + abilities, + name, + }; + return { type: "Death", t, confidence: 0.9, data }; } function scoreboard( - t: number, - { lobby = "PRIVATE" as CvLobby | null } = {}, + t: number, + { lobby = "PRIVATE" as CvLobby | null } = {}, ): DetectedEvent { - const data: ScoreboardData = { - lobby, - mode: "SZ", - stage: 0, - scores: [100, 47], - players: NAMES.map((name) => ({ - name, - weaponId: 40, - paint: 1000, - ka: 10, - d: 5, - s: 2, - })), - povIndex: 0, - }; - return { type: "Scoreboard", t, confidence: 0.9, data }; + const data: ScoreboardData = { + lobby, + mode: "SZ", + stage: 0, + scores: [100, 47], + players: NAMES.map((name) => ({ + name, + weaponId: 40, + paint: 1000, + ka: 10, + d: 5, + s: 2, + })), + povIndex: 0, + }; + return { type: "Scoreboard", t, confidence: 0.9, data }; } function scoreboardPlayers(batch: DetectedEvent[]): IngestScoreboardPlayer[] { - return (batch.at(-1)!.data as ScoreboardData).players; + return (batch.at(-1)!.data as ScoreboardData).players; } test("groups map start, deaths and scoreboard into one batch", () => { - const batches = buildIngestBatches([ - mapStart(0), - death(60, "l2"), - death(120, "l3"), - scoreboard(300), - ]); - assert.equal(batches.length, 1); - assert.deepEqual( - batches[0]!.map((e) => e.type), - ["MapStart", "Death", "Death", "Scoreboard"], - ); + const batches = buildIngestBatches([ + mapStart(0), + death(60, "l2"), + death(120, "l3"), + scoreboard(300), + ]); + assert.equal(batches.length, 1); + assert.deepEqual( + batches[0]!.map((e) => e.type), + ["MapStart", "Death", "Death", "Scoreboard"], + ); }); test("enriches the scoreboard players with abilities from the batch's deaths", () => { - const build: CvAbility[][] = [ - ["ISM", "ISS", "ISS", "ISS"], - ["QR", "QSJ", "QSJ", "QSJ"], - ["SSU", "RSU", "RSU", "RSU"], - ]; - const batches = buildIngestBatches([mapStart(0), death(60, "l2", build), scoreboard(300)]); - const players = scoreboardPlayers(batches[0]!); - assert.deepEqual(players[5]!.abilities, build); - assert.equal(players[0]!.abilities, undefined); + const build: CvAbility[][] = [ + ["ISM", "ISS", "ISS", "ISS"], + ["QR", "QSJ", "QSJ", "QSJ"], + ["SSU", "RSU", "RSU", "RSU"], + ]; + const batches = buildIngestBatches([ + mapStart(0), + death(60, "l2", build), + scoreboard(300), + ]); + const players = scoreboardPlayers(batches[0]!); + assert.deepEqual(players[5]!.abilities, build); + assert.equal(players[0]!.abilities, undefined); }); test("deaths from an earlier match do not leak into the next batch", () => { - const batches = buildIngestBatches([ - mapStart(0), - death(60, "l2"), - scoreboard(300), - mapStart(400), - scoreboard(700), - ]); - assert.equal(batches.length, 2); - assert.equal(scoreboardPlayers(batches[1]!)[5]!.abilities, undefined); + const batches = buildIngestBatches([ + mapStart(0), + death(60, "l2"), + scoreboard(300), + mapStart(400), + scoreboard(700), + ]); + assert.equal(batches.length, 2); + assert.equal(scoreboardPlayers(batches[1]!)[5]!.abilities, undefined); }); test("a scoreboard without a preceding map start claims the last 10 minutes of deaths", () => { - const batches = buildIngestBatches([death(60, "l2"), scoreboard(300)]); - assert.equal(batches.length, 1); - assert.deepEqual( - batches[0]!.map((e) => e.type), - ["Death", "Scoreboard"], - ); - assert.notEqual(scoreboardPlayers(batches[0]!)[5]!.abilities, undefined); + const batches = buildIngestBatches([death(60, "l2"), scoreboard(300)]); + assert.equal(batches.length, 1); + assert.deepEqual( + batches[0]!.map((e) => e.type), + ["Death", "Scoreboard"], + ); + assert.notEqual(scoreboardPlayers(batches[0]!)[5]!.abilities, undefined); }); test("deaths older than 10 minutes do not join a map-start-less batch", () => { - const batches = buildIngestBatches([death(60, "l2"), death(700, "l3"), scoreboard(1000)]); - assert.equal(batches.length, 1); - assert.deepEqual( - batches[0]!.map((e) => e.t), - [700, 1000], - ); + const batches = buildIngestBatches([ + death(60, "l2"), + death(700, "l3"), + scoreboard(1000), + ]); + assert.equal(batches.length, 1); + assert.deepEqual( + batches[0]!.map((e) => e.t), + [700, 1000], + ); }); test("the fallback window does not reach past the previous scoreboard", () => { - const batches = buildIngestBatches([ - mapStart(0), - death(60, "l2"), - scoreboard(300), - death(400, "l3"), - scoreboard(700), - ]); - assert.equal(batches.length, 2); - assert.deepEqual( - batches[1]!.map((e) => e.t), - [400, 700], - ); + const batches = buildIngestBatches([ + mapStart(0), + death(60, "l2"), + scoreboard(300), + death(400, "l3"), + scoreboard(700), + ]); + assert.equal(batches.length, 2); + assert.deepEqual( + batches[1]!.map((e) => e.t), + [400, 700], + ); }); test("non-private-battle scoreboards are dropped together with their batch", () => { - const batches = buildIngestBatches([ - mapStart(0), - death(60, "l2"), - scoreboard(300, { lobby: "X" }), - mapStart(400), - scoreboard(700), - ]); - assert.equal(batches.length, 1); - assert.deepEqual( - batches[0]!.map((e) => e.type), - ["MapStart", "Scoreboard"], - ); + const batches = buildIngestBatches([ + mapStart(0), + death(60, "l2"), + scoreboard(300, { lobby: "X" }), + mapStart(400), + scoreboard(700), + ]); + assert.equal(batches.length, 1); + assert.deepEqual( + batches[0]!.map((e) => e.type), + ["MapStart", "Scoreboard"], + ); }); test("an unreadable lobby is kept", () => { - const batches = buildIngestBatches([scoreboard(300, { lobby: null })]); - assert.equal(batches.length, 1); + const batches = buildIngestBatches([scoreboard(300, { lobby: null })]); + assert.equal(batches.length, 1); }); test("a match whose scoreboard was missed is dropped on the next map start", () => { - const batches = buildIngestBatches([ - mapStart(0), - death(60, "l2"), - mapStart(400), - death(460, "l3"), - scoreboard(700), - ]); - assert.equal(batches.length, 1); - assert.deepEqual( - batches[0]!.map((e) => e.t), - [400, 460, 700], - ); + const batches = buildIngestBatches([ + mapStart(0), + death(60, "l2"), + mapStart(400), + death(460, "l3"), + scoreboard(700), + ]); + assert.equal(batches.length, 1); + assert.deepEqual( + batches[0]!.map((e) => e.t), + [400, 460, 700], + ); }); test("a trailing match without a scoreboard is not sent", () => { - const batches = buildIngestBatches([mapStart(0), death(60, "l2")]); - assert.equal(batches.length, 0); + const batches = buildIngestBatches([mapStart(0), death(60, "l2")]); + assert.equal(batches.length, 0); }); test("event types the endpoint does not accept are excluded", () => { - const own: DetectedEvent = { - type: "ScoreboardOwn", - t: 310, - confidence: 0.9, - data: { - lobby: "PRIVATE", - mode: null, - stage: null, - weaponId: null, - abilities: [], - }, - }; - const batches = buildIngestBatches([mapStart(0), own, scoreboard(300), own]); - assert.equal(batches.length, 1); - assert.deepEqual( - batches[0]!.map((e) => e.type), - ["MapStart", "Scoreboard"], - ); + const own: DetectedEvent = { + type: "ScoreboardOwn", + t: 310, + confidence: 0.9, + data: { + lobby: "PRIVATE", + mode: null, + stage: null, + weaponId: null, + abilities: [], + }, + }; + const batches = buildIngestBatches([mapStart(0), own, scoreboard(300), own]); + assert.equal(batches.length, 1); + assert.deepEqual( + batches[0]!.map((e) => e.type), + ["MapStart", "Scoreboard"], + ); }); test("chunkIngestBatches: packs whole batches up to the event cap", () => { - const batches = [ - [mapStart(0), death(60, "l1"), scoreboard(300)], - [mapStart(400), scoreboard(700)], - [mapStart(800), scoreboard(1100)], - ]; - const chunks = chunkIngestBatches(batches, 5); - assert.deepEqual( - chunks.map((chunk) => chunk.map((batch) => batch.length)), - [[3, 2], [2]], - ); - // batches are kept whole and in order - assert.deepEqual(chunks.flat(), batches); + const batches = [ + [mapStart(0), death(60, "l1"), scoreboard(300)], + [mapStart(400), scoreboard(700)], + [mapStart(800), scoreboard(1100)], + ]; + const chunks = chunkIngestBatches(batches, 5); + assert.deepEqual( + chunks.map((chunk) => chunk.map((batch) => batch.length)), + [[3, 2], [2]], + ); + // batches are kept whole and in order + assert.deepEqual(chunks.flat(), batches); }); test("chunkIngestBatches: everything fits in one request", () => { - const batches = [ - [mapStart(0), scoreboard(300)], - [mapStart(400), scoreboard(700)], - ]; - assert.deepEqual(chunkIngestBatches(batches, 1000), [batches]); + const batches = [ + [mapStart(0), scoreboard(300)], + [mapStart(400), scoreboard(700)], + ]; + assert.deepEqual(chunkIngestBatches(batches, 1000), [batches]); }); test("chunkIngestBatches: an oversized lone batch still gets a chunk", () => { - const big = [mapStart(0), death(1, "l1"), death(2, "l2"), scoreboard(300)]; - assert.deepEqual(chunkIngestBatches([big], 2), [[big]]); + const big = [mapStart(0), death(1, "l1"), death(2, "l2"), scoreboard(300)]; + assert.deepEqual(chunkIngestBatches([big], 2), [[big]]); }); test("chunkIngestBatches: no batches, no requests", () => { - assert.deepEqual(chunkIngestBatches([], 1000), []); + assert.deepEqual(chunkIngestBatches([], 1000), []); }); diff --git a/app/features/cv/tests/map-start.test.ts b/app/features/cv/tests/map-start.test.ts index 4eff64496..f4eb16b23 100644 --- a/app/features/cv/tests/map-start.test.ts +++ b/app/features/cv/tests/map-start.test.ts @@ -7,22 +7,22 @@ */ import assert from "node:assert/strict"; -import test from "./node-test-compat"; import { loadOpenCV } from "../core/cv"; import { createDeathDetector } from "../core/detectors/death/index"; import { - createMapStartDetector, - type MapStartData, + createMapStartDetector, + type MapStartData, } from "../core/detectors/map-start/index"; import { createScoreboardDetector } from "../core/detectors/scoreboard/index"; import { createScoreboardReplayDetector } from "../core/detectors/scoreboard-replay/index"; import { - type Fixture, - isFieldSkipped, - loadFixtures, - runDetectorOnFixture, + 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(); @@ -30,95 +30,122 @@ const detector = createMapStartDetector(resources); const fixtures = loadFixtures("map-start"); test("map-start fixtures exist", () => { - assert.ok(fixtures.length > 0, "no fixtures found under map-start/"); + assert.ok(fixtures.length > 0, "no fixtures found under map-start/"); }); for (const fixture of fixtures) { - test(`map-start/${fixture.name}`, async (t) => { - const { gate, events } = await runDetectorOnFixture(detector, fixture); - const expectPositive = fixture.expected.event === "MapStart"; + test(`map-start/${fixture.name}`, async (t) => { + const { gate, events } = await runDetectorOnFixture( + detector, + fixture, + ); + const expectPositive = fixture.expected.event === "MapStart"; - 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"}`, - ); - }); + 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 (label confirmation failed?)" - : "no event (gate did not fire)", - ); - const expected = fixture.expected.data ?? {}; + if (!expectPositive) return; + const event = events[0]; + assert.ok( + event, + gate.pass + ? "gate passed but parse emitted no event (label confirmation failed?)" + : "no event (gate did not fire)", + ); + const expected = fixture.expected.data ?? {}; - await t.test("mode", { skip: expected.mode === undefined || skip(fixture, "mode") }, () => { - assert.equal( - event.data.mode, - expected.mode, - `mode mismatch (raw: "${event.debug?.modeReading}", score=${Number(event.debug?.modeScore ?? 0).toFixed(3)})`, - ); - }); + await t.test( + "mode", + { skip: expected.mode === undefined || skip(fixture, "mode") }, + () => { + assert.equal( + event.data.mode, + expected.mode, + `mode mismatch (raw: "${event.debug?.modeReading}", score=${Number(event.debug?.modeScore ?? 0).toFixed(3)})`, + ); + }, + ); - await t.test("stage", { skip: expected.stage === undefined || skip(fixture, "stage") }, () => { - assert.equal( - event.data.stage, - expected.stage, - `stage mismatch (raw: "${event.debug?.stageReading}", score=${Number(event.debug?.stageScore ?? 0).toFixed(3)})`, - ); - }); - }); + await t.test( + "stage", + { skip: expected.stage === undefined || skip(fixture, "stage") }, + () => { + assert.equal( + event.data.stage, + expected.stage, + `stage mismatch (raw: "${event.debug?.stageReading}", score=${Number(event.debug?.stageScore ?? 0).toFixed(3)})`, + ); + }, + ); + }); } // The intro splash overlays live gameplay while the other screens replace // or overlay it differently; none of the four gates may fire on another // detector's positives. const otherPositives = [ - ...loadFixtures("scoreboard").filter((f) => f.expected.event === "Scoreboard"), - ...loadFixtures("scoreboard-replay").filter((f) => f.expected.event === "ScoreboardReplay"), - ...loadFixtures("death").filter((f) => f.expected.event === "Death"), + ...loadFixtures("scoreboard").filter( + (f) => f.expected.event === "Scoreboard", + ), + ...loadFixtures("scoreboard-replay").filter( + (f) => f.expected.event === "ScoreboardReplay", + ), + ...loadFixtures("death").filter((f) => f.expected.event === "Death"), ]; for (const fixture of otherPositives) { - test(`map-start gate stays quiet on ${fixture.dir.split("/").slice(-2).join("/")}`, async () => { - const { gate } = await runDetectorOnFixture(detector, fixture); - assert.equal(gate.pass, false, `map-start gate fired (score=${gate.score.toFixed(3)})`); - }); + test(`map-start gate stays quiet on ${fixture.dir.split("/").slice(-2).join("/")}`, async () => { + const { gate } = await runDetectorOnFixture(detector, fixture); + assert.equal( + gate.pass, + false, + `map-start gate fired (score=${gate.score.toFixed(3)})`, + ); + }); } // Shared negatives (tests/fixtures/negative/): frames no detector may fire on. for (const fixture of loadFixtures("negative")) { - test(`map-start gate stays quiet on negative/${fixture.name}`, async () => { - const { gate } = await runDetectorOnFixture(detector, fixture); - assert.equal(gate.pass, false, `map-start gate fired (score=${gate.score.toFixed(3)})`); - }); + test(`map-start gate stays quiet on negative/${fixture.name}`, async () => { + const { gate } = await runDetectorOnFixture(detector, fixture); + assert.equal( + gate.pass, + false, + `map-start gate fired (score=${gate.score.toFixed(3)})`, + ); + }); } const scoreboardDetector = createScoreboardDetector(resources); const replayDetector = createScoreboardReplayDetector(resources); const deathDetector = createDeathDetector(resources); for (const fixture of fixtures.filter((f) => f.expected.event === "MapStart")) { - test(`other gates stay quiet on map-start/${fixture.name}`, async () => { - const live = await runDetectorOnFixture(scoreboardDetector, fixture); - assert.equal( - live.gate.pass, - false, - `scoreboard gate fired (score=${live.gate.score.toFixed(3)})`, - ); - const replay = await runDetectorOnFixture(replayDetector, fixture); - assert.equal( - replay.gate.pass, - false, - `replay gate fired (score=${replay.gate.score.toFixed(3)})`, - ); - const death = await runDetectorOnFixture(deathDetector, fixture); - assert.equal(death.gate.pass, false, `death gate fired (score=${death.gate.score.toFixed(3)})`); - }); + test(`other gates stay quiet on map-start/${fixture.name}`, async () => { + const live = await runDetectorOnFixture(scoreboardDetector, fixture); + assert.equal( + live.gate.pass, + false, + `scoreboard gate fired (score=${live.gate.score.toFixed(3)})`, + ); + const replay = await runDetectorOnFixture(replayDetector, fixture); + assert.equal( + replay.gate.pass, + false, + `replay gate fired (score=${replay.gate.score.toFixed(3)})`, + ); + const death = await runDetectorOnFixture(deathDetector, fixture); + assert.equal( + death.gate.pass, + false, + `death gate fired (score=${death.gate.score.toFixed(3)})`, + ); + }); } function skip(fixture: Fixture, field: string): boolean | string { - return isFieldSkipped(fixture, field) ? "skipFields" : false; + return isFieldSkipped(fixture, field) ? "skipFields" : false; } diff --git a/app/features/cv/tests/minimap.test.ts b/app/features/cv/tests/minimap.test.ts index f6d639504..1d22fd557 100644 --- a/app/features/cv/tests/minimap.test.ts +++ b/app/features/cv/tests/minimap.test.ts @@ -6,22 +6,25 @@ */ import assert from "node:assert/strict"; -import test from "./node-test-compat"; import { loadOpenCV } from "../core/cv"; import { createDeathDetector } from "../core/detectors/death/index"; import { createMapStartDetector } from "../core/detectors/map-start/index"; -import { createMinimapDetector, type MinimapData } from "../core/detectors/minimap/index"; +import { + createMinimapDetector, + type MinimapData, +} from "../core/detectors/minimap/index"; import { createScoreboardDetector } from "../core/detectors/scoreboard/index"; import { createScoreboardOwnDetector } from "../core/detectors/scoreboard-own/index"; import { createScoreboardReplayDetector } from "../core/detectors/scoreboard-replay/index"; import type { Detector } from "../core/detectors/types"; import { - type Fixture, - isFieldSkipped, - loadFixtures, - runDetectorOnFixture, + 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(); @@ -29,132 +32,214 @@ const detector = createMinimapDetector(resources); const fixtures = loadFixtures("minimap"); test("minimap fixtures exist", () => { - assert.ok(fixtures.length > 0, "no fixtures found under minimap/"); + assert.ok(fixtures.length > 0, "no fixtures found under minimap/"); }); for (const fixture of fixtures) { - test(`minimap/${fixture.name}`, async (t) => { - const { gate, events } = await runDetectorOnFixture(detector, fixture); - const expectPositive = fixture.expected.event === "Minimap"; + test(`minimap/${fixture.name}`, async (t) => { + const { gate, events } = await runDetectorOnFixture( + detector, + fixture, + ); + const expectPositive = fixture.expected.event === "Minimap"; - 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"}`, - ); - }); + 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 passed but no event parsed"); - const expected = fixture.expected.data ?? {}; + if (!expectPositive) return; + const event = events[0]; + assert.ok(event, "gate passed but no event parsed"); + const expected = fixture.expected.data ?? {}; - await t.test("spectator", { skip: expected.spectator === undefined }, () => { - assert.equal(event.data.spectator, expected.spectator); - }); + await t.test( + "spectator", + { skip: expected.spectator === undefined }, + () => { + assert.equal(event.data.spectator, expected.spectator); + }, + ); - await t.test("stage", { skip: expected.stage === undefined || skip(fixture, "stage") }, () => { - assert.equal( - event.data.stage, - expected.stage, - `stage mismatch (debug: ${JSON.stringify(event.debug?.stage)})`, - ); - }); + await t.test( + "stage", + { skip: expected.stage === undefined || skip(fixture, "stage") }, + () => { + assert.equal( + event.data.stage, + expected.stage, + `stage mismatch (debug: ${JSON.stringify(event.debug?.stage)})`, + ); + }, + ); - // an expected array also pins the count, so a spectator stub (or a - // misaligned parse) can't smuggle in phantom cards or rows - await t.test("teammate count", { skip: expected.teammates === undefined }, () => { - assert.equal(event.data.teammates.length, expected.teammates?.length); - }); - await t.test("enemy count", { skip: expected.enemies === undefined }, () => { - assert.equal(event.data.enemies.length, expected.enemies?.length); - }); + // an expected array also pins the count, so a spectator stub (or a + // misaligned parse) can't smuggle in phantom cards or rows + await t.test( + "teammate count", + { skip: expected.teammates === undefined }, + () => { + assert.equal(event.data.teammates.length, expected.teammates?.length); + }, + ); + await t.test( + "enemy count", + { skip: expected.enemies === undefined }, + () => { + assert.equal(event.data.enemies.length, expected.enemies?.length); + }, + ); - for (const [i, want] of (expected.teammates ?? []).entries()) { - await t.test( - `teammate ${i} (${want.slot ?? "?"})`, - { skip: skip(fixture, `teammates.${i}`) }, - () => { - const got = event.data.teammates[i]; - assert.ok(got, `teammate ${i} missing (got ${event.data.teammates.length})`); - const cardDebug = JSON.stringify((event.debug?.cards as unknown[])?.[i]); - if (want.slot !== undefined) assert.equal(got.slot, want.slot); - if (want.name !== undefined && !isFieldSkipped(fixture, `teammates.${i}.name`)) { - assert.equal(got.name, want.name, `name (debug: ${cardDebug})`); - } - if (want.weaponId !== undefined && !isFieldSkipped(fixture, `teammates.${i}.weapon`)) { - assert.equal(got.weaponId, want.weaponId, `weapon (debug: ${cardDebug})`); - } - if ( - want.abilities !== undefined && - !isFieldSkipped(fixture, `teammates.${i}.abilities`) - ) { - assert.deepEqual(got.abilities, want.abilities, `abilities (debug: ${cardDebug})`); - } - }, - ); - } + for (const [i, want] of (expected.teammates ?? []).entries()) { + await t.test( + `teammate ${i} (${want.slot ?? "?"})`, + { skip: skip(fixture, `teammates.${i}`) }, + () => { + const got = event.data.teammates[i]; + assert.ok( + got, + `teammate ${i} missing (got ${event.data.teammates.length})`, + ); + const cardDebug = JSON.stringify( + (event.debug?.cards as unknown[])?.[i], + ); + if (want.slot !== undefined) assert.equal(got.slot, want.slot); + if ( + want.name !== undefined && + !isFieldSkipped(fixture, `teammates.${i}.name`) + ) { + assert.equal(got.name, want.name, `name (debug: ${cardDebug})`); + } + if ( + want.weaponId !== undefined && + !isFieldSkipped(fixture, `teammates.${i}.weapon`) + ) { + assert.equal( + got.weaponId, + want.weaponId, + `weapon (debug: ${cardDebug})`, + ); + } + if ( + want.abilities !== undefined && + !isFieldSkipped(fixture, `teammates.${i}.abilities`) + ) { + assert.deepEqual( + got.abilities, + want.abilities, + `abilities (debug: ${cardDebug})`, + ); + } + }, + ); + } - for (const [i, want] of (expected.enemies ?? []).entries()) { - await t.test(`enemy ${i}`, { skip: skip(fixture, `enemies.${i}`) }, () => { - const got = event.data.enemies[i]; - assert.ok(got, `enemy row ${i} missing (got ${event.data.enemies.length})`); - const rowDebug = JSON.stringify((event.debug?.enemies as unknown[])?.[i]); - if (want.name !== undefined && !isFieldSkipped(fixture, `enemies.${i}.name`)) { - assert.equal(got.name, want.name, `name (debug: ${rowDebug})`); - } - if (want.weaponId !== undefined && !isFieldSkipped(fixture, `enemies.${i}.weapon`)) { - assert.equal(got.weaponId, want.weaponId, `weapon (debug: ${rowDebug})`); - } - if (want.abilities !== undefined && !isFieldSkipped(fixture, `enemies.${i}.abilities`)) { - assert.deepEqual(got.abilities, want.abilities, `abilities (debug: ${rowDebug})`); - } - }); - } - }); + for (const [i, want] of (expected.enemies ?? []).entries()) { + await t.test( + `enemy ${i}`, + { skip: skip(fixture, `enemies.${i}`) }, + () => { + const got = event.data.enemies[i]; + assert.ok( + got, + `enemy row ${i} missing (got ${event.data.enemies.length})`, + ); + const rowDebug = JSON.stringify( + (event.debug?.enemies as unknown[])?.[i], + ); + if ( + want.name !== undefined && + !isFieldSkipped(fixture, `enemies.${i}.name`) + ) { + assert.equal(got.name, want.name, `name (debug: ${rowDebug})`); + } + if ( + want.weaponId !== undefined && + !isFieldSkipped(fixture, `enemies.${i}.weapon`) + ) { + assert.equal( + got.weaponId, + want.weaponId, + `weapon (debug: ${rowDebug})`, + ); + } + if ( + want.abilities !== undefined && + !isFieldSkipped(fixture, `enemies.${i}.abilities`) + ) { + assert.deepEqual( + got.abilities, + want.abilities, + `abilities (debug: ${rowDebug})`, + ); + } + }, + ); + } + }); } // The map overlay replaces everything else on screen; its gate may not fire // on any other detector's positives, nor theirs on the minimap fixtures. const otherPositives = [ - ...loadFixtures("scoreboard").filter((f) => f.expected.event === "Scoreboard"), - ...loadFixtures("scoreboard-replay").filter((f) => f.expected.event === "ScoreboardReplay"), - ...loadFixtures("scoreboard-own").filter((f) => f.expected.event === "ScoreboardOwn"), - ...loadFixtures("death").filter((f) => f.expected.event === "Death"), - ...loadFixtures("map-start").filter((f) => f.expected.event === "MapStart"), + ...loadFixtures("scoreboard").filter( + (f) => f.expected.event === "Scoreboard", + ), + ...loadFixtures("scoreboard-replay").filter( + (f) => f.expected.event === "ScoreboardReplay", + ), + ...loadFixtures("scoreboard-own").filter( + (f) => f.expected.event === "ScoreboardOwn", + ), + ...loadFixtures("death").filter((f) => f.expected.event === "Death"), + ...loadFixtures("map-start").filter((f) => f.expected.event === "MapStart"), ]; for (const fixture of otherPositives) { - test(`minimap gate stays quiet on ${fixture.dir.split("/").slice(-2).join("/")}`, async () => { - const { gate } = await runDetectorOnFixture(detector, fixture); - assert.equal(gate.pass, false, `minimap gate fired (score=${gate.score.toFixed(3)})`); - }); + test(`minimap gate stays quiet on ${fixture.dir.split("/").slice(-2).join("/")}`, async () => { + const { gate } = await runDetectorOnFixture(detector, fixture); + assert.equal( + gate.pass, + false, + `minimap gate fired (score=${gate.score.toFixed(3)})`, + ); + }); } // Shared negatives (tests/fixtures/negative/): frames no detector may fire on. for (const fixture of loadFixtures("negative")) { - test(`minimap gate stays quiet on negative/${fixture.name}`, async () => { - const { gate } = await runDetectorOnFixture(detector, fixture); - assert.equal(gate.pass, false, `minimap gate fired (score=${gate.score.toFixed(3)})`); - }); + test(`minimap gate stays quiet on negative/${fixture.name}`, async () => { + const { gate } = await runDetectorOnFixture(detector, fixture); + assert.equal( + gate.pass, + false, + `minimap gate fired (score=${gate.score.toFixed(3)})`, + ); + }); } const otherDetectors: Detector[] = [ - createScoreboardDetector(resources) as Detector, - createScoreboardReplayDetector(resources) as Detector, - createScoreboardOwnDetector(resources) as Detector, - createDeathDetector(resources) as Detector, - createMapStartDetector(resources) as Detector, + createScoreboardDetector(resources) as Detector, + createScoreboardReplayDetector(resources) as Detector, + createScoreboardOwnDetector(resources) as Detector, + createDeathDetector(resources) as Detector, + createMapStartDetector(resources) as Detector, ]; for (const fixture of fixtures.filter((f) => f.expected.event === "Minimap")) { - test(`other gates stay quiet on minimap/${fixture.name}`, async () => { - for (const other of otherDetectors) { - const { gate } = await runDetectorOnFixture(other, fixture); - assert.equal(gate.pass, false, `${other.id} gate fired (score=${gate.score.toFixed(3)})`); - } - }); + test(`other gates stay quiet on minimap/${fixture.name}`, async () => { + for (const other of otherDetectors) { + const { gate } = await runDetectorOnFixture(other, fixture); + assert.equal( + gate.pass, + false, + `${other.id} gate fired (score=${gate.score.toFixed(3)})`, + ); + } + }); } function skip(fixture: Fixture, field: string): boolean | string { - return isFieldSkipped(fixture, field) ? "skipFields" : false; + return isFieldSkipped(fixture, field) ? "skipFields" : false; } diff --git a/app/features/cv/tests/node-test-compat.ts b/app/features/cv/tests/node-test-compat.ts index a6a192103..01ca591bd 100644 --- a/app/features/cv/tests/node-test-compat.ts +++ b/app/features/cv/tests/node-test-compat.ts @@ -12,7 +12,11 @@ type SubtestBody = () => void | Promise; export interface CompatTestContext { test(name: string, fn: SubtestBody): Promise; - test(name: string, opts: { skip?: boolean | string }, fn: SubtestBody): Promise; + test( + name: string, + opts: { skip?: boolean | string }, + fn: SubtestBody, + ): Promise; } export default function test( @@ -47,11 +51,14 @@ export default function test( throw new Error(`[${subName}] ${String(error)}`); } const summary = failures - .map(({ name: subName, error }) => - `[${subName}] ${error instanceof Error ? error.message : String(error)}`, + .map( + ({ name: subName, error }) => + `[${subName}] ${error instanceof Error ? error.message : String(error)}`, ) .join("\n"); - const aggregate = new Error(`${failures.length} subtests failed:\n${summary}`); + const aggregate = new Error( + `${failures.length} subtests failed:\n${summary}`, + ); const first = failures[0]?.error; if (first instanceof Error && first.stack) aggregate.stack = first.stack; throw aggregate; diff --git a/app/features/cv/tests/opencv-smoke.test.ts b/app/features/cv/tests/opencv-smoke.test.ts index 05cff41f0..2c2308263 100644 --- a/app/features/cv/tests/opencv-smoke.test.ts +++ b/app/features/cv/tests/opencv-smoke.test.ts @@ -1,29 +1,29 @@ import assert from "node:assert/strict"; -import test from "./node-test-compat"; import { loadOpenCV, minMaxLoc } from "../core/cv"; +import test from "./node-test-compat"; test("opencv.js loads in Node and matchTemplate works", async () => { - const cv = await loadOpenCV(); + const cv = await loadOpenCV(); - // 20x20 gray canvas with a 5x5 white square at (10, 7) - const scene = new cv.Mat(20, 20, cv.CV_8UC1, new cv.Scalar(64)); - const white = scene.roi(new cv.Rect(10, 7, 5, 5)); - white.setTo(new cv.Scalar(255)); - white.delete(); + // 20x20 gray canvas with a 5x5 white square at (10, 7) + const scene = new cv.Mat(20, 20, cv.CV_8UC1, new cv.Scalar(64)); + const white = scene.roi(new cv.Rect(10, 7, 5, 5)); + white.setTo(new cv.Scalar(255)); + white.delete(); - // Template must have variance for TM_CCOEFF_NORMED: white square + gray border - const patch = scene.roi(new cv.Rect(9, 6, 7, 7)); - const template = patch.clone(); - patch.delete(); - const result = new cv.Mat(); - cv.matchTemplate(scene, template, result, cv.TM_CCOEFF_NORMED); - const { maxLoc, maxVal } = minMaxLoc(result); + // Template must have variance for TM_CCOEFF_NORMED: white square + gray border + const patch = scene.roi(new cv.Rect(9, 6, 7, 7)); + const template = patch.clone(); + patch.delete(); + const result = new cv.Mat(); + cv.matchTemplate(scene, template, result, cv.TM_CCOEFF_NORMED); + const { maxLoc, maxVal } = minMaxLoc(result); - assert.equal(maxLoc.x, 9); - assert.equal(maxLoc.y, 6); - assert.ok(maxVal > 0.99, `expected strong peak, got ${maxVal}`); + assert.equal(maxLoc.x, 9); + assert.equal(maxLoc.y, 6); + assert.ok(maxVal > 0.99, `expected strong peak, got ${maxVal}`); - scene.delete(); - template.delete(); - result.delete(); + scene.delete(); + template.delete(); + result.delete(); }); diff --git a/app/features/cv/tests/replay-time.test.ts b/app/features/cv/tests/replay-time.test.ts index ed4dbdde3..cc332094a 100644 --- a/app/features/cv/tests/replay-time.test.ts +++ b/app/features/cv/tests/replay-time.test.ts @@ -1,106 +1,142 @@ import assert from "node:assert/strict"; -import test from "./node-test-compat"; import { parseReplayTimestamp } from "../core/replay-time"; +import test from "./node-test-compat"; /** Expected epoch for a local wall time — timezone-independent comparison. */ -function local(year: number, month: number, day: number, h: number, min: number): number { - return new Date(year, month - 1, day, h, min).getTime(); +function local( + year: number, + month: number, + day: number, + h: number, + min: number, +): number { + return new Date(year, month - 1, day, h, min).getTime(); } test("day-first locale reads D/M/Y", () => { - assert.equal( - parseReplayTimestamp("3/7/2026 22:28", { locale: "fi-FI", now: local(2026, 7, 4, 10, 0) }), - local(2026, 7, 3, 22, 28), - ); + assert.equal( + parseReplayTimestamp("3/7/2026 22:28", { + locale: "fi-FI", + now: local(2026, 7, 4, 10, 0), + }), + local(2026, 7, 3, 22, 28), + ); }); test("a 24h hour implies day-first even under a month-first browser locale", () => { - // the real misread: Finnish console, en-US browser — 21:15 proves a 24h - // clock (12h consoles render AM/PM), and 24h locales are day-first - assert.equal( - parseReplayTimestamp("3.7.2026 21:15", { locale: "en-US", now: local(2026, 7, 5, 12, 0) }), - local(2026, 7, 3, 21, 15), - ); - // midnight hours only exist on a 24h clock too - assert.equal( - parseReplayTimestamp("3/7/2026 0:45", { locale: "en-US", now: local(2026, 7, 4, 10, 0) }), - local(2026, 7, 3, 0, 45), - ); + // the real misread: Finnish console, en-US browser — 21:15 proves a 24h + // clock (12h consoles render AM/PM), and 24h locales are day-first + assert.equal( + parseReplayTimestamp("3.7.2026 21:15", { + locale: "en-US", + now: local(2026, 7, 5, 12, 0), + }), + local(2026, 7, 3, 21, 15), + ); + // midnight hours only exist on a 24h clock too + assert.equal( + parseReplayTimestamp("3/7/2026 0:45", { + locale: "en-US", + now: local(2026, 7, 4, 10, 0), + }), + local(2026, 7, 3, 0, 45), + ); }); test("month-first locale reads M/D/Y when the hour is clock-ambiguous", () => { - // 9:15 could be either clock, and now is far from both readings — the - // browser locale is the only remaining signal - assert.equal( - parseReplayTimestamp("3/7/2026 9:15", { locale: "en-US", now: local(2026, 5, 10, 12, 0) }), - local(2026, 3, 7, 9, 15), - ); + // 9:15 could be either clock, and now is far from both readings — the + // browser locale is the only remaining signal + assert.equal( + parseReplayTimestamp("3/7/2026 9:15", { + locale: "en-US", + now: local(2026, 5, 10, 12, 0), + }), + local(2026, 3, 7, 9, 15), + ); }); test("recency flips a locale misread whose swap lands near now", () => { - // Finnish console with an en-US browser and no 24h evidence: the locale - // says March 7, but that's months away while July 3 is yesterday - assert.equal( - parseReplayTimestamp("3/7/2026 9:15", { locale: "en-US", now: local(2026, 7, 4, 12, 0) }), - local(2026, 7, 3, 9, 15), - ); + // Finnish console with an en-US browser and no 24h evidence: the locale + // says March 7, but that's months away while July 3 is yesterday + assert.equal( + parseReplayTimestamp("3/7/2026 9:15", { + locale: "en-US", + now: local(2026, 7, 4, 12, 0), + }), + local(2026, 7, 3, 9, 15), + ); }); test("recency leaves a recent locale reading alone", () => { - assert.equal( - parseReplayTimestamp("3/7/2026 9:15", { locale: "en-US", now: local(2026, 3, 8, 12, 0) }), - local(2026, 3, 7, 9, 15), - ); + assert.equal( + parseReplayTimestamp("3/7/2026 9:15", { + locale: "en-US", + now: local(2026, 3, 8, 12, 0), + }), + local(2026, 3, 7, 9, 15), + ); }); test("an old replay keeps the locale order when neither reading is recent", () => { - assert.equal( - parseReplayTimestamp("5.1.2026 9:15", { locale: "fi-FI", now: local(2026, 7, 15, 12, 0) }), - local(2026, 1, 5, 9, 15), - ); + assert.equal( + parseReplayTimestamp("5.1.2026 9:15", { + locale: "fi-FI", + now: local(2026, 7, 15, 12, 0), + }), + local(2026, 1, 5, 9, 15), + ); }); test("a segment above 12 overrides every order signal", () => { - assert.equal( - parseReplayTimestamp("25/7/2026 9:05", { locale: "en-US" }), - local(2026, 7, 25, 9, 5), - ); - assert.equal( - parseReplayTimestamp("7/25/2026 9:05", { locale: "fi-FI" }), - local(2026, 7, 25, 9, 5), - ); + assert.equal( + parseReplayTimestamp("25/7/2026 9:05", { locale: "en-US" }), + local(2026, 7, 25, 9, 5), + ); + assert.equal( + parseReplayTimestamp("7/25/2026 9:05", { locale: "fi-FI" }), + local(2026, 7, 25, 9, 5), + ); }); test("dot and dash separators parse", () => { - const now = local(2026, 3, 8, 12, 0); - assert.equal( - parseReplayTimestamp("7.3.2026 22:28", { locale: "de-DE", now }), - local(2026, 3, 7, 22, 28), - ); - assert.equal( - parseReplayTimestamp("7-3-2026 22:28", { locale: "de-DE", now }), - local(2026, 3, 7, 22, 28), - ); + const now = local(2026, 3, 8, 12, 0); + assert.equal( + parseReplayTimestamp("7.3.2026 22:28", { locale: "de-DE", now }), + local(2026, 3, 7, 22, 28), + ); + assert.equal( + parseReplayTimestamp("7-3-2026 22:28", { locale: "de-DE", now }), + local(2026, 3, 7, 22, 28), + ); }); test("year-first format is month-first, regardless of clock or recency", () => { - assert.equal( - parseReplayTimestamp("2026/3/7 22:28", { locale: "ja-JP", now: local(2026, 7, 4, 12, 0) }), - local(2026, 3, 7, 22, 28), - ); + assert.equal( + parseReplayTimestamp("2026/3/7 22:28", { + locale: "ja-JP", + now: local(2026, 7, 4, 12, 0), + }), + local(2026, 3, 7, 22, 28), + ); }); test("two-digit year lands in the 2000s", () => { - assert.equal( - parseReplayTimestamp("3/7/26 22:28", { locale: "fi-FI", now: local(2026, 7, 4, 10, 0) }), - local(2026, 7, 3, 22, 28), - ); + assert.equal( + parseReplayTimestamp("3/7/26 22:28", { + locale: "fi-FI", + now: local(2026, 7, 4, 10, 0), + }), + local(2026, 7, 3, 22, 28), + ); }); test("invalid dates and times are rejected", () => { - assert.equal(parseReplayTimestamp("31/2/2026 10:00", { locale: "fi-FI" }), null); - assert.equal(parseReplayTimestamp("13/13/2026 10:00"), null); - assert.equal(parseReplayTimestamp("3/7/2026 25:00"), null); - assert.equal(parseReplayTimestamp("3/7/2026 10:60"), null); - assert.equal(parseReplayTimestamp("not a timestamp"), null); + assert.equal( + parseReplayTimestamp("31/2/2026 10:00", { locale: "fi-FI" }), + null, + ); + assert.equal(parseReplayTimestamp("13/13/2026 10:00"), null); + assert.equal(parseReplayTimestamp("3/7/2026 25:00"), null); + assert.equal(parseReplayTimestamp("3/7/2026 10:60"), null); + assert.equal(parseReplayTimestamp("not a timestamp"), null); }); diff --git a/app/features/cv/tests/scoreboard-own.test.ts b/app/features/cv/tests/scoreboard-own.test.ts index 8f4fda791..a4868271c 100644 --- a/app/features/cv/tests/scoreboard-own.test.ts +++ b/app/features/cv/tests/scoreboard-own.test.ts @@ -6,24 +6,24 @@ */ import assert from "node:assert/strict"; -import test from "./node-test-compat"; import { loadOpenCV } from "../core/cv"; import { createDeathDetector } from "../core/detectors/death/index"; import { createMapStartDetector } from "../core/detectors/map-start/index"; import { createScoreboardDetector } from "../core/detectors/scoreboard/index"; import { - createScoreboardOwnDetector, - type ScoreboardOwnData, + createScoreboardOwnDetector, + type ScoreboardOwnData, } from "../core/detectors/scoreboard-own/index"; import { createScoreboardReplayDetector } from "../core/detectors/scoreboard-replay/index"; import type { Detector } from "../core/detectors/types"; import { - type Fixture, - isFieldSkipped, - loadFixtures, - runDetectorOnFixture, + 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(); @@ -31,99 +31,128 @@ const detector = createScoreboardOwnDetector(resources); const fixtures = loadFixtures("scoreboard-own"); test("scoreboard-own fixtures exist", () => { - assert.ok(fixtures.length > 0, "no fixtures found under scoreboard-own/"); + assert.ok(fixtures.length > 0, "no fixtures found under scoreboard-own/"); }); for (const fixture of fixtures) { - test(`scoreboard-own/${fixture.name}`, async (t) => { - const { gate, events } = await runDetectorOnFixture(detector, fixture); - const expectPositive = fixture.expected.event === "ScoreboardOwn"; + test(`scoreboard-own/${fixture.name}`, async (t) => { + const { gate, events } = await runDetectorOnFixture( + detector, + fixture, + ); + const expectPositive = fixture.expected.event === "ScoreboardOwn"; - 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"}`, - ); - }); + 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 passed but no event parsed"); - const expected = fixture.expected.data ?? {}; + if (!expectPositive) return; + const event = events[0]; + assert.ok(event, "gate passed but no event parsed"); + const expected = fixture.expected.data ?? {}; - for (const field of ["lobby", "mode", "stage"] as const) { - await t.test(field, { skip: expected[field] === undefined || skip(fixture, field) }, () => { - assert.equal( - event.data[field], - expected[field], - `${field} mismatch (header debug: ${JSON.stringify(event.debug?.header)})`, - ); - }); - } + for (const field of ["lobby", "mode", "stage"] as const) { + await t.test( + field, + { skip: expected[field] === undefined || skip(fixture, field) }, + () => { + assert.equal( + event.data[field], + expected[field], + `${field} mismatch (header debug: ${JSON.stringify(event.debug?.header)})`, + ); + }, + ); + } - await t.test( - "weapon", - { skip: expected.weaponId === undefined || skip(fixture, "weapon") }, - () => { - assert.equal( - event.data.weaponId, - expected.weaponId, - `weapon mismatch (read: "${event.debug?.weaponName}", raw: "${event.debug?.weaponReading}", score=${Number(event.debug?.weaponScore ?? 0).toFixed(3)})`, - ); - }, - ); + await t.test( + "weapon", + { skip: expected.weaponId === undefined || skip(fixture, "weapon") }, + () => { + assert.equal( + event.data.weaponId, + expected.weaponId, + `weapon mismatch (read: "${event.debug?.weaponName}", raw: "${event.debug?.weaponReading}", score=${Number(event.debug?.weaponScore ?? 0).toFixed(3)})`, + ); + }, + ); - for (const [row, wantRow] of (expected.abilities ?? []).entries()) { - await t.test(`abilities row ${row}`, { skip: skip(fixture, `abilities.${row}`) }, () => { - assert.deepEqual( - event.data.abilities[row], - wantRow, - `ability row mismatch (debug: ${JSON.stringify((event.debug?.abilityRows as unknown[])?.[row])})`, - ); - }); - } - }); + for (const [row, wantRow] of (expected.abilities ?? []).entries()) { + await t.test( + `abilities row ${row}`, + { skip: skip(fixture, `abilities.${row}`) }, + () => { + assert.deepEqual( + event.data.abilities[row], + wantRow, + `ability row mismatch (debug: ${JSON.stringify((event.debug?.abilityRows as unknown[])?.[row])})`, + ); + }, + ); + } + }); } // The own-results screen replaces everything else on screen; its gate may // not fire on any other detector's positives, nor theirs on its fixtures. const otherPositives = [ - ...loadFixtures("scoreboard").filter((f) => f.expected.event === "Scoreboard"), - ...loadFixtures("scoreboard-replay").filter((f) => f.expected.event === "ScoreboardReplay"), - ...loadFixtures("death").filter((f) => f.expected.event === "Death"), - ...loadFixtures("map-start").filter((f) => f.expected.event === "MapStart"), + ...loadFixtures("scoreboard").filter( + (f) => f.expected.event === "Scoreboard", + ), + ...loadFixtures("scoreboard-replay").filter( + (f) => f.expected.event === "ScoreboardReplay", + ), + ...loadFixtures("death").filter((f) => f.expected.event === "Death"), + ...loadFixtures("map-start").filter((f) => f.expected.event === "MapStart"), ]; for (const fixture of otherPositives) { - test(`scoreboard-own gate stays quiet on ${fixture.dir.split("/").slice(-2).join("/")}`, async () => { - const { gate } = await runDetectorOnFixture(detector, fixture); - assert.equal(gate.pass, false, `scoreboard-own gate fired (score=${gate.score.toFixed(3)})`); - }); + test(`scoreboard-own gate stays quiet on ${fixture.dir.split("/").slice(-2).join("/")}`, async () => { + const { gate } = await runDetectorOnFixture(detector, fixture); + assert.equal( + gate.pass, + false, + `scoreboard-own gate fired (score=${gate.score.toFixed(3)})`, + ); + }); } // Shared negatives (tests/fixtures/negative/): frames no detector may fire on. for (const fixture of loadFixtures("negative")) { - test(`scoreboard-own gate stays quiet on negative/${fixture.name}`, async () => { - const { gate } = await runDetectorOnFixture(detector, fixture); - assert.equal(gate.pass, false, `scoreboard-own gate fired (score=${gate.score.toFixed(3)})`); - }); + test(`scoreboard-own gate stays quiet on negative/${fixture.name}`, async () => { + const { gate } = await runDetectorOnFixture(detector, fixture); + assert.equal( + gate.pass, + false, + `scoreboard-own gate fired (score=${gate.score.toFixed(3)})`, + ); + }); } const otherDetectors: Detector[] = [ - createScoreboardDetector(resources) as Detector, - createScoreboardReplayDetector(resources) as Detector, - createDeathDetector(resources) as Detector, - createMapStartDetector(resources) as Detector, + createScoreboardDetector(resources) as Detector, + createScoreboardReplayDetector(resources) as Detector, + createDeathDetector(resources) as Detector, + createMapStartDetector(resources) as Detector, ]; -for (const fixture of fixtures.filter((f) => f.expected.event === "ScoreboardOwn")) { - test(`other gates stay quiet on scoreboard-own/${fixture.name}`, async () => { - for (const other of otherDetectors) { - const { gate } = await runDetectorOnFixture(other, fixture); - assert.equal(gate.pass, false, `${other.id} gate fired (score=${gate.score.toFixed(3)})`); - } - }); +for (const fixture of fixtures.filter( + (f) => f.expected.event === "ScoreboardOwn", +)) { + test(`other gates stay quiet on scoreboard-own/${fixture.name}`, async () => { + for (const other of otherDetectors) { + const { gate } = await runDetectorOnFixture(other, fixture); + assert.equal( + gate.pass, + false, + `${other.id} gate fired (score=${gate.score.toFixed(3)})`, + ); + } + }); } function skip(fixture: Fixture, field: string): boolean | string { - return isFieldSkipped(fixture, field) ? "skipFields" : false; + return isFieldSkipped(fixture, field) ? "skipFields" : false; } diff --git a/app/features/cv/tests/scoreboard.test.ts b/app/features/cv/tests/scoreboard.test.ts index 1e03dadd1..1531a82f4 100644 --- a/app/features/cv/tests/scoreboard.test.ts +++ b/app/features/cv/tests/scoreboard.test.ts @@ -7,154 +7,182 @@ */ import assert from "node:assert/strict"; -import test from "./node-test-compat"; import { loadOpenCV } from "../core/cv"; import { - createScoreboardDetector, - type ScoreboardPlayer, - type ScoreboardRowDebug, + createScoreboardDetector, + type ScoreboardPlayer, + type ScoreboardRowDebug, } from "../core/detectors/scoreboard/index"; import { - type Fixture, - isFieldSkipped, - loadFixtures, - runDetectorOnFixture, + type Fixture, + isFieldSkipped, + loadFixtures, + runDetectorOnFixture, } from "../node/fixtures"; import { loadScoreboardResources } from "../node/resources"; +import test from "./node-test-compat"; await loadOpenCV(); const detector = createScoreboardDetector(await loadScoreboardResources()); const fixtures = loadFixtures("scoreboard"); test("fixtures exist", () => { - assert.ok(fixtures.length > 0, "no fixtures found under scoreboard/"); + assert.ok(fixtures.length > 0, "no fixtures found under scoreboard/"); }); for (const fixture of fixtures) { - test(`scoreboard/${fixture.name}`, async (t) => { - const { gate, events } = await runDetectorOnFixture(detector, fixture); - const expectPositive = fixture.expected.event === "Scoreboard"; + test(`scoreboard/${fixture.name}`, async (t) => { + const { gate, events } = await runDetectorOnFixture(detector, fixture); + const expectPositive = fixture.expected.event === "Scoreboard"; - 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"}`, - ); - }); + 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 passed but no event parsed"); - const rows = (event.debug?.rows ?? []) as ScoreboardRowDebug[]; - const expected = fixture.expected.data ?? {}; + if (!expectPositive) return; + const event = events[0]; + assert.ok(event, "gate passed but no event parsed"); + const rows = (event.debug?.rows ?? []) as ScoreboardRowDebug[]; + const expected = fixture.expected.data ?? {}; - await t.test("scores", { skip: skip(fixture, "scores") }, () => { - assert.deepEqual(event.data.scores, expected.scores); - }); + await t.test("scores", { skip: skip(fixture, "scores") }, () => { + assert.deepEqual(event.data.scores, expected.scores); + }); - await t.test("header", { skip: expected.mode === undefined || skip(fixture, "header") }, () => { - const dbg = event.debug?.header as - | { lobbyReading?: string; lineReading?: string } - | undefined; - assert.deepEqual( - { lobby: event.data.lobby, mode: event.data.mode, stage: event.data.stage }, - { - lobby: expected.lobby ?? null, - mode: expected.mode ?? null, - stage: expected.stage ?? null, - }, - `header mismatch (readings: "${dbg?.lobbyReading}" / "${dbg?.lineReading}")`, - ); - }); + await t.test( + "header", + { skip: expected.mode === undefined || skip(fixture, "header") }, + () => { + const dbg = event.debug?.header as + | { lobbyReading?: string; lineReading?: string } + | undefined; + assert.deepEqual( + { + lobby: event.data.lobby, + mode: event.data.mode, + stage: event.data.stage, + }, + { + lobby: expected.lobby ?? null, + mode: expected.mode ?? null, + stage: expected.stage ?? null, + }, + `header mismatch (readings: "${dbg?.lobbyReading}" / "${dbg?.lineReading}")`, + ); + }, + ); - await t.test( - "povIndex", - { skip: expected.povIndex === undefined || skip(fixture, "povIndex") }, - () => { - const fractions = rows.map((r) => r.povFraction.toFixed(3)).join(","); - assert.equal( - event.data.povIndex, - expected.povIndex, - `povIndex mismatch (yellow fractions=${fractions})`, - ); - }, - ); + await t.test( + "povIndex", + { skip: expected.povIndex === undefined || skip(fixture, "povIndex") }, + () => { + const fractions = rows.map((r) => r.povFraction.toFixed(3)).join(","); + assert.equal( + event.data.povIndex, + expected.povIndex, + `povIndex mismatch (yellow fractions=${fractions})`, + ); + }, + ); - const players = expected.players ?? []; - for (const [i, want] of players.entries()) { - const got: ScoreboardPlayer | undefined = event.data.players[i]; - const dbg = rows[i]; - assert.ok(got, `row ${i} missing from parse`); + const players = expected.players ?? []; + for (const [i, want] of players.entries()) { + const got: ScoreboardPlayer | undefined = event.data.players[i]; + const dbg = rows[i]; + assert.ok(got, `row ${i} missing from parse`); - await t.test( - `row ${i} weapon`, - { skip: want.weaponId === undefined || skip(fixture, `players.${i}.weaponId`) }, - () => { - const top = dbg?.weapon?.top.map((c) => `${c.id}:${c.score.toFixed(3)}`).join(" "); - assert.equal(got.weaponId, want.weaponId, `weapon mismatch (candidates: ${top})`); - }, - ); + await t.test( + `row ${i} weapon`, + { + skip: + want.weaponId === undefined || + skip(fixture, `players.${i}.weaponId`), + }, + () => { + const top = dbg?.weapon?.top + .map((c) => `${c.id}:${c.score.toFixed(3)}`) + .join(" "); + assert.equal( + got.weaponId, + want.weaponId, + `weapon mismatch (candidates: ${top})`, + ); + }, + ); - await t.test( - `row ${i} name`, - { skip: want.name === undefined || skip(fixture, `players.${i}.name`) }, - () => { - assert.equal( - got.name, - want.name, - `name mismatch (min glyph score=${dbg?.nameScore.toFixed(3)})`, - ); - }, - ); + await t.test( + `row ${i} name`, + { skip: want.name === undefined || skip(fixture, `players.${i}.name`) }, + () => { + assert.equal( + got.name, + want.name, + `name mismatch (min glyph score=${dbg?.nameScore.toFixed(3)})`, + ); + }, + ); - await t.test( - `row ${i} paint`, - { skip: want.paint === undefined || skip(fixture, `players.${i}.paint`) }, - () => { - assert.equal( - got.paint, - want.paint, - `paint mismatch (score=${dbg?.paintScore.toFixed(3)})`, - ); - }, - ); + await t.test( + `row ${i} paint`, + { + skip: want.paint === undefined || skip(fixture, `players.${i}.paint`), + }, + () => { + assert.equal( + got.paint, + want.paint, + `paint mismatch (score=${dbg?.paintScore.toFixed(3)})`, + ); + }, + ); - await t.test( - `row ${i} stats`, - { skip: want.ka === undefined || skip(fixture, `players.${i}.stats`) }, - () => { - const scores = dbg?.statScores.map((s) => s.toFixed(3)).join(","); - assert.deepEqual( - { ka: got.ka, d: got.d, s: got.s }, - { ka: want.ka, d: want.d ?? null, s: want.s ?? null }, - `stat mismatch (scores=${scores})`, - ); - }, - ); - } - }); + await t.test( + `row ${i} stats`, + { skip: want.ka === undefined || skip(fixture, `players.${i}.stats`) }, + () => { + const scores = dbg?.statScores.map((s) => s.toFixed(3)).join(","); + assert.deepEqual( + { ka: got.ka, d: got.d, s: got.s }, + { ka: want.ka, d: want.d ?? null, s: want.s ?? null }, + `stat mismatch (scores=${scores})`, + ); + }, + ); + } + }); } // Mirror of the cross-negative sweep in suites/scoreboard-replay.ts: the live // gate must stay quiet on every replay-browser positive. for (const fixture of loadFixtures("scoreboard-replay").filter( - (f) => f.expected.event === "ScoreboardReplay", + (f) => f.expected.event === "ScoreboardReplay", )) { - test(`scoreboard gate stays quiet on scoreboard-replay/${fixture.name}`, async () => { - const { gate } = await runDetectorOnFixture(detector, fixture); - assert.equal(gate.pass, false, `scoreboard gate fired (score=${gate.score.toFixed(3)})`); - }); + test(`scoreboard gate stays quiet on scoreboard-replay/${fixture.name}`, async () => { + const { gate } = await runDetectorOnFixture(detector, fixture); + assert.equal( + gate.pass, + false, + `scoreboard gate fired (score=${gate.score.toFixed(3)})`, + ); + }); } // Shared negatives (tests/fixtures/negative/): frames no detector may fire on. for (const fixture of loadFixtures("negative")) { - test(`scoreboard gate stays quiet on negative/${fixture.name}`, async () => { - const { gate } = await runDetectorOnFixture(detector, fixture); - assert.equal(gate.pass, false, `scoreboard gate fired (score=${gate.score.toFixed(3)})`); - }); + test(`scoreboard gate stays quiet on negative/${fixture.name}`, async () => { + const { gate } = await runDetectorOnFixture(detector, fixture); + assert.equal( + gate.pass, + false, + `scoreboard gate fired (score=${gate.score.toFixed(3)})`, + ); + }); } function skip(fixture: Fixture, field: string): boolean | string { - return isFieldSkipped(fixture, field) ? "skipFields" : false; + return isFieldSkipped(fixture, field) ? "skipFields" : false; } diff --git a/app/features/cv/tests/suites/death.ts b/app/features/cv/tests/suites/death.ts index 173a56018..61906b6f7 100644 --- a/app/features/cv/tests/suites/death.ts +++ b/app/features/cv/tests/suites/death.ts @@ -12,131 +12,159 @@ */ import assert from "node:assert/strict"; -import test from "../node-test-compat"; import { loadOpenCV } from "../../core/cv"; -import { createDeathDetector, type DeathData } from "../../core/detectors/death/index"; +import { + createDeathDetector, + type DeathData, +} from "../../core/detectors/death/index"; import { createScoreboardDetector } from "../../core/detectors/scoreboard/index"; import { createScoreboardReplayDetector } from "../../core/detectors/scoreboard-replay/index"; import { - type Fixture, - isFieldSkipped, - loadFixtures, - runDetectorOnFixture, + type Fixture, + isFieldSkipped, + loadFixtures, + runDetectorOnFixture, } from "../../node/fixtures"; import { loadScoreboardResources } from "../../node/resources"; +import test from "../node-test-compat"; -export async function runDeathSuite(shard: number, shardCount: number): Promise { - await loadOpenCV(); - const resources = await loadScoreboardResources(); - const detector = createDeathDetector(resources); - const fixtures = loadFixtures("death"); - const mine = (items: T[]): T[] => items.filter((_, i) => i % shardCount === shard); +export async function runDeathSuite( + shard: number, + shardCount: number, +): Promise { + await loadOpenCV(); + const resources = await loadScoreboardResources(); + const detector = createDeathDetector(resources); + const fixtures = loadFixtures("death"); + const mine = (items: T[]): T[] => + items.filter((_, i) => i % shardCount === shard); - if (shard === 0) { - test("death fixtures exist", () => { - assert.ok(fixtures.length > 0, "no fixtures found under death/"); - }); - } + if (shard === 0) { + test("death fixtures exist", () => { + assert.ok(fixtures.length > 0, "no fixtures found under death/"); + }); + } - for (const fixture of mine(fixtures)) { - test(`death/${fixture.name}`, async (t) => { - const { gate, events } = await runDetectorOnFixture(detector, fixture); - const expectPositive = fixture.expected.event === "Death"; + for (const fixture of mine(fixtures)) { + test(`death/${fixture.name}`, async (t) => { + const { gate, events } = await runDetectorOnFixture( + detector, + fixture, + ); + const expectPositive = fixture.expected.event === "Death"; - 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"}`, - ); - }); + 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 passed but no event parsed"); - const expected = fixture.expected.data ?? {}; + if (!expectPositive) return; + const event = events[0]; + assert.ok(event, "gate passed but no event parsed"); + const expected = fixture.expected.data ?? {}; - await t.test( - "weapon", - { skip: expected.weaponId === undefined || skip(fixture, "weapon") }, - () => { - assert.equal( - event.data.weaponId, - expected.weaponId, - `weapon mismatch (read: "${event.debug?.weaponName}", raw: "${event.debug?.weaponRaw}", score=${Number(event.debug?.weaponScore ?? 0).toFixed(3)})`, - ); - if (expected.weaponType !== undefined) { - assert.equal(event.data.weaponType, expected.weaponType); - } - }, - ); + await t.test( + "weapon", + { skip: expected.weaponId === undefined || skip(fixture, "weapon") }, + () => { + assert.equal( + event.data.weaponId, + expected.weaponId, + `weapon mismatch (read: "${event.debug?.weaponName}", raw: "${event.debug?.weaponRaw}", score=${Number(event.debug?.weaponScore ?? 0).toFixed(3)})`, + ); + if (expected.weaponType !== undefined) { + assert.equal(event.data.weaponType, expected.weaponType); + } + }, + ); - for (const [row, wantRow] of (expected.abilities ?? []).entries()) { - await t.test(`abilities row ${row}`, { skip: skip(fixture, `abilities.${row}`) }, () => { - assert.deepEqual( - event.data.abilities[row], - wantRow, - `ability row mismatch (debug: ${JSON.stringify((event.debug?.abilityRows as unknown[])?.[row])})`, - ); - }); - } + for (const [row, wantRow] of (expected.abilities ?? []).entries()) { + await t.test( + `abilities row ${row}`, + { skip: skip(fixture, `abilities.${row}`) }, + () => { + assert.deepEqual( + event.data.abilities[row], + wantRow, + `ability row mismatch (debug: ${JSON.stringify((event.debug?.abilityRows as unknown[])?.[row])})`, + ); + }, + ); + } - await t.test("name", { skip: expected.name === undefined || skip(fixture, "name") }, () => { - assert.equal( - event.data.name, - expected.name, - `name mismatch (raw: "${event.debug?.nameRaw}", score=${Number(event.debug?.nameScore ?? 0).toFixed(3)})`, - ); - }); - }); - } + await t.test( + "name", + { skip: expected.name === undefined || skip(fixture, "name") }, + () => { + assert.equal( + event.data.name, + expected.name, + `name mismatch (raw: "${event.debug?.nameRaw}", score=${Number(event.debug?.nameScore ?? 0).toFixed(3)})`, + ); + }, + ); + }); + } - // The death overlay sits on live gameplay while the scoreboards replace it; - // none of the three gates may fire on another detector's positives. - const scoreboardPositives = loadFixtures("scoreboard").filter( - (f) => f.expected.event === "Scoreboard", - ); - const replayPositives = loadFixtures("scoreboard-replay").filter( - (f) => f.expected.event === "ScoreboardReplay", - ); - for (const fixture of mine([...scoreboardPositives, ...replayPositives])) { - test(`death gate stays quiet on ${fixture.dir.split("/").slice(-2).join("/")}`, async () => { - const { gate } = await runDetectorOnFixture(detector, fixture); - assert.equal(gate.pass, false, `death gate fired (score=${gate.score.toFixed(3)})`); - }); - } + // The death overlay sits on live gameplay while the scoreboards replace it; + // none of the three gates may fire on another detector's positives. + const scoreboardPositives = loadFixtures("scoreboard").filter( + (f) => f.expected.event === "Scoreboard", + ); + const replayPositives = loadFixtures("scoreboard-replay").filter( + (f) => f.expected.event === "ScoreboardReplay", + ); + for (const fixture of mine([...scoreboardPositives, ...replayPositives])) { + test(`death gate stays quiet on ${fixture.dir.split("/").slice(-2).join("/")}`, async () => { + const { gate } = await runDetectorOnFixture(detector, fixture); + assert.equal( + gate.pass, + false, + `death gate fired (score=${gate.score.toFixed(3)})`, + ); + }); + } - // Shared negatives (tests/fixtures/negative/): frames no detector may fire on. - for (const fixture of mine(loadFixtures("negative"))) { - test(`death gate stays quiet on negative/${fixture.name}`, async () => { - const { gate } = await runDetectorOnFixture(detector, fixture); - assert.equal(gate.pass, false, `death gate fired (score=${gate.score.toFixed(3)})`); - }); - } + // Shared negatives (tests/fixtures/negative/): frames no detector may fire on. + for (const fixture of mine(loadFixtures("negative"))) { + test(`death gate stays quiet on negative/${fixture.name}`, async () => { + const { gate } = await runDetectorOnFixture(detector, fixture); + assert.equal( + gate.pass, + false, + `death gate fired (score=${gate.score.toFixed(3)})`, + ); + }); + } - const crossFixtures = mine(fixtures.filter((f) => f.expected.event === "Death")); - if (crossFixtures.length > 0) { - const scoreboardDetector = createScoreboardDetector(resources); - const replayDetector = createScoreboardReplayDetector(resources); - for (const fixture of crossFixtures) { - test(`scoreboard gates stay quiet on death/${fixture.name}`, async () => { - const live = await runDetectorOnFixture(scoreboardDetector, fixture); - assert.equal( - live.gate.pass, - false, - `scoreboard gate fired (score=${live.gate.score.toFixed(3)})`, - ); - const replay = await runDetectorOnFixture(replayDetector, fixture); - assert.equal( - replay.gate.pass, - false, - `replay gate fired (score=${replay.gate.score.toFixed(3)})`, - ); - }); - } - } + const crossFixtures = mine( + fixtures.filter((f) => f.expected.event === "Death"), + ); + if (crossFixtures.length > 0) { + const scoreboardDetector = createScoreboardDetector(resources); + const replayDetector = createScoreboardReplayDetector(resources); + for (const fixture of crossFixtures) { + test(`scoreboard gates stay quiet on death/${fixture.name}`, async () => { + const live = await runDetectorOnFixture(scoreboardDetector, fixture); + assert.equal( + live.gate.pass, + false, + `scoreboard gate fired (score=${live.gate.score.toFixed(3)})`, + ); + const replay = await runDetectorOnFixture(replayDetector, fixture); + assert.equal( + replay.gate.pass, + false, + `replay gate fired (score=${replay.gate.score.toFixed(3)})`, + ); + }); + } + } } function skip(fixture: Fixture, field: string): boolean | string { - return isFieldSkipped(fixture, field) ? "skipFields" : false; + return isFieldSkipped(fixture, field) ? "skipFields" : false; } diff --git a/app/features/cv/tests/suites/scoreboard-replay.ts b/app/features/cv/tests/suites/scoreboard-replay.ts index 3bc1b1ed3..c5381519b 100644 --- a/app/features/cv/tests/suites/scoreboard-replay.ts +++ b/app/features/cv/tests/suites/scoreboard-replay.ts @@ -12,207 +12,255 @@ */ import assert from "node:assert/strict"; -import test from "../node-test-compat"; import { loadOpenCV } from "../../core/cv"; import type { - ScoreboardPlayer, - ScoreboardRowDebug, + ScoreboardPlayer, + ScoreboardRowDebug, } from "../../core/detectors/scoreboard/index"; import { createScoreboardReplayDetector } from "../../core/detectors/scoreboard-replay/index"; import { - type Fixture, - isFieldSkipped, - loadFixtures, - runDetectorOnFixture, + type Fixture, + isFieldSkipped, + loadFixtures, + runDetectorOnFixture, } from "../../node/fixtures"; import { loadScoreboardResources } from "../../node/resources"; +import test from "../node-test-compat"; -export async function runScoreboardReplaySuite(shard: number, shardCount: number): Promise { - await loadOpenCV(); - const detector = createScoreboardReplayDetector(await loadScoreboardResources()); - const fixtures = loadFixtures("scoreboard-replay"); - const mine = (items: T[]): T[] => items.filter((_, i) => i % shardCount === shard); +export async function runScoreboardReplaySuite( + shard: number, + shardCount: number, +): Promise { + await loadOpenCV(); + const detector = createScoreboardReplayDetector( + await loadScoreboardResources(), + ); + const fixtures = loadFixtures("scoreboard-replay"); + const mine = (items: T[]): T[] => + items.filter((_, i) => i % shardCount === shard); - if (shard === 0) { - test("replay fixtures exist", () => { - assert.ok(fixtures.length > 0, "no fixtures found under scoreboard-replay/"); - }); - } + if (shard === 0) { + test("replay fixtures exist", () => { + assert.ok( + fixtures.length > 0, + "no fixtures found under scoreboard-replay/", + ); + }); + } - for (const fixture of mine(fixtures)) { - test(`scoreboard-replay/${fixture.name}`, async (t) => { - const { gate, events } = await runDetectorOnFixture(detector, fixture); - const expectPositive = fixture.expected.event === "ScoreboardReplay"; + for (const fixture of mine(fixtures)) { + test(`scoreboard-replay/${fixture.name}`, async (t) => { + const { gate, events } = await runDetectorOnFixture(detector, fixture); + const expectPositive = fixture.expected.event === "ScoreboardReplay"; - 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"}`, - ); - }); + 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 passed but no event parsed"); - const rows = (event.debug?.rows ?? []) as ScoreboardRowDebug[]; - const expected = fixture.expected.data ?? {}; + if (!expectPositive) return; + const event = events[0]; + assert.ok(event, "gate passed but no event parsed"); + const rows = (event.debug?.rows ?? []) as ScoreboardRowDebug[]; + const expected = fixture.expected.data ?? {}; - await t.test("scores", { skip: skip(fixture, "scores") }, () => { - assert.deepEqual(event.data.scores, expected.scores); - }); + await t.test("scores", { skip: skip(fixture, "scores") }, () => { + assert.deepEqual(event.data.scores, expected.scores); + }); - await t.test( - "matchScores", - { skip: expected.matchScores === undefined || skip(fixture, "matchScores") }, - () => { - assert.deepEqual(event.data.matchScores, expected.matchScores); - }, - ); + await t.test( + "matchScores", + { + skip: + expected.matchScores === undefined || skip(fixture, "matchScores"), + }, + () => { + assert.deepEqual(event.data.matchScores, expected.matchScores); + }, + ); - await t.test( - "header", - { skip: expected.mode === undefined || skip(fixture, "header") }, - () => { - const dbg = event.debug?.header as - | { topReading?: string; bottomReading?: string } - | undefined; - assert.deepEqual( - { lobby: event.data.lobby, mode: event.data.mode, stage: event.data.stage }, - { - lobby: expected.lobby ?? null, - mode: expected.mode ?? null, - stage: expected.stage ?? null, - }, - `header mismatch (readings: "${dbg?.topReading}" / "${dbg?.bottomReading}")`, - ); - }, - ); + await t.test( + "header", + { skip: expected.mode === undefined || skip(fixture, "header") }, + () => { + const dbg = event.debug?.header as + | { topReading?: string; bottomReading?: string } + | undefined; + assert.deepEqual( + { + lobby: event.data.lobby, + mode: event.data.mode, + stage: event.data.stage, + }, + { + lobby: expected.lobby ?? null, + mode: expected.mode ?? null, + stage: expected.stage ?? null, + }, + `header mismatch (readings: "${dbg?.topReading}" / "${dbg?.bottomReading}")`, + ); + }, + ); - await t.test( - "timestamp", - { skip: expected.timestamp === undefined || skip(fixture, "timestamp") }, - () => { - const dbg = event.debug?.header as { topReading?: string } | undefined; - assert.equal( - event.data.timestamp, - expected.timestamp, - `timestamp mismatch (reading: "${dbg?.topReading}")`, - ); - }, - ); + await t.test( + "timestamp", + { + skip: expected.timestamp === undefined || skip(fixture, "timestamp"), + }, + () => { + const dbg = event.debug?.header as + | { topReading?: string } + | undefined; + assert.equal( + event.data.timestamp, + expected.timestamp, + `timestamp mismatch (reading: "${dbg?.topReading}")`, + ); + }, + ); - await t.test( - "replayCode", - { skip: expected.replayCode === undefined || skip(fixture, "replayCode") }, - () => { - assert.equal( - event.data.replayCode, - expected.replayCode, - `replay code mismatch (raw: "${event.debug?.codeRaw}")`, - ); - }, - ); + await t.test( + "replayCode", + { + skip: + expected.replayCode === undefined || skip(fixture, "replayCode"), + }, + () => { + assert.equal( + event.data.replayCode, + expected.replayCode, + `replay code mismatch (raw: "${event.debug?.codeRaw}")`, + ); + }, + ); - await t.test( - "povIndex", - { skip: expected.povIndex === undefined || skip(fixture, "povIndex") }, - () => { - const fractions = rows.map((r) => r.povFraction.toFixed(3)).join(","); - assert.equal( - event.data.povIndex, - expected.povIndex, - `povIndex mismatch (yellow fractions=${fractions})`, - ); - }, - ); + await t.test( + "povIndex", + { skip: expected.povIndex === undefined || skip(fixture, "povIndex") }, + () => { + const fractions = rows.map((r) => r.povFraction.toFixed(3)).join(","); + assert.equal( + event.data.povIndex, + expected.povIndex, + `povIndex mismatch (yellow fractions=${fractions})`, + ); + }, + ); - const players = expected.players ?? []; + const players = expected.players ?? []; - // Fixtures list the complete roster; empty pills (short teams) must be - // skipped by the parser, not emitted as phantom players. - await t.test( - "player count", - { skip: expected.players === undefined || skip(fixture, "players") }, - () => { - assert.equal(event.data.players.length, players.length); - }, - ); + // Fixtures list the complete roster; empty pills (short teams) must be + // skipped by the parser, not emitted as phantom players. + await t.test( + "player count", + { skip: expected.players === undefined || skip(fixture, "players") }, + () => { + assert.equal(event.data.players.length, players.length); + }, + ); - for (const [i, want] of players.entries()) { - const got: ScoreboardPlayer | undefined = event.data.players[i]; - const dbg = rows[i]; - assert.ok(got, `row ${i} missing from parse`); + for (const [i, want] of players.entries()) { + const got: ScoreboardPlayer | undefined = event.data.players[i]; + const dbg = rows[i]; + assert.ok(got, `row ${i} missing from parse`); - await t.test( - `row ${i} weapon`, - { skip: want.weaponId === undefined || skip(fixture, `players.${i}.weaponId`) }, - () => { - const top = dbg?.weapon?.top.map((c) => `${c.id}:${c.score.toFixed(3)}`).join(" "); - assert.equal(got.weaponId, want.weaponId, `weapon mismatch (candidates: ${top})`); - }, - ); + await t.test( + `row ${i} weapon`, + { + skip: + want.weaponId === undefined || + skip(fixture, `players.${i}.weaponId`), + }, + () => { + const top = dbg?.weapon?.top + .map((c) => `${c.id}:${c.score.toFixed(3)}`) + .join(" "); + assert.equal( + got.weaponId, + want.weaponId, + `weapon mismatch (candidates: ${top})`, + ); + }, + ); - await t.test( - `row ${i} name`, - { skip: want.name === undefined || skip(fixture, `players.${i}.name`) }, - () => { - assert.equal( - got.name, - want.name, - `name mismatch (min glyph score=${dbg?.nameScore.toFixed(3)})`, - ); - }, - ); + await t.test( + `row ${i} name`, + { + skip: want.name === undefined || skip(fixture, `players.${i}.name`), + }, + () => { + assert.equal( + got.name, + want.name, + `name mismatch (min glyph score=${dbg?.nameScore.toFixed(3)})`, + ); + }, + ); - await t.test( - `row ${i} paint`, - { skip: want.paint === undefined || skip(fixture, `players.${i}.paint`) }, - () => { - assert.equal( - got.paint, - want.paint, - `paint mismatch (score=${dbg?.paintScore.toFixed(3)})`, - ); - }, - ); + await t.test( + `row ${i} paint`, + { + skip: + want.paint === undefined || skip(fixture, `players.${i}.paint`), + }, + () => { + assert.equal( + got.paint, + want.paint, + `paint mismatch (score=${dbg?.paintScore.toFixed(3)})`, + ); + }, + ); - await t.test( - `row ${i} stats`, - { skip: want.ka === undefined || skip(fixture, `players.${i}.stats`) }, - () => { - const scores = dbg?.statScores.map((s) => s.toFixed(3)).join(","); - assert.deepEqual( - { ka: got.ka, d: got.d, s: got.s }, - { ka: want.ka, d: want.d ?? null, s: want.s ?? null }, - `stat mismatch (scores=${scores})`, - ); - }, - ); - } - }); - } + await t.test( + `row ${i} stats`, + { + skip: want.ka === undefined || skip(fixture, `players.${i}.stats`), + }, + () => { + const scores = dbg?.statScores.map((s) => s.toFixed(3)).join(","); + assert.deepEqual( + { ka: got.ka, d: got.d, s: got.s }, + { ka: want.ka, d: want.d ?? null, s: want.s ?? null }, + `stat mismatch (scores=${scores})`, + ); + }, + ); + } + }); + } - // The two scoreboard screens must not trigger each other's detectors; the - // mirror sweep (live gate over replay fixtures) lives in scoreboard.test.ts. - for (const fixture of mine( - loadFixtures("scoreboard").filter((f) => f.expected.event === "Scoreboard"), - )) { - test(`replay gate stays quiet on scoreboard/${fixture.name}`, async () => { - const { gate } = await runDetectorOnFixture(detector, fixture); - assert.equal(gate.pass, false, `replay gate fired (score=${gate.score.toFixed(3)})`); - }); - } + // The two scoreboard screens must not trigger each other's detectors; the + // mirror sweep (live gate over replay fixtures) lives in scoreboard.test.ts. + for (const fixture of mine( + loadFixtures("scoreboard").filter((f) => f.expected.event === "Scoreboard"), + )) { + test(`replay gate stays quiet on scoreboard/${fixture.name}`, async () => { + const { gate } = await runDetectorOnFixture(detector, fixture); + assert.equal( + gate.pass, + false, + `replay gate fired (score=${gate.score.toFixed(3)})`, + ); + }); + } - // Shared negatives (tests/fixtures/negative/): frames no detector may fire on. - for (const fixture of mine(loadFixtures("negative"))) { - test(`replay gate stays quiet on negative/${fixture.name}`, async () => { - const { gate } = await runDetectorOnFixture(detector, fixture); - assert.equal(gate.pass, false, `replay gate fired (score=${gate.score.toFixed(3)})`); - }); - } + // Shared negatives (tests/fixtures/negative/): frames no detector may fire on. + for (const fixture of mine(loadFixtures("negative"))) { + test(`replay gate stays quiet on negative/${fixture.name}`, async () => { + const { gate } = await runDetectorOnFixture(detector, fixture); + assert.equal( + gate.pass, + false, + `replay gate fired (score=${gate.score.toFixed(3)})`, + ); + }); + } } function skip(fixture: Fixture, field: string): boolean | string { - return isFieldSkipped(fixture, field) ? "skipFields" : false; + return isFieldSkipped(fixture, field) ? "skipFields" : false; } diff --git a/app/features/cv/tests/suppressor.test.ts b/app/features/cv/tests/suppressor.test.ts index 92c81589b..5d2a31833 100644 --- a/app/features/cv/tests/suppressor.test.ts +++ b/app/features/cv/tests/suppressor.test.ts @@ -5,56 +5,60 @@ */ import assert from "node:assert/strict"; -import test from "./node-test-compat"; import { ParseSuppressor } from "../core/detectors/suppressor"; +import test from "./node-test-compat"; const OPTS = { maxStagnantParses: 3, minImprovement: 0.001 }; -function feed(s: ParseSuppressor, id: string, confidence: number | null): boolean { - const allowed = s.shouldParse(id, true); - if (allowed) s.recordParse(id, confidence === null ? [] : [{ confidence }]); - return allowed; +function feed( + s: ParseSuppressor, + id: string, + confidence: number | null, +): boolean { + const allowed = s.shouldParse(id, true); + if (allowed) s.recordParse(id, confidence === null ? [] : [{ confidence }]); + return allowed; } test("suppresses after stagnant parses on a static screen", () => { - const s = new ParseSuppressor(OPTS); - assert.equal(feed(s, "scoreboard", 0.9), true); // sets the baseline - assert.equal(feed(s, "scoreboard", 0.9), true); // stagnant 1 - assert.equal(feed(s, "scoreboard", 0.9), true); // stagnant 2 - assert.equal(feed(s, "scoreboard", 0.9), true); // stagnant 3 -> suppressed - assert.equal(s.shouldParse("scoreboard", true), false); - assert.equal(s.shouldParse("scoreboard", true), false); + const s = new ParseSuppressor(OPTS); + assert.equal(feed(s, "scoreboard", 0.9), true); // sets the baseline + assert.equal(feed(s, "scoreboard", 0.9), true); // stagnant 1 + assert.equal(feed(s, "scoreboard", 0.9), true); // stagnant 2 + assert.equal(feed(s, "scoreboard", 0.9), true); // stagnant 3 -> suppressed + assert.equal(s.shouldParse("scoreboard", true), false); + assert.equal(s.shouldParse("scoreboard", true), false); }); test("an improving read resets the stagnation counter", () => { - const s = new ParseSuppressor(OPTS); - feed(s, "scoreboard", 0.7); - feed(s, "scoreboard", 0.7); - feed(s, "scoreboard", 0.7); - assert.equal(feed(s, "scoreboard", 0.85), true); // improvement, counter resets - assert.equal(feed(s, "scoreboard", 0.85), true); - assert.equal(feed(s, "scoreboard", 0.85), true); - assert.equal(feed(s, "scoreboard", 0.85), true); // stagnant 3 -> suppressed - assert.equal(s.shouldParse("scoreboard", true), false); + const s = new ParseSuppressor(OPTS); + feed(s, "scoreboard", 0.7); + feed(s, "scoreboard", 0.7); + feed(s, "scoreboard", 0.7); + assert.equal(feed(s, "scoreboard", 0.85), true); // improvement, counter resets + assert.equal(feed(s, "scoreboard", 0.85), true); + assert.equal(feed(s, "scoreboard", 0.85), true); + assert.equal(feed(s, "scoreboard", 0.85), true); // stagnant 3 -> suppressed + assert.equal(s.shouldParse("scoreboard", true), false); }); test("a gate drop ends the streak and re-enables parsing", () => { - const s = new ParseSuppressor(OPTS); - for (let i = 0; i < 5; i++) feed(s, "scoreboard", 0.9); - assert.equal(s.shouldParse("scoreboard", true), false); - assert.equal(s.shouldParse("scoreboard", false), false); // screen changed - assert.equal(feed(s, "scoreboard", 0.9), true); // fresh streak parses again + const s = new ParseSuppressor(OPTS); + for (let i = 0; i < 5; i++) feed(s, "scoreboard", 0.9); + assert.equal(s.shouldParse("scoreboard", true), false); + assert.equal(s.shouldParse("scoreboard", false), false); // screen changed + assert.equal(feed(s, "scoreboard", 0.9), true); // fresh streak parses again }); test("gate firing without events stagnates too", () => { - const s = new ParseSuppressor(OPTS); - for (let i = 0; i < 4; i++) assert.equal(feed(s, "death", null), true); - assert.equal(s.shouldParse("death", true), false); + const s = new ParseSuppressor(OPTS); + for (let i = 0; i < 4; i++) assert.equal(feed(s, "death", null), true); + assert.equal(s.shouldParse("death", true), false); }); test("detectors are tracked independently", () => { - const s = new ParseSuppressor(OPTS); - for (let i = 0; i < 5; i++) feed(s, "scoreboard", 0.9); - assert.equal(s.shouldParse("scoreboard", true), false); - assert.equal(s.shouldParse("death", true), true); + const s = new ParseSuppressor(OPTS); + for (let i = 0; i < 5; i++) feed(s, "scoreboard", 0.9); + assert.equal(s.shouldParse("scoreboard", true), false); + assert.equal(s.shouldParse("death", true), true); }); diff --git a/app/features/cv/tests/timeline.test.ts b/app/features/cv/tests/timeline.test.ts index 2c4f156f2..f4ec571d8 100644 --- a/app/features/cv/tests/timeline.test.ts +++ b/app/features/cv/tests/timeline.test.ts @@ -1,150 +1,177 @@ import assert from "node:assert/strict"; -import test from "./node-test-compat"; import type { ScoreboardData } from "../core/detectors/scoreboard/index"; import type { DetectedEvent } from "../core/detectors/types"; import { TimelineBuilder } from "../core/timeline/index"; +import test from "./node-test-compat"; -function event(t: number, confidence: number, type = "Scoreboard"): DetectedEvent { - return { type, t, confidence, data: { t } }; +function event( + t: number, + confidence: number, + type = "Scoreboard", +): DetectedEvent { + return { type, t, confidence, data: { t } }; } /** A ScoreboardReplay event with just the identity-relevant data fields. */ function replay( - t: number, - confidence: number, - data: Partial & { timestamp?: string | null; replayCode?: string | null }, + t: number, + confidence: number, + data: Partial & { + timestamp?: string | null; + replayCode?: string | null; + }, ): DetectedEvent { - return { - type: "ScoreboardReplay", - t, - confidence, - data: { - lobby: null, - mode: null, - stage: null, - timestamp: null, - replayCode: null, - scores: [null, null], - players: [], - povIndex: null, - ...data, - }, - }; + return { + type: "ScoreboardReplay", + t, + confidence, + data: { + lobby: null, + mode: null, + stage: null, + timestamp: null, + replayCode: null, + scores: [null, null], + players: [], + povIndex: null, + ...data, + }, + }; } function players(paints: (number | null)[]): ScoreboardData["players"] { - return paints.map((paint, i) => ({ - name: `p${i}`, - weaponId: null, - paint, - ka: null, - d: null, - s: null, - })); + return paints.map((paint, i) => ({ + name: `p${i}`, + weaponId: null, + paint, + ka: null, + d: null, + s: null, + })); } test("timeline drops low-confidence events", () => { - const tl = new TimelineBuilder({ minConfidence: 0.6 }); - assert.equal(tl.push(event(0, 0.5)).action, "dropped"); - assert.equal(tl.events.length, 0); + const tl = new TimelineBuilder({ minConfidence: 0.6 }); + assert.equal(tl.push(event(0, 0.5)).action, "dropped"); + assert.equal(tl.events.length, 0); }); test("timeline merges same-type events inside the window", () => { - const tl = new TimelineBuilder({ mergeWindow: 30, minConfidence: 0 }); - assert.equal(tl.push(event(100, 0.8)).action, "added"); - assert.equal(tl.push(event(110, 0.7)).action, "merged"); - assert.equal(tl.events.length, 1); - assert.equal(tl.events[0]!.confidence, 0.8); + const tl = new TimelineBuilder({ mergeWindow: 30, minConfidence: 0 }); + assert.equal(tl.push(event(100, 0.8)).action, "added"); + assert.equal(tl.push(event(110, 0.7)).action, "merged"); + assert.equal(tl.events.length, 1); + assert.equal(tl.events[0]!.confidence, 0.8); }); test("timeline keeps the highest-confidence version", () => { - const tl = new TimelineBuilder({ mergeWindow: 30, minConfidence: 0 }); - tl.push(event(100, 0.7)); - const result = tl.push(event(105, 0.9)); - assert.equal(result.action, "replaced"); - assert.equal(tl.events[0]!.confidence, 0.9); + const tl = new TimelineBuilder({ mergeWindow: 30, minConfidence: 0 }); + tl.push(event(100, 0.7)); + const result = tl.push(event(105, 0.9)); + assert.equal(result.action, "replaced"); + assert.equal(tl.events[0]!.confidence, 0.9); }); test("timeline keeps separate events outside the window", () => { - const tl = new TimelineBuilder({ mergeWindow: 30, minConfidence: 0 }); - tl.push(event(100, 0.8)); - assert.equal(tl.push(event(200, 0.8)).action, "added"); - assert.equal(tl.events.length, 2); + const tl = new TimelineBuilder({ mergeWindow: 30, minConfidence: 0 }); + tl.push(event(100, 0.8)); + assert.equal(tl.push(event(200, 0.8)).action, "added"); + assert.equal(tl.events.length, 2); }); test("different event types never merge", () => { - const tl = new TimelineBuilder({ mergeWindow: 30, minConfidence: 0 }); - tl.push(event(100, 0.8, "Scoreboard")); - assert.equal(tl.push(event(105, 0.8, "Death")).action, "added"); - assert.equal(tl.events.length, 2); + const tl = new TimelineBuilder({ mergeWindow: 30, minConfidence: 0 }); + tl.push(event(100, 0.8, "Scoreboard")); + assert.equal(tl.push(event(105, 0.8, "Death")).action, "added"); + assert.equal(tl.events.length, 2); }); test("scoreboards with different stages stay separate inside the window", () => { - const tl = new TimelineBuilder({ minConfidence: 0 }); - assert.equal(tl.push(replay(100, 0.8, { stage: 21 })).action, "added"); - assert.equal(tl.push(replay(104, 0.8, { stage: 2 })).action, "added"); - assert.equal(tl.push(replay(108, 0.8, { stage: 17 })).action, "added"); - // revisiting the first replay merges back into its event, skipping the - // incompatible ones in between - assert.equal(tl.push(replay(112, 0.7, { stage: 21 })).action, "merged"); - assert.equal(tl.events.length, 3); + const tl = new TimelineBuilder({ minConfidence: 0 }); + assert.equal(tl.push(replay(100, 0.8, { stage: 21 })).action, "added"); + assert.equal(tl.push(replay(104, 0.8, { stage: 2 })).action, "added"); + assert.equal(tl.push(replay(108, 0.8, { stage: 17 })).action, "added"); + // revisiting the first replay merges back into its event, skipping the + // incompatible ones in between + assert.equal(tl.push(replay(112, 0.7, { stage: 21 })).action, "merged"); + assert.equal(tl.events.length, 3); }); test("a null stage read never splits", () => { - const tl = new TimelineBuilder({ minConfidence: 0 }); - tl.push(replay(100, 0.8, { stage: 21 })); - assert.equal(tl.push(replay(104, 0.7, { stage: null })).action, "merged"); + const tl = new TimelineBuilder({ minConfidence: 0 }); + tl.push(replay(100, 0.8, { stage: 21 })); + assert.equal(tl.push(replay(104, 0.7, { stage: null })).action, "merged"); }); test("different recording timestamps split, equal ones merge", () => { - const tl = new TimelineBuilder({ minConfidence: 0 }); - tl.push(replay(100, 0.8, { timestamp: "3/7/2026 21:15" })); - assert.equal(tl.push(replay(104, 0.8, { timestamp: "3/7/2026 21:22" })).action, "added"); - assert.equal(tl.push(replay(108, 0.7, { timestamp: "3/7/2026 21:15" })).action, "merged"); + const tl = new TimelineBuilder({ minConfidence: 0 }); + tl.push(replay(100, 0.8, { timestamp: "3/7/2026 21:15" })); + assert.equal( + tl.push(replay(104, 0.8, { timestamp: "3/7/2026 21:22" })).action, + "added", + ); + assert.equal( + tl.push(replay(108, 0.7, { timestamp: "3/7/2026 21:15" })).action, + "merged", + ); }); test("replay codes tolerate misread glyphs but split on real differences", () => { - const tl = new TimelineBuilder({ minConfidence: 0 }); - tl.push(replay(100, 0.8, { replayCode: "R797-V51Y-945W-C4JJ" })); - // same replay, three glyphs misread on a low-fidelity capture - assert.equal(tl.push(replay(104, 0.7, { replayCode: "R797-U51Y-945W-G4JL" })).action, "merged"); - assert.equal(tl.push(replay(108, 0.8, { replayCode: "RVRM-XXEL-0573-Q4SV" })).action, "added"); + const tl = new TimelineBuilder({ minConfidence: 0 }); + tl.push(replay(100, 0.8, { replayCode: "R797-V51Y-945W-C4JJ" })); + // same replay, three glyphs misread on a low-fidelity capture + assert.equal( + tl.push(replay(104, 0.7, { replayCode: "R797-U51Y-945W-G4JL" })).action, + "merged", + ); + assert.equal( + tl.push(replay(108, 0.8, { replayCode: "RVRM-XXEL-0573-Q4SV" })).action, + "added", + ); }); test("disjoint paint totals split when stage and code are unreadable", () => { - const tl = new TimelineBuilder({ minConfidence: 0 }); - tl.push(replay(100, 0.8, { players: players([327, 408, 270, 354, 381, 613, 456, 287]) })); - const other = replay(104, 0.8, { - players: players([733, 946, 1020, 878, 666, 1068, 1050, 631]), - }); - assert.equal(tl.push(other).action, "added"); - // same board re-read with jitter: a couple of rows misread or null - const jittered = replay(108, 0.7, { - players: players([733, 946, 1020, null, 666, 1068, 1050, 613]), - }); - assert.equal(tl.push(jittered).action, "merged"); - assert.equal(tl.events.length, 2); + const tl = new TimelineBuilder({ minConfidence: 0 }); + tl.push( + replay(100, 0.8, { + players: players([327, 408, 270, 354, 381, 613, 456, 287]), + }), + ); + const other = replay(104, 0.8, { + players: players([733, 946, 1020, 878, 666, 1068, 1050, 631]), + }); + assert.equal(tl.push(other).action, "added"); + // same board re-read with jitter: a couple of rows misread or null + const jittered = replay(108, 0.7, { + players: players([733, 946, 1020, null, 666, 1068, 1050, 613]), + }); + assert.equal(tl.push(jittered).action, "merged"); + assert.equal(tl.events.length, 2); }); test("too few readable paints never split", () => { - const tl = new TimelineBuilder({ minConfidence: 0 }); - tl.push(replay(100, 0.8, { players: players([327, 408, 270, null, null, null, null, null]) })); - const other = replay(104, 0.7, { - players: players([733, 946, 1020, null, null, null, null, null]), - }); - assert.equal(tl.push(other).action, "merged"); + const tl = new TimelineBuilder({ minConfidence: 0 }); + tl.push( + replay(100, 0.8, { + players: players([327, 408, 270, null, null, null, null, null]), + }), + ); + const other = replay(104, 0.7, { + players: players([733, 946, 1020, null, null, null, null, null]), + }); + assert.equal(tl.push(other).action, "merged"); }); test("per-type merge window: repeat death frames merge, consecutive deaths do not", () => { - const tl = new TimelineBuilder({ minConfidence: 0 }); - assert.equal(tl.push(event(100, 0.8, "Death")).action, "added"); - // same death, sampled again 3s later - assert.equal(tl.push(event(103, 0.7, "Death")).action, "merged"); - // next death after respawn + travel: outside the 8s Death window but - // well inside the default 30s - assert.equal(tl.push(event(112, 0.8, "Death")).action, "added"); - assert.equal(tl.events.length, 2); - // scoreboards keep the wide default window - tl.push(event(200, 0.8, "Scoreboard")); - assert.equal(tl.push(event(212, 0.7, "Scoreboard")).action, "merged"); + const tl = new TimelineBuilder({ minConfidence: 0 }); + assert.equal(tl.push(event(100, 0.8, "Death")).action, "added"); + // same death, sampled again 3s later + assert.equal(tl.push(event(103, 0.7, "Death")).action, "merged"); + // next death after respawn + travel: outside the 8s Death window but + // well inside the default 30s + assert.equal(tl.push(event(112, 0.8, "Death")).action, "added"); + assert.equal(tl.events.length, 2); + // scoreboards keep the wide default window + tl.push(event(200, 0.8, "Scoreboard")); + assert.equal(tl.push(event(212, 0.7, "Scoreboard")).action, "merged"); }); diff --git a/app/features/cv/tests/vod-matches.test.ts b/app/features/cv/tests/vod-matches.test.ts index 370ac1245..017e0ce18 100644 --- a/app/features/cv/tests/vod-matches.test.ts +++ b/app/features/cv/tests/vod-matches.test.ts @@ -1,134 +1,138 @@ import assert from "node:assert/strict"; -import type { MainWeaponId, ModeShort, StageId } from "~/modules/in-game-lists/types"; -import test from "./node-test-compat"; import type { - MinimapData, - MinimapEnemy, - MinimapTeammate, + MainWeaponId, + ModeShort, + StageId, +} from "~/modules/in-game-lists/types"; +import type { + MinimapData, + MinimapEnemy, + MinimapTeammate, } from "../core/detectors/minimap/index"; import { SPECTATOR_SLOTS } from "../core/detectors/minimap/rois"; import type { ScoreboardData } from "../core/detectors/scoreboard/index"; import type { DetectedEvent } from "../core/detectors/types"; import { buildVodMatches } from "../core/vod-matches"; +import test from "./node-test-compat"; const ALPHA: MainWeaponId[] = [40, 1001, 2010, 3030]; const BRAVO: MainWeaponId[] = [50, 210, 4010, 8000]; const ALL = [...ALPHA, ...BRAVO]; function teammate(weaponId: MainWeaponId | null, i: number): MinimapTeammate { - return { - slot: SPECTATOR_SLOTS[i]!, - name: null, - weaponId, - abilities: [], - }; + return { + slot: SPECTATOR_SLOTS[i]!, + name: null, + weaponId, + abilities: [], + }; } function enemy(weaponId: MainWeaponId | null): MinimapEnemy { - return { - name: null, - weaponId, - abilities: [], - }; + return { + name: null, + weaponId, + abilities: [], + }; } function minimap( - t: number, - { - stage = 0 as StageId | null, - alpha = ALPHA as (MainWeaponId | null)[], - bravo = BRAVO as (MainWeaponId | null)[], - } = {}, + t: number, + { + stage = 0 as StageId | null, + alpha = ALPHA as (MainWeaponId | null)[], + bravo = BRAVO as (MainWeaponId | null)[], + } = {}, ): DetectedEvent { - const data: MinimapData = { - stage, - spectator: true, - teammates: alpha.map(teammate), - enemies: bravo.map(enemy), - }; - return { type: "Minimap", t, confidence: 0.8, data }; + const data: MinimapData = { + stage, + spectator: true, + teammates: alpha.map(teammate), + enemies: bravo.map(enemy), + }; + return { type: "Minimap", t, confidence: 0.8, data }; } function mapStart( - t: number, - { mode = "SZ" as ModeShort | null, stage = 0 as StageId | null } = {}, + t: number, + { mode = "SZ" as ModeShort | null, stage = 0 as StageId | null } = {}, ): DetectedEvent { - return { type: "MapStart", t, confidence: 0.9, data: { mode, stage } }; + return { type: "MapStart", t, confidence: 0.9, data: { mode, stage } }; } function scoreboard( - t: number, - { - mode = "SZ" as ModeShort | null, - stage = 0 as StageId | null, - weapons = ALL as (MainWeaponId | null)[], - } = {}, + t: number, + { + mode = "SZ" as ModeShort | null, + stage = 0 as StageId | null, + weapons = ALL as (MainWeaponId | null)[], + } = {}, ): DetectedEvent { - const data: ScoreboardData = { - lobby: "PRIVATE", - mode, - stage, - scores: [100, 47], - players: weapons.map((weaponId, i) => ({ - name: `p${i}`, - weaponId, - paint: 1000, - ka: 10, - d: 5, - s: 2, - })), - povIndex: null, - }; - return { type: "Scoreboard", t, confidence: 0.9, data }; + const data: ScoreboardData = { + lobby: "PRIVATE", + mode, + stage, + scores: [100, 47], + players: weapons.map((weaponId, i) => ({ + name: `p${i}`, + weaponId, + paint: 1000, + ka: 10, + d: 5, + s: 2, + })), + povIndex: null, + }; + return { type: "Scoreboard", t, confidence: 0.9, data }; } test("a spectator map's minimaps become one match: weapons + stage from the minimap, mode defaulted", () => { - const matches = buildVodMatches([minimap(70), minimap(120)]); - assert.equal(matches.length, 1); - assert.deepEqual(matches[0], { - startsAt: 70, - mode: "SZ", // PoC default — the minimap can't read mode - modeAssumed: true, - stage: 0, - weapons: ALL, - }); + const matches = buildVodMatches([minimap(70), minimap(120)]); + assert.equal(matches.length, 1); + assert.deepEqual(matches[0], { + startsAt: 70, + mode: "SZ", // PoC default — the minimap can't read mode + modeAssumed: true, + stage: 0, + weapons: ALL, + }); }); test("a real mode read is not flagged as assumed", () => { - const matches = buildVodMatches([mapStart(30, { mode: "RM" }), minimap(70)]); - assert.equal(matches[0]!.mode, "RM"); - assert.equal(matches[0]!.modeAssumed, false); + const matches = buildVodMatches([mapStart(30, { mode: "RM" }), minimap(70)]); + assert.equal(matches[0]!.mode, "RM"); + assert.equal(matches[0]!.modeAssumed, false); }); test("a lone misread stage neither splits the match nor poisons its stage", () => { - // the Eeltail frame disagrees with the running stage AND is refuted by - // the next read, so it folds in as a minority vote: one match, majority - // stage, its weapons still contributing to the slot merge - const matches = buildVodMatches([ - minimap(70, { stage: 0 }), - minimap(90, { stage: 1 }), - minimap(110, { stage: 0 }), - minimap(130, { stage: 0 }), - ]); - assert.equal(matches.length, 1); - assert.equal(matches[0]!.stage, 0); + // the Eeltail frame disagrees with the running stage AND is refuted by + // the next read, so it folds in as a minority vote: one match, majority + // stage, its weapons still contributing to the slot merge + const matches = buildVodMatches([ + minimap(70, { stage: 0 }), + minimap(90, { stage: 1 }), + minimap(110, { stage: 0 }), + minimap(130, { stage: 0 }), + ]); + assert.equal(matches.length, 1); + assert.equal(matches[0]!.stage, 0); }); test("a confirmed stage change splits even when the misread-looking frame is mid-stream", () => { - const matches = buildVodMatches([ - minimap(70, { stage: 0 }), - minimap(90, { stage: 1 }), - minimap(110, { stage: 1 }), - ]); - assert.equal(matches.length, 2); - assert.deepEqual( - matches.map((m) => m.stage), - [0, 1], - ); - assert.deepEqual( - matches.map((m) => m.startsAt), - [70, 90], - ); + const matches = buildVodMatches([ + minimap(70, { stage: 0 }), + minimap(90, { stage: 1 }), + minimap(110, { stage: 1 }), + ]); + assert.equal(matches.length, 2); + assert.deepEqual( + matches.map((m) => m.stage), + [0, 1], + ); + assert.deepEqual( + matches.map((m) => m.startsAt), + [70, 90], + ); }); // KNOWN LIMITATION (documented, not desired): two consecutive games on the @@ -137,78 +141,82 @@ test("a confirmed stage change splits even when the misread-looking frame is mid // simplified minimap carries no signal to split on. Real mode/game detection // should replace this. test("same-stage rematch within the gap window merges into one match (known limitation)", () => { - const game1 = [minimap(70), minimap(150)]; - const game2 = [minimap(380), minimap(460)]; // 230s after game 1's last open - const matches = buildVodMatches([...game1, ...game2]); - assert.equal(matches.length, 1); + const game1 = [minimap(70), minimap(150)]; + const game2 = [minimap(380), minimap(460)]; // 230s after game 1's last open + const matches = buildVodMatches([...game1, ...game2]); + assert.equal(matches.length, 1); }); test("a stage change splits minimaps into separate per-map matches", () => { - const matches = buildVodMatches([ - minimap(70, { stage: 0 }), - minimap(120, { stage: 0 }), - minimap(400, { stage: 1 }), - ]); - assert.equal(matches.length, 2); - assert.deepEqual( - matches.map((m) => m.stage), - [0, 1], - ); - assert.deepEqual( - matches.map((m) => m.startsAt), - [70, 400], - ); + const matches = buildVodMatches([ + minimap(70, { stage: 0 }), + minimap(120, { stage: 0 }), + minimap(400, { stage: 1 }), + ]); + assert.equal(matches.length, 2); + assert.deepEqual( + matches.map((m) => m.stage), + [0, 1], + ); + assert.deepEqual( + matches.map((m) => m.startsAt), + [70, 400], + ); }); test("a large time gap splits even same-stage minimaps (different games)", () => { - const matches = buildVodMatches([minimap(70), minimap(90), minimap(600)]); - assert.equal(matches.length, 2); - assert.deepEqual( - matches.map((m) => m.startsAt), - [70, 600], - ); + const matches = buildVodMatches([minimap(70), minimap(90), minimap(600)]); + assert.equal(matches.length, 2); + assert.deepEqual( + matches.map((m) => m.startsAt), + [70, 600], + ); }); test("minimaps of one game (close in time, same stage) stay one match", () => { - const matches = buildVodMatches([minimap(70), minimap(90), minimap(250)]); - assert.equal(matches.length, 1); - assert.equal(matches[0]!.startsAt, 70); + const matches = buildVodMatches([minimap(70), minimap(90), minimap(250)]); + assert.equal(matches.length, 1); + assert.equal(matches[0]!.startsAt, 70); }); test("weapon slots are merged across a match's minimap frames", () => { - const frame1 = minimap(70, { alpha: [null, 1001, null, 3030] }); - const frame2 = minimap(90, { alpha: [40, null, 2010, 3030] }); - const matches = buildVodMatches([frame1, frame2]); - assert.deepEqual(matches[0]!.weapons, ALL); + const frame1 = minimap(70, { alpha: [null, 1001, null, 3030] }); + const frame2 = minimap(90, { alpha: [40, null, 2010, 3030] }); + const matches = buildVodMatches([frame1, frame2]); + assert.deepEqual(matches[0]!.weapons, ALL); }); test("a slot no frame read stays null for the endpoint to skip on", () => { - const matches = buildVodMatches([minimap(70, { alpha: [40, 1001, 2010, null] })]); - assert.deepEqual(matches[0]!.weapons, [40, 1001, 2010, null, ...BRAVO]); + const matches = buildVodMatches([ + minimap(70, { alpha: [40, 1001, 2010, null] }), + ]); + assert.deepEqual(matches[0]!.weapons, [40, 1001, 2010, null, ...BRAVO]); }); test("a MapStart supplies the real mode and opens a match", () => { - const matches = buildVodMatches([ - mapStart(30, { mode: "RM", stage: 6 }), - minimap(70, { stage: 6 }), - ]); - assert.equal(matches.length, 1); - assert.equal(matches[0]!.mode, "RM"); - assert.equal(matches[0]!.startsAt, 30); + const matches = buildVodMatches([ + mapStart(30, { mode: "RM", stage: 6 }), + minimap(70, { stage: 6 }), + ]); + assert.equal(matches.length, 1); + assert.equal(matches[0]!.mode, "RM"); + assert.equal(matches[0]!.startsAt, 30); }); test("a scoreboard is the preferred weapon/mode source and closes a match", () => { - const boardWeapons: (MainWeaponId | null)[] = [10, 10, 10, 10, 20, 20, 20, 20]; - const matches = buildVodMatches([ - minimap(70), - scoreboard(330, { mode: "TC", weapons: boardWeapons }), - ]); - assert.equal(matches.length, 1); - assert.equal(matches[0]!.mode, "TC"); - assert.deepEqual(matches[0]!.weapons, boardWeapons); - assert.equal(matches[0]!.startsAt, 70); // first minimap open + const boardWeapons: (MainWeaponId | null)[] = [ + 10, 10, 10, 10, 20, 20, 20, 20, + ]; + const matches = buildVodMatches([ + minimap(70), + scoreboard(330, { mode: "TC", weapons: boardWeapons }), + ]); + assert.equal(matches.length, 1); + assert.equal(matches[0]!.mode, "TC"); + assert.deepEqual(matches[0]!.weapons, boardWeapons); + assert.equal(matches[0]!.startsAt, 70); // first minimap open }); test("no minimaps and no scoreboard means no match", () => { - assert.deepEqual(buildVodMatches([mapStart(30), mapStart(400)]), []); + assert.deepEqual(buildVodMatches([mapStart(30), mapStart(400)]), []); }); diff --git a/app/features/cv/worker/analyzer.worker.ts b/app/features/cv/worker/analyzer.worker.ts index 015cbab0c..6cebfa8b7 100644 --- a/app/features/cv/worker/analyzer.worker.ts +++ b/app/features/cv/worker/analyzer.worker.ts @@ -14,67 +14,81 @@ let detectors: Detector[] = []; let suppressor: ParseSuppressor | null = null; function post(message: WorkerResponse): void { - self.postMessage(message); + self.postMessage(message); } -async function init({ assetsBaseUrl, suppressSteadyFrames = true }: InitRequest): Promise { - try { - await loadOpenCV(); - const resources = await fetchScoreboardResources(assetsBaseUrl); - detectors = createAllDetectors(resources); - suppressor = suppressSteadyFrames ? new ParseSuppressor() : null; - post({ kind: "ready" }); - } catch (error) { - post({ kind: "error", message: `init failed: ${String(error)}` }); - } +async function init({ + assetsBaseUrl, + suppressSteadyFrames = true, +}: InitRequest): Promise { + try { + await loadOpenCV(); + const resources = await fetchScoreboardResources(assetsBaseUrl); + detectors = createAllDetectors(resources); + suppressor = suppressSteadyFrames ? new ParseSuppressor() : null; + post({ kind: "ready" }); + } catch (error) { + post({ kind: "error", message: `init failed: ${String(error)}` }); + } } async function analyze({ bitmap, t }: AnalyzeRequest): Promise { - const width = "displayWidth" in bitmap ? bitmap.displayWidth : bitmap.width; - const height = "displayHeight" in bitmap ? bitmap.displayHeight : bitmap.height; - const canvas = new OffscreenCanvas(width, height); - const ctx = canvas.getContext("2d")!; - ctx.drawImage(bitmap, 0, 0); - bitmap.close(); - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const width = "displayWidth" in bitmap ? bitmap.displayWidth : bitmap.width; + const height = + "displayHeight" in bitmap ? bitmap.displayHeight : bitmap.height; + const canvas = new OffscreenCanvas(width, height); + const ctx = canvas.getContext("2d")!; + ctx.drawImage(bitmap, 0, 0); + bitmap.close(); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - const src = toMat({ - width: imageData.width, - height: imageData.height, - data: imageData.data, - }); - let frame: ReturnType; - try { - frame = normalizeFrame(src); - } finally { - src.delete(); - } + const src = toMat({ + width: imageData.width, + height: imageData.height, + data: imageData.data, + }); + let frame: ReturnType; + try { + frame = normalizeFrame(src); + } finally { + src.delete(); + } - // On detection, ship back the exact analyzed pixels (lossless, at capture - // resolution) so the UI never has to re-grab a later frame — encoded at - // most once per frame, however many detectors fire on it. - let encoded: Promise | null = null; - const frameBlob = () => (encoded ??= canvas.convertToBlob({ type: "image/png" })); + // On detection, ship back the exact analyzed pixels (lossless, at capture + // resolution) so the UI never has to re-grab a later frame — encoded at + // most once per frame, however many detectors fire on it. + let encoded: Promise | null = null; + const frameBlob = () => + (encoded ??= canvas.convertToBlob({ type: "image/png" })); - try { - for (const detector of detectors) { - const gate = detector.gate(frame); - const runParse = suppressor ? suppressor.shouldParse(detector.id, gate.pass) : gate.pass; - const events = runParse ? detector.parse(frame, t, gate) : []; - if (runParse) suppressor?.recordParse(detector.id, events); - const blob = events.length > 0 ? await frameBlob() : undefined; - post({ kind: "result", detector: detector.id, t, gate, events, frame: blob }); - } - } catch (error) { - post({ kind: "error", message: `analyze failed: ${String(error)}` }); - } finally { - frame.delete(); - post({ kind: "done", t }); - } + try { + for (const detector of detectors) { + const gate = detector.gate(frame); + const runParse = suppressor + ? suppressor.shouldParse(detector.id, gate.pass) + : gate.pass; + const events = runParse ? detector.parse(frame, t, gate) : []; + if (runParse) suppressor?.recordParse(detector.id, events); + const blob = events.length > 0 ? await frameBlob() : undefined; + post({ + kind: "result", + detector: detector.id, + t, + gate, + events, + frame: blob, + }); + } + } catch (error) { + post({ kind: "error", message: `analyze failed: ${String(error)}` }); + } finally { + frame.delete(); + post({ kind: "done", t }); + } } self.onmessage = (e: MessageEvent) => { - const msg = e.data as { kind: string } & Record; - if (msg.kind === "init") void init(msg as unknown as InitRequest); - else if (msg.kind === "frame") void analyze(msg as unknown as AnalyzeRequest); + const msg = e.data as { kind: string } & Record; + if (msg.kind === "init") void init(msg as unknown as InitRequest); + else if (msg.kind === "frame") void analyze(msg as unknown as AnalyzeRequest); }; diff --git a/app/features/cv/worker/client.ts b/app/features/cv/worker/client.ts index a0de6cbaf..42d4fde4a 100644 --- a/app/features/cv/worker/client.ts +++ b/app/features/cv/worker/client.ts @@ -6,88 +6,94 @@ import { CV_ASSETS_URL } from "../../../utils/urls"; import type { WorkerResponse } from "./protocol"; -export type ResultHandler = (result: Extract) => void; +export type ResultHandler = ( + result: Extract, +) => void; export type ErrorHandler = (message: string) => void; export type DoneHandler = (t: number) => void; export class AnalyzerClient { - #worker: Worker; - #ready = false; - #busy = false; - #onResult: ResultHandler; - #onError: ErrorHandler; - #onDone: DoneHandler | undefined; - #readyPromise: Promise; + #worker: Worker; + #ready = false; + #busy = false; + #onResult: ResultHandler; + #onError: ErrorHandler; + #onDone: DoneHandler | undefined; + #readyPromise: Promise; - constructor( - onResult: ResultHandler, - onError: ErrorHandler = console.error, - onDone?: DoneHandler, - options: { suppressSteadyFrames?: boolean } = {}, - ) { - this.#onResult = onResult; - this.#onError = onError; - this.#onDone = onDone; - this.#worker = new Worker(new URL("./analyzer.worker.ts", import.meta.url), { - type: "module", - }); - let resolveReady!: () => void; - this.#readyPromise = new Promise((resolve) => { - resolveReady = resolve; - }); - this.#worker.onmessage = (e: MessageEvent) => { - const msg = e.data; - if (msg.kind === "ready") { - this.#ready = true; - resolveReady(); - } else if (msg.kind === "result") { - this.#onResult(msg); - } else if (msg.kind === "done") { - this.#busy = false; - this.#onDone?.(msg.t); - } else if (msg.kind === "error") { - this.#busy = false; - this.#onError(msg.message); - } - }; - // A throw outside the worker's own try/catch posts neither "error" nor - // "done"; without these handlers `busy` would stay true forever and the - // sampler / VoD scan would silently freeze. - this.#worker.onerror = (e: ErrorEvent) => { - this.#busy = false; - this.#onError(`worker error: ${e.message || String(e)}`); - }; - this.#worker.onmessageerror = () => { - this.#busy = false; - this.#onError("worker message deserialization failed"); - }; - this.#worker.postMessage({ - kind: "init", - assetsBaseUrl: CV_ASSETS_URL, - suppressSteadyFrames: options.suppressSteadyFrames ?? true, - }); - } + constructor( + onResult: ResultHandler, + // biome-ignore lint/suspicious/noConsole: default sink for worker errors when no handler is passed + onError: ErrorHandler = console.error, + onDone?: DoneHandler, + options: { suppressSteadyFrames?: boolean } = {}, + ) { + this.#onResult = onResult; + this.#onError = onError; + this.#onDone = onDone; + this.#worker = new Worker( + new URL("./analyzer.worker.ts", import.meta.url), + { + type: "module", + }, + ); + let resolveReady!: () => void; + this.#readyPromise = new Promise((resolve) => { + resolveReady = resolve; + }); + this.#worker.onmessage = (e: MessageEvent) => { + const msg = e.data; + if (msg.kind === "ready") { + this.#ready = true; + resolveReady(); + } else if (msg.kind === "result") { + this.#onResult(msg); + } else if (msg.kind === "done") { + this.#busy = false; + this.#onDone?.(msg.t); + } else if (msg.kind === "error") { + this.#busy = false; + this.#onError(msg.message); + } + }; + // A throw outside the worker's own try/catch posts neither "error" nor + // "done"; without these handlers `busy` would stay true forever and the + // sampler / VoD scan would silently freeze. + this.#worker.onerror = (e: ErrorEvent) => { + this.#busy = false; + this.#onError(`worker error: ${e.message || String(e)}`); + }; + this.#worker.onmessageerror = () => { + this.#busy = false; + this.#onError("worker message deserialization failed"); + }; + this.#worker.postMessage({ + kind: "init", + assetsBaseUrl: CV_ASSETS_URL, + suppressSteadyFrames: options.suppressSteadyFrames ?? true, + }); + } - whenReady(): Promise { - return this.#readyPromise; - } + whenReady(): Promise { + return this.#readyPromise; + } - get busy(): boolean { - return this.#busy || !this.#ready; - } + get busy(): boolean { + return this.#busy || !this.#ready; + } - /** Returns false (and closes the bitmap) if the worker is still busy. */ - analyze(bitmap: ImageBitmap | VideoFrame, t: number): boolean { - if (this.busy) { - bitmap.close(); - return false; - } - this.#busy = true; - this.#worker.postMessage({ kind: "frame", bitmap, t }, [bitmap]); - return true; - } + /** Returns false (and closes the bitmap) if the worker is still busy. */ + analyze(bitmap: ImageBitmap | VideoFrame, t: number): boolean { + if (this.busy) { + bitmap.close(); + return false; + } + this.#busy = true; + this.#worker.postMessage({ kind: "frame", bitmap, t }, [bitmap]); + return true; + } - dispose(): void { - this.#worker.terminate(); - } + dispose(): void { + this.#worker.terminate(); + } } diff --git a/app/features/cv/worker/pool.ts b/app/features/cv/worker/pool.ts index 374fad037..b479a25fb 100644 --- a/app/features/cv/worker/pool.ts +++ b/app/features/cv/worker/pool.ts @@ -7,70 +7,74 @@ * consumer must tolerate (TimelineBuilder does: it merges on |Δt| and * keeps its list sorted, independent of arrival order). */ -import { AnalyzerClient, type ErrorHandler, type ResultHandler } from "./client"; +import { + AnalyzerClient, + type ErrorHandler, + type ResultHandler, +} from "./client"; /** leave cores for the main thread and the video decoder */ export function defaultPoolSize(): number { - return Math.min(4, Math.max(1, (navigator.hardwareConcurrency || 4) - 2)); + return Math.min(4, Math.max(1, (navigator.hardwareConcurrency || 4) - 2)); } export class AnalyzerPool { - #clients: AnalyzerClient[]; - #idleWaiters: (() => void)[] = []; + #clients: AnalyzerClient[]; + #idleWaiters: (() => void)[] = []; - constructor(size: number, onResult: ResultHandler, onError: ErrorHandler) { - const wake = () => { - const waiters = this.#idleWaiters; - this.#idleWaiters = []; - for (const waiter of waiters) waiter(); - }; - this.#clients = Array.from( - { length: size }, - () => - new AnalyzerClient( - onResult, - (message) => { - wake(); // an errored frame is also a finished frame — don't hang whenIdle - onError(message); - }, - wake, - ), - ); - } + constructor(size: number, onResult: ResultHandler, onError: ErrorHandler) { + const wake = () => { + const waiters = this.#idleWaiters; + this.#idleWaiters = []; + for (const waiter of waiters) waiter(); + }; + this.#clients = Array.from( + { length: size }, + () => + new AnalyzerClient( + onResult, + (message) => { + wake(); // an errored frame is also a finished frame — don't hang whenIdle + onError(message); + }, + wake, + ), + ); + } - whenReady(): Promise { - return Promise.all(this.#clients.map((c) => c.whenReady())).then(() => {}); - } + whenReady(): Promise { + return Promise.all(this.#clients.map((c) => c.whenReady())).then(() => {}); + } - hasIdle(): boolean { - return this.#clients.some((c) => !c.busy); - } + hasIdle(): boolean { + return this.#clients.some((c) => !c.busy); + } - /** Resolves once at least one worker is free. Call whenReady() first. */ - async whenAnyIdle(): Promise { - while (!this.hasIdle()) { - await new Promise((resolve) => this.#idleWaiters.push(resolve)); - } - } + /** Resolves once at least one worker is free. Call whenReady() first. */ + async whenAnyIdle(): Promise { + while (!this.hasIdle()) { + await new Promise((resolve) => this.#idleWaiters.push(resolve)); + } + } - /** Hand the frame to an idle worker; false (and the frame closed) when all are busy. */ - tryAnalyze(frame: ImageBitmap | VideoFrame, t: number): boolean { - const idle = this.#clients.find((c) => !c.busy); - if (!idle) { - frame.close(); - return false; - } - return idle.analyze(frame, t); - } + /** Hand the frame to an idle worker; false (and the frame closed) when all are busy. */ + tryAnalyze(frame: ImageBitmap | VideoFrame, t: number): boolean { + const idle = this.#clients.find((c) => !c.busy); + if (!idle) { + frame.close(); + return false; + } + return idle.analyze(frame, t); + } - /** Resolves once no worker has a frame in flight. Call whenReady() first. */ - async whenIdle(): Promise { - while (this.#clients.some((c) => c.busy)) { - await new Promise((resolve) => this.#idleWaiters.push(resolve)); - } - } + /** Resolves once no worker has a frame in flight. Call whenReady() first. */ + async whenIdle(): Promise { + while (this.#clients.some((c) => c.busy)) { + await new Promise((resolve) => this.#idleWaiters.push(resolve)); + } + } - dispose(): void { - for (const client of this.#clients) client.dispose(); - } + dispose(): void { + for (const client of this.#clients) client.dispose(); + } } diff --git a/app/features/cv/worker/protocol.ts b/app/features/cv/worker/protocol.ts index bf12058aa..372a0d324 100644 --- a/app/features/cv/worker/protocol.ts +++ b/app/features/cv/worker/protocol.ts @@ -1,41 +1,41 @@ import type { DetectedEvent, GateResult } from "../core/detectors/types"; export interface InitRequest { - kind: "init"; - /** - * base URL the worker fetches atlases/templates from (e.g. - * `${Config.staticAssetsUrl}/cv/v1`); passed in the init message so the - * worker bundle stays free of the app config graph - */ - assetsBaseUrl: string; - /** - * skip parse() for a detector whose gate keeps firing without confidence - * improving (static screen); default true — one-shot consumers like the - * screenshot harness turn it off - */ - suppressSteadyFrames?: boolean; + kind: "init"; + /** + * base URL the worker fetches atlases/templates from (e.g. + * `${Config.staticAssetsUrl}/cv/v1`); passed in the init message so the + * worker bundle stays free of the app config graph + */ + assetsBaseUrl: string; + /** + * skip parse() for a detector whose gate keeps firing without confidence + * improving (static screen); default true — one-shot consumers like the + * screenshot harness turn it off + */ + suppressSteadyFrames?: boolean; } export interface AnalyzeRequest { - kind: "frame"; - /** VideoFrame is what VoD decode produces; transferring it directly skips - * a main-thread ImageBitmap conversion */ - bitmap: ImageBitmap | VideoFrame; - /** seconds into the stream */ - t: number; + kind: "frame"; + /** VideoFrame is what VoD decode produces; transferring it directly skips + * a main-thread ImageBitmap conversion */ + bitmap: ImageBitmap | VideoFrame; + /** seconds into the stream */ + t: number; } export type WorkerResponse = - | { kind: "ready" } - | { - kind: "result"; - detector: string; - t: number; - gate: GateResult; - events: DetectedEvent[]; - /** lossless PNG of the exact frame that was analyzed; present when events fired */ - frame?: Blob; - } - /** all detectors have reported for frame t */ - | { kind: "done"; t: number } - | { kind: "error"; message: string }; + | { kind: "ready" } + | { + kind: "result"; + detector: string; + t: number; + gate: GateResult; + events: DetectedEvent[]; + /** lossless PNG of the exact frame that was analyzed; present when events fired */ + frame?: Blob; + } + /** all detectors have reported for frame t */ + | { kind: "done"; t: number } + | { kind: "error"; message: string }; diff --git a/app/features/cv/worker/resources.ts b/app/features/cv/worker/resources.ts index 4df960d9c..9634c2055 100644 --- a/app/features/cv/worker/resources.ts +++ b/app/features/cv/worker/resources.ts @@ -8,9 +8,9 @@ */ import { - loadPlannerStages, - type PlannerManifest, - type PlannerStage, + loadPlannerStages, + type PlannerManifest, + type PlannerStage, } from "../core/detectors/minimap/stage"; import type { ScoreboardResources } from "../core/detectors/scoreboard/index"; import { type AtlasMeta, type GlyphSet, loadGlyphSet } from "../core/glyphs"; @@ -18,60 +18,68 @@ import type { FrameData } from "../core/image"; import { assembleScoreboardResources } from "../core/resources"; async function fetchImage(url: string): Promise { - const res = await fetch(url); - if (!res.ok) throw new Error(`fetch ${url}: ${res.status}`); - const bitmap = await createImageBitmap(await res.blob()); - const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); - const ctx = canvas.getContext("2d")!; - ctx.drawImage(bitmap, 0, 0); - bitmap.close(); - const data = ctx.getImageData(0, 0, canvas.width, canvas.height); - return { width: data.width, height: data.height, data: data.data }; + const res = await fetch(url); + if (!res.ok) throw new Error(`fetch ${url}: ${res.status}`); + const bitmap = await createImageBitmap(await res.blob()); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const ctx = canvas.getContext("2d")!; + ctx.drawImage(bitmap, 0, 0); + bitmap.close(); + const data = ctx.getImageData(0, 0, canvas.width, canvas.height); + return { width: data.width, height: data.height, data: data.data }; } function makeFetchAtlas(base: string) { - return async function fetchAtlas(name: string): Promise<() => GlyphSet | null> { - try { - const [meta, image] = await Promise.all([ - fetch(`${base}/glyphs/${name}.json`).then((r) => { - if (!r.ok) throw new Error(String(r.status)); - return r.json() as Promise; - }), - fetchImage(`${base}/glyphs/${name}.png`), - ]); - const set = loadGlyphSet(image, meta); - return () => set; - } catch { - return () => null; - } - }; + return async function fetchAtlas( + name: string, + ): Promise<() => GlyphSet | null> { + try { + const [meta, image] = await Promise.all([ + fetch(`${base}/glyphs/${name}.json`).then((r) => { + if (!r.ok) throw new Error(String(r.status)); + return r.json() as Promise; + }), + fetchImage(`${base}/glyphs/${name}.png`), + ]); + const set = loadGlyphSet(image, meta); + return () => set; + } catch { + return () => null; + } + }; } function makeFetchPlannerStages(base: string) { - return async function fetchPlannerStages(): Promise<() => PlannerStage[] | null> { - try { - const [manifest, atlas] = await Promise.all([ - fetch(`${base}/planner/manifest.json`).then((r) => { - if (!r.ok) throw new Error(String(r.status)); - return r.json() as Promise; - }), - fetchImage(`${base}/planner/signatures.png`), - ]); - const stages = loadPlannerStages(atlas, manifest); - return () => stages; - } catch { - return () => null; - } - }; + return async function fetchPlannerStages(): Promise< + () => PlannerStage[] | null + > { + try { + const [manifest, atlas] = await Promise.all([ + fetch(`${base}/planner/manifest.json`).then((r) => { + if (!r.ok) throw new Error(String(r.status)); + return r.json() as Promise; + }), + fetchImage(`${base}/planner/signatures.png`), + ]); + const stages = loadPlannerStages(atlas, manifest); + return () => stages; + } catch { + return () => null; + } + }; } /** Requires loadOpenCV() to have resolved. */ -export function fetchScoreboardResources(base: string): Promise { - return assembleScoreboardResources({ - readManifest: (dir) => - fetch(`${base}/${dir}/manifest.json`).then((r) => r.json() as Promise), - readIcon: (dir, id) => fetchImage(`${base}/${dir}/${id}.png`), - loadAtlas: makeFetchAtlas(base), - loadPlannerStages: makeFetchPlannerStages(base), - }); +export function fetchScoreboardResources( + base: string, +): Promise { + return assembleScoreboardResources({ + readManifest: (dir) => + fetch(`${base}/${dir}/manifest.json`).then( + (r) => r.json() as Promise, + ), + readIcon: (dir, id) => fetchImage(`${base}/${dir}/${id}.png`), + loadAtlas: makeFetchAtlas(base), + loadPlannerStages: makeFetchPlannerStages(base), + }); } diff --git a/biome.json b/biome.json index 145afaaba..87c9cee47 100644 --- a/biome.json +++ b/biome.json @@ -7,6 +7,7 @@ "!scripts/output/**/*", "!app/db/seed/placements.json", "!build/**/*", + "!app/features/cv/tests/fixtures/**", "!test-results/**/*", "!playwright-report/**/*", "!.react-router/**/*", diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 696d37146ad4578c66b73f768dd6c38316bb3233..1ad27e0b3e75025a79403ab05fc43d4f4502338d 100644 GIT binary patch delta 843 zcmaixO=}ZD7=ZU!si3{&=1Dwx6ZKfHf`7w{_#>PVDM4s6FfRl1@VsBc;l}XW#+y04C`t0JbbgdO zZ=Jead<_UPf`XtT5QGdu79oc)g^)*>M!14dKqw+;2o&Kef{rkQP(m;e%AcX~-c@yS zJj!{M>xBFy$Ded~N(P%pjcj#cL3x=5?M{>&bmBB>C25fQ<8OKQ-lOKqdedBASzT+I z<6nzuyW_;+3#S_$INeU_bVDAJU~j(@S!+lBU;F0&cJ@zkTcl!ge371=>jh|i^&6ER z4^Hor0-+}o{YB5{M|#3Lc}1755mL3Qt3kLOwMA$Jhv`nQ--<(@nLg81aziTZ#oPTL zjeFgMHI^)exNd3m0gan=W->!2wnU#KQU7ri^tX0eVUPwjz7Q#|MH2l^-_eBLr?c8` z?Myq>BA)i4Hn8)#s$DLZjSDg}&#W(tt4i{sJvABjIqY-zmYtgj*OmvKSrh7cJ3E=? z>Y7l`+L?(k<3%%JHf(YUJmACSEi$fVLQO4I(52EP^WbJ7p0<>UDAx*WrXlBCmr^=E aww|qC_gOspg2qgNd7d)&JnAQWvwr~MG3ws{ delta 126 zcmX}Zy%7OH0EXe+`*DZm{@6soh|MtL1X@tSMQ^B#6?{3-HJ}4yqzcfHMCN(Iy9=Sa zE-Gh?J)6Jic-!o)yCAHHL@W}Kih;;PE{0+x#=?n-n2JJ_q7t=e#7xYe`1>|4(zefb Se%Y@}+dNi$lUIB2pVJQsHX@b) diff --git a/knip.ts b/knip.ts index 7597ba063..c02b47b61 100644 --- a/knip.ts +++ b/knip.ts @@ -5,6 +5,8 @@ const config = { }, tags: ["-lintignore"], ignoreBinaries: ["lsof"], + // vendored 2022 dictionary tooling, not part of the module graph + ignore: ["scripts/dicts/**"], // cwd relative path inside an execSync command, which knip resolves relative to the file instead ignoreUnresolved: ["scripts/seed-single-variation.ts"], entry: [ diff --git a/scripts/cv/bootstrap-atlas-from-fixture.ts b/scripts/cv/bootstrap-atlas-from-fixture.ts index 75bd82106..1a5f41b56 100644 --- a/scripts/cv/bootstrap-atlas-from-fixture.ts +++ b/scripts/cv/bootstrap-atlas-from-fixture.ts @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/suspicious/noConsole: CLI script output */ /** * Bootstrap glyph atlases from the labeled reference fixtures. * @@ -15,22 +16,29 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { loadOpenCV, type Mat } from "../../app/features/cv/core/cv"; import { - TAG_NAME_INNER, - TAG_NAME_OUTER, - TAG_NAME_TEXT_HEIGHT, - TAG_TILT_DEG, + TAG_NAME_INNER, + TAG_NAME_OUTER, + TAG_NAME_TEXT_HEIGHT, + TAG_TILT_DEG, } from "../../app/features/cv/core/detectors/death/rois"; import { - nameRoi, - paintRoi, - ROW_CENTERS, - statRoi, - TEAM_SCORE_ROIS, + nameRoi, + paintRoi, + ROW_CENTERS, + statRoi, + TEAM_SCORE_ROIS, } from "../../app/features/cv/core/detectors/scoreboard/rois"; -import { CODE_TEXT_HEIGHT, REPLAY_CODE_ROI } from "../../app/features/cv/core/detectors/scoreboard-replay/rois"; +import { + CODE_TEXT_HEIGHT, + REPLAY_CODE_ROI, +} from "../../app/features/cv/core/detectors/scoreboard-replay/rois"; import type { AtlasMeta } from "../../app/features/cv/core/glyphs"; +import { + normalizeFrame, + type Roi, + toMat, +} from "../../app/features/cv/core/image"; import { CV_ASSETS_DIR } from "../../app/features/cv/node/assets-dir"; -import { normalizeFrame, type Roi, toMat } from "../../app/features/cv/core/image"; import { FIXTURES_DIR } from "../../app/features/cv/node/fixtures"; import { readImage, writePng } from "../../app/features/cv/node/image-io"; @@ -39,144 +47,162 @@ const BIN_THRESHOLD = 150; /** A labeled header tag region (positions are fixture-specific: tags size to their text). */ interface HeaderSpec { - roi: Roi; - label: string; - splitHints?: number[]; - threshold?: number; + roi: Roi; + label: string; + splitHints?: number[]; + threshold?: number; } interface Source { - frame: string; - /** row labels, top to bottom; "" skips a row (e.g. unverifiable glyphs) */ - names: string[]; - paints: string[]; - /** per row: [ka, deaths, specials], zero-padded 2 digits as rendered */ - stats: string[][]; - teamScores: string[]; - /** - * Split hints are absolute x positions where merged segments must be cut - * (keyed by row index) — e.g. the "Te" in Teddy renders as one segment, - * with the exact boundary shifting a couple of pixels between sources. - */ - nameSplitHints: Record; - /** omit to skip header harvest for this source */ - header?: { lobby: HeaderSpec; mode: HeaderSpec; stage: HeaderSpec }; + frame: string; + /** row labels, top to bottom; "" skips a row (e.g. unverifiable glyphs) */ + names: string[]; + paints: string[]; + /** per row: [ka, deaths, specials], zero-padded 2 digits as rendered */ + stats: string[][]; + teamScores: string[]; + /** + * Split hints are absolute x positions where merged segments must be cut + * (keyed by row index) — e.g. the "Te" in Teddy renders as one segment, + * with the exact boundary shifting a couple of pixels between sources. + */ + nameSplitHints: Record; + /** omit to skip header harvest for this source */ + header?: { lobby: HeaderSpec; mode: HeaderSpec; stage: HeaderSpec }; } /** Ground-truth labels for the reference match (captured through two pipelines). */ const REFERENCE = { - names: [ - "Pinhole", - "Sunshield", - "Headphones", - "Now or Never Seven", - "Charms", - "Teddy", - "Circle", - "Fleece", - ], - paints: ["842", "1217", "1768", "693", "1422", "980", "1204", "1053"], - stats: [ - ["12", "07", "02"], - ["07", "06", "03"], - ["04", "04", "05"], - ["04", "04", "02"], - ["09", "06", "05"], - ["07", "07", "05"], - ["08", "06", "03"], - ["05", "02", "04"], - ], - /** - * Team totals render with an outline on the colored team box, so they get - * their own atlas rather than reusing paint digits. This fixture only - * contributes '5' and '0' — later fixtures extend the set (harvest keeps - * the first instance per char, so rerunning with more fixtures is additive). - */ - teamScores: ["500", "0"], - header: { - lobby: { roi: { x: 838, y: 44, w: 82, h: 26 }, label: "X Battle" }, - // mode is bold, stage is regular — harvested separately so shared letters - // (e.g. both have an 'S') keep one template per face; bold text bridges - // at threshold 150, 170 separates all but "pl" - mode: { - roi: { x: 832, y: 88, w: 216, h: 40 }, - label: "Splat Zones", - splitHints: [877], - threshold: 170, - }, - stage: { roi: { x: 1078, y: 88, w: 160, h: 40 }, label: "Scorch Gorge" }, - }, + names: [ + "Pinhole", + "Sunshield", + "Headphones", + "Now or Never Seven", + "Charms", + "Teddy", + "Circle", + "Fleece", + ], + paints: ["842", "1217", "1768", "693", "1422", "980", "1204", "1053"], + stats: [ + ["12", "07", "02"], + ["07", "06", "03"], + ["04", "04", "05"], + ["04", "04", "02"], + ["09", "06", "05"], + ["07", "07", "05"], + ["08", "06", "03"], + ["05", "02", "04"], + ], + /** + * Team totals render with an outline on the colored team box, so they get + * their own atlas rather than reusing paint digits. This fixture only + * contributes '5' and '0' — later fixtures extend the set (harvest keeps + * the first instance per char, so rerunning with more fixtures is additive). + */ + teamScores: ["500", "0"], + header: { + lobby: { roi: { x: 838, y: 44, w: 82, h: 26 }, label: "X Battle" }, + // mode is bold, stage is regular — harvested separately so shared letters + // (e.g. both have an 'S') keep one template per face; bold text bridges + // at threshold 150, 170 separates all but "pl" + mode: { + roi: { x: 832, y: 88, w: 216, h: 40 }, + label: "Splat Zones", + splitHints: [877], + threshold: 170, + }, + stage: { roi: { x: 1078, y: 88, w: 160, h: 40 }, label: "Scorch Gorge" }, + }, }; /** Labeled source frames; every source contributes its own crop per character. */ const SOURCES: Source[] = [ - { - frame: "scoreboard/xbattle-splat-zones-ko/frame.jpg", - ...REFERENCE, - nameSplitHints: { 5: [1137] }, - }, - { - frame: "scoreboard/xbattle-splat-zones-ko-capture/frame.png", - ...REFERENCE, - nameSplitHints: { 5: [1135] }, - }, - { - frame: "scoreboard/private-battle-splat-zones-ko-kera/frame.png", - names: ["Mongering", "y0s", "fuzzy", "kera", "sigma", "Reefslider", "Cucumber", "tomato"], - paints: ["503", "766", "624", "323", "500", "503", "427", "414"], - stats: [ - ["11", "01", "02"], - ["08", "00", "03"], - ["05", "02", "02"], - ["05", "03", "00"], - ["04", "03", "02"], - ["03", "06", "01"], - ["02", "05", "02"], - ["01", "06", "01"], - ], - teamScores: ["500", "0"], - nameSplitHints: {}, - }, - { - // This capture renders names slightly smaller than the reference: its - // 'T' (9x15) loses to the taller reference crops, and its baseline dots - // are pure homoglyphs the font templates can't split — '.' renders 4px, - // '・' 5px, so exact crops separate them via the ink-coverage penalty. - // Only the dot/T rows are labeled; the rest add nothing new. - frame: "scoreboard/robot/frame.png", - names: ["R.O.B.O.T", "", "", "", "", "Rαι×ι..・", "", ""], - paints: ["", "", "", "", "", "", "", ""], - stats: [ - ["", "", ""], - ["", "", ""], - ["", "", ""], - ["", "", ""], - ["", "", ""], - ["", "", ""], - ["", "", ""], - ["", "", ""], - ], - teamScores: ["", ""], - nameSplitHints: {}, - }, - { - frame: "scoreboard/splash-sploosh/frame.png", - // rows 2 and 7 hold kana/symbol glyphs not yet verified char-by-char - names: ["Bocchi", "have faith", "", "Florescent", "Elis", "Jrod_14", "GOLD SHIP", ""], - paints: ["1422", "1161", "993", "1046", "1662", "1105", "799", "1155"], - stats: [ - ["15", "10", "06"], - ["13", "06", "04"], - ["06", "02", "05"], - ["13", "06", "05"], - ["15", "02", "06"], - ["10", "10", "03"], - ["02", "02", "04"], - ["08", "12", "06"], - ], - teamScores: ["500", "0"], - nameSplitHints: {}, - }, + { + frame: "scoreboard/xbattle-splat-zones-ko/frame.jpg", + ...REFERENCE, + nameSplitHints: { 5: [1137] }, + }, + { + frame: "scoreboard/xbattle-splat-zones-ko-capture/frame.png", + ...REFERENCE, + nameSplitHints: { 5: [1135] }, + }, + { + frame: "scoreboard/private-battle-splat-zones-ko-kera/frame.png", + names: [ + "Mongering", + "y0s", + "fuzzy", + "kera", + "sigma", + "Reefslider", + "Cucumber", + "tomato", + ], + paints: ["503", "766", "624", "323", "500", "503", "427", "414"], + stats: [ + ["11", "01", "02"], + ["08", "00", "03"], + ["05", "02", "02"], + ["05", "03", "00"], + ["04", "03", "02"], + ["03", "06", "01"], + ["02", "05", "02"], + ["01", "06", "01"], + ], + teamScores: ["500", "0"], + nameSplitHints: {}, + }, + { + // This capture renders names slightly smaller than the reference: its + // 'T' (9x15) loses to the taller reference crops, and its baseline dots + // are pure homoglyphs the font templates can't split — '.' renders 4px, + // '・' 5px, so exact crops separate them via the ink-coverage penalty. + // Only the dot/T rows are labeled; the rest add nothing new. + frame: "scoreboard/robot/frame.png", + names: ["R.O.B.O.T", "", "", "", "", "Rαι×ι..・", "", ""], + paints: ["", "", "", "", "", "", "", ""], + stats: [ + ["", "", ""], + ["", "", ""], + ["", "", ""], + ["", "", ""], + ["", "", ""], + ["", "", ""], + ["", "", ""], + ["", "", ""], + ], + teamScores: ["", ""], + nameSplitHints: {}, + }, + { + frame: "scoreboard/splash-sploosh/frame.png", + // rows 2 and 7 hold kana/symbol glyphs not yet verified char-by-char + names: [ + "Bocchi", + "have faith", + "", + "Florescent", + "Elis", + "Jrod_14", + "GOLD SHIP", + "", + ], + paints: ["1422", "1161", "993", "1046", "1662", "1105", "799", "1155"], + stats: [ + ["15", "10", "06"], + ["13", "06", "04"], + ["06", "02", "05"], + ["13", "06", "05"], + ["15", "02", "06"], + ["10", "10", "03"], + ["02", "02", "04"], + ["08", "12", "06"], + ], + teamScores: ["500", "0"], + nameSplitHints: {}, + }, ]; /** Team totals sit on the light team-color pattern; binarize higher. */ @@ -188,29 +214,29 @@ const cv = await loadOpenCV(); let gray: Mat; async function loadGray(framePath: string): Promise { - const srcMat = toMat(await readImage(join(FIXTURES_DIR, framePath))); - const frame = normalizeFrame(srcMat); - srcMat.delete(); - const out = new cv.Mat(); - cv.cvtColor(frame, out, cv.COLOR_RGBA2GRAY); - frame.delete(); - return out; + const srcMat = toMat(await readImage(join(FIXTURES_DIR, framePath))); + const frame = normalizeFrame(srcMat); + srcMat.delete(); + const out = new cv.Mat(); + cv.cvtColor(frame, out, cv.COLOR_RGBA2GRAY); + frame.delete(); + return out; } /** Green channel instead of luminance — the replay code is green-on-dark. */ async function loadGreen(framePath: string): Promise { - const srcMat = toMat(await readImage(join(FIXTURES_DIR, framePath))); - const frame = normalizeFrame(srcMat); - srcMat.delete(); - const channels = new cv.MatVector(); - cv.split(frame, channels); - frame.delete(); - const g = channels.get(1); - const out = new cv.Mat(); - g.copyTo(out); - g.delete(); - channels.delete(); - return out; + const srcMat = toMat(await readImage(join(FIXTURES_DIR, framePath))); + const frame = normalizeFrame(srcMat); + srcMat.delete(); + const channels = new cv.MatVector(); + cv.split(frame, channels); + frame.delete(); + const g = channels.get(1); + const out = new cv.Mat(); + g.copyTo(out); + g.delete(); + channels.delete(); + return out; } /** @@ -220,69 +246,79 @@ async function loadGreen(framePath: string): Promise { * distance from the median banner color, normalized to 0-255. */ async function loadTagBand(framePath: string): Promise { - const srcMat = toMat(await readImage(join(FIXTURES_DIR, framePath))); - const frame = normalizeFrame(srcMat); - srcMat.delete(); - const rgb = new cv.Mat(); - cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB); - frame.delete(); + const srcMat = toMat(await readImage(join(FIXTURES_DIR, framePath))); + const frame = normalizeFrame(srcMat); + srcMat.delete(); + const rgb = new cv.Mat(); + cv.cvtColor(frame, rgb, cv.COLOR_RGBA2RGB); + frame.delete(); - const outerView = rgb.roi( - new cv.Rect(TAG_NAME_OUTER.x, TAG_NAME_OUTER.y, TAG_NAME_OUTER.w, TAG_NAME_OUTER.h), - ); - const outer = new cv.Mat(); - outerView.copyTo(outer); - outerView.delete(); - rgb.delete(); - const center = new cv.Point(outer.cols / 2, outer.rows / 2); - const m = cv.getRotationMatrix2D(center, -TAG_TILT_DEG, 1); - const rotated = new cv.Mat(); - cv.warpAffine( - outer, - rotated, - m, - new cv.Size(outer.cols, outer.rows), - cv.INTER_LINEAR, - cv.BORDER_REPLICATE, - new cv.Scalar(), - ); - m.delete(); - outer.delete(); - const innerView = rotated.roi( - new cv.Rect(TAG_NAME_INNER.x, TAG_NAME_INNER.y, TAG_NAME_INNER.w, TAG_NAME_INNER.h), - ); - const inner = new cv.Mat(); - innerView.copyTo(inner); - innerView.delete(); - rotated.delete(); + const outerView = rgb.roi( + new cv.Rect( + TAG_NAME_OUTER.x, + TAG_NAME_OUTER.y, + TAG_NAME_OUTER.w, + TAG_NAME_OUTER.h, + ), + ); + const outer = new cv.Mat(); + outerView.copyTo(outer); + outerView.delete(); + rgb.delete(); + const center = new cv.Point(outer.cols / 2, outer.rows / 2); + const m = cv.getRotationMatrix2D(center, -TAG_TILT_DEG, 1); + const rotated = new cv.Mat(); + cv.warpAffine( + outer, + rotated, + m, + new cv.Size(outer.cols, outer.rows), + cv.INTER_LINEAR, + cv.BORDER_REPLICATE, + new cv.Scalar(), + ); + m.delete(); + outer.delete(); + const innerView = rotated.roi( + new cv.Rect( + TAG_NAME_INNER.x, + TAG_NAME_INNER.y, + TAG_NAME_INNER.w, + TAG_NAME_INNER.h, + ), + ); + const inner = new cv.Mat(); + innerView.copyTo(inner); + innerView.delete(); + rotated.delete(); - const n = inner.rows * inner.cols; - const px = inner.data; - const background: number[] = []; - for (let c = 0; c < 3; c++) { - const hist = new Array(256).fill(0); - for (let i = 0; i < n; i++) hist[px[i * 3 + c]!]!++; - let acc = 0; - let v = 0; - for (; v < 255; v++) { - acc += hist[v]!; - if (acc >= n / 2) break; - } - background.push(v); - } - const band = new cv.Mat(inner.rows, inner.cols, cv.CV_8UC1); - const out = band.data; - for (let i = 0; i < n; i++) { - out[i] = Math.max( - Math.abs(px[i * 3]! - background[0]!), - Math.abs(px[i * 3 + 1]! - background[1]!), - Math.abs(px[i * 3 + 2]! - background[2]!), - ); - } - inner.delete(); - cv.normalize(band, band, 0, 255, cv.NORM_MINMAX); - clearBorderBlobs(band, TAG_BIN_THRESHOLD); - return band; + const n = inner.rows * inner.cols; + const px = inner.data; + const background: number[] = []; + for (let c = 0; c < 3; c++) { + const hist = new Array(256).fill(0); + for (let i = 0; i < n; i++) hist[px[i * 3 + c]!]!++; + let acc = 0; + let v = 0; + for (; v < 255; v++) { + acc += hist[v]!; + if (acc >= n / 2) break; + } + background.push(v); + } + const band = new cv.Mat(inner.rows, inner.cols, cv.CV_8UC1); + const out = band.data; + for (let i = 0; i < n; i++) { + out[i] = Math.max( + Math.abs(px[i * 3]! - background[0]!), + Math.abs(px[i * 3 + 1]! - background[1]!), + Math.abs(px[i * 3 + 2]! - background[2]!), + ); + } + inner.delete(); + cv.normalize(band, band, 0, 255, cv.NORM_MINMAX); + clearBorderBlobs(band, TAG_BIN_THRESHOLD); + return band; } /** @@ -293,116 +329,135 @@ async function loadTagBand(framePath: string): Promise { * tight box past the band edge. */ function clearBorderBlobs(band: Mat, threshold: number): void { - const bin = new cv.Mat(); - cv.threshold(band, bin, threshold, 255, cv.THRESH_BINARY); - const labels = new cv.Mat(); - const stats = new cv.Mat(); - const centroids = new cv.Mat(); - const count = cv.connectedComponentsWithStats(bin, labels, stats, centroids, 8); - bin.delete(); - centroids.delete(); - const s = stats.data32S; - const touchesBorder = new Uint8Array(count); - for (let i = 1; i < count; i++) { - const left = s[i * 5 + cv.CC_STAT_LEFT]!; - const top = s[i * 5 + cv.CC_STAT_TOP]!; - const right = left + s[i * 5 + cv.CC_STAT_WIDTH]!; - const bottom = top + s[i * 5 + cv.CC_STAT_HEIGHT]!; - touchesBorder[i] = - left === 0 || top === 0 || right === band.cols || bottom === band.rows ? 1 : 0; - } - stats.delete(); - const lab = labels.data32S; - const out = band.data; - for (let i = 0; i < out.length; i++) { - if (touchesBorder[lab[i]!]!) out[i] = 0; - } - labels.delete(); + const bin = new cv.Mat(); + cv.threshold(band, bin, threshold, 255, cv.THRESH_BINARY); + const labels = new cv.Mat(); + const stats = new cv.Mat(); + const centroids = new cv.Mat(); + const count = cv.connectedComponentsWithStats( + bin, + labels, + stats, + centroids, + 8, + ); + bin.delete(); + centroids.delete(); + const s = stats.data32S; + const touchesBorder = new Uint8Array(count); + for (let i = 1; i < count; i++) { + const left = s[i * 5 + cv.CC_STAT_LEFT]!; + const top = s[i * 5 + cv.CC_STAT_TOP]!; + const right = left + s[i * 5 + cv.CC_STAT_WIDTH]!; + const bottom = top + s[i * 5 + cv.CC_STAT_HEIGHT]!; + touchesBorder[i] = + left === 0 || top === 0 || right === band.cols || bottom === band.rows + ? 1 + : 0; + } + stats.delete(); + const lab = labels.data32S; + const out = band.data; + for (let i = 0; i < out.length; i++) { + if (touchesBorder[lab[i]!]!) out[i] = 0; + } + labels.delete(); } function columnRuns( - roi: Roi, - splitHints: number[] = [], - threshold = BIN_THRESHOLD, - mergeHints: number[] = [], + roi: Roi, + splitHints: number[] = [], + threshold = BIN_THRESHOLD, + mergeHints: number[] = [], ): { x0: number; x1: number }[] { - let runs: { x0: number; x1: number }[] = []; - let start = -1; - for (let x = roi.x; x < roi.x + roi.w; x++) { - let count = 0; - for (let y = roi.y; y < roi.y + roi.h; y++) { - if (gray.ucharPtr(y, x)[0]! > threshold) count++; - } - const on = count >= 1; - if (on && start < 0) start = x; - if (!on && start >= 0) { - if (x - start >= 2) runs.push({ x0: start, x1: x }); - start = -1; - } - } - if (start >= 0) runs.push({ x0: start, x1: roi.x + roi.w }); - // Merge hints bridge runs that belong to one glyph (multi-stroke kana - // like パ segments as two strokes): a hint x inside the gap between two - // consecutive runs joins them into a single crop. - for (const hint of mergeHints) { - const i = runs.findIndex( - (r, idx) => idx + 1 < runs.length && r.x1 <= hint && runs[idx + 1]!.x0 >= hint, - ); - if (i >= 0) { - runs = [...runs.slice(0, i), { x0: runs[i]!.x0, x1: runs[i + 1]!.x1 }, ...runs.slice(i + 2)]; - } - } - return runs.flatMap((run) => { - const cuts = splitHints.filter((h) => h > run.x0 + 1 && h < run.x1 - 1).sort((a, b) => a - b); - if (cuts.length === 0) return [run]; - const parts: { x0: number; x1: number }[] = []; - let x0 = run.x0; - for (const cut of cuts) { - parts.push({ x0, x1: cut }); - x0 = cut; - } - parts.push({ x0, x1: run.x1 }); - return parts; - }); + let runs: { x0: number; x1: number }[] = []; + let start = -1; + for (let x = roi.x; x < roi.x + roi.w; x++) { + let count = 0; + for (let y = roi.y; y < roi.y + roi.h; y++) { + if (gray.ucharPtr(y, x)[0]! > threshold) count++; + } + const on = count >= 1; + if (on && start < 0) start = x; + if (!on && start >= 0) { + if (x - start >= 2) runs.push({ x0: start, x1: x }); + start = -1; + } + } + if (start >= 0) runs.push({ x0: start, x1: roi.x + roi.w }); + // Merge hints bridge runs that belong to one glyph (multi-stroke kana + // like パ segments as two strokes): a hint x inside the gap between two + // consecutive runs joins them into a single crop. + for (const hint of mergeHints) { + const i = runs.findIndex( + (r, idx) => + idx + 1 < runs.length && r.x1 <= hint && runs[idx + 1]!.x0 >= hint, + ); + if (i >= 0) { + runs = [ + ...runs.slice(0, i), + { x0: runs[i]!.x0, x1: runs[i + 1]!.x1 }, + ...runs.slice(i + 2), + ]; + } + } + return runs.flatMap((run) => { + const cuts = splitHints + .filter((h) => h > run.x0 + 1 && h < run.x1 - 1) + .sort((a, b) => a - b); + if (cuts.length === 0) return [run]; + const parts: { x0: number; x1: number }[] = []; + let x0 = run.x0; + for (const cut of cuts) { + parts.push({ x0, x1: cut }); + x0 = cut; + } + parts.push({ x0, x1: run.x1 }); + return parts; + }); } -function cropGlyph(run: { x0: number; x1: number }, roi: Roi, threshold = BIN_THRESHOLD): Mat { - // tight vertical bounds within the run - let yMin = roi.y + roi.h; - let yMax = -1; - for (let y = roi.y; y < roi.y + roi.h; y++) { - for (let x = run.x0; x < run.x1; x++) { - if (gray.ucharPtr(y, x)[0]! > threshold) { - if (y < yMin) yMin = y; - if (y > yMax) yMax = y; - } - } - } - const pad = 1; - const rect = new cv.Rect( - run.x0 - pad, - yMin - pad, - run.x1 - run.x0 + 2 * pad, - yMax - yMin + 1 + 2 * pad, - ); - const view = gray.roi(rect); - const out = new cv.Mat(); - view.copyTo(out); - view.delete(); - // Apply the same background masking recognizeText uses at match time, so - // templates harvested off colored backgrounds stay comparable. - const binary = new cv.Mat(); - cv.threshold(out, binary, threshold, 255, cv.THRESH_BINARY); - const mask = new cv.Mat(); - const kernel = cv.getStructuringElement(cv.MORPH_RECT, new cv.Size(3, 3)); - cv.dilate(binary, mask, kernel, new cv.Point(-1, -1), 2); - kernel.delete(); - binary.delete(); - const masked = new cv.Mat(out.rows, out.cols, cv.CV_8UC1, new cv.Scalar(0)); - out.copyTo(masked, mask); - out.delete(); - mask.delete(); - return masked; +function cropGlyph( + run: { x0: number; x1: number }, + roi: Roi, + threshold = BIN_THRESHOLD, +): Mat { + // tight vertical bounds within the run + let yMin = roi.y + roi.h; + let yMax = -1; + for (let y = roi.y; y < roi.y + roi.h; y++) { + for (let x = run.x0; x < run.x1; x++) { + if (gray.ucharPtr(y, x)[0]! > threshold) { + if (y < yMin) yMin = y; + if (y > yMax) yMax = y; + } + } + } + const pad = 1; + const rect = new cv.Rect( + run.x0 - pad, + yMin - pad, + run.x1 - run.x0 + 2 * pad, + yMax - yMin + 1 + 2 * pad, + ); + const view = gray.roi(rect); + const out = new cv.Mat(); + view.copyTo(out); + view.delete(); + // Apply the same background masking recognizeText uses at match time, so + // templates harvested off colored backgrounds stay comparable. + const binary = new cv.Mat(); + cv.threshold(out, binary, threshold, 255, cv.THRESH_BINARY); + const mask = new cv.Mat(); + const kernel = cv.getStructuringElement(cv.MORPH_RECT, new cv.Size(3, 3)); + cv.dilate(binary, mask, kernel, new cv.Point(-1, -1), 2); + kernel.delete(); + binary.delete(); + const masked = new cv.Mat(out.rows, out.cols, cv.CV_8UC1, new cv.Scalar(0)); + out.copyTo(masked, mask); + out.delete(); + mask.delete(); + return masked; } /** @@ -412,69 +467,81 @@ function cropGlyph(run: { x0: number; x1: number }, roi: Roi, threshold = BIN_TH * phases, so each occurrence is a distinct, equally-authoritative exemplar. */ function harvest( - roi: Roi, - label: string, - collected: Map | [string, Mat][], - splitHints: number[] = [], - threshold = BIN_THRESHOLD, - mergeHints: number[] = [], + roi: Roi, + label: string, + collected: Map | [string, Mat][], + splitHints: number[] = [], + threshold = BIN_THRESHOLD, + mergeHints: number[] = [], ): void { - if (label === "") return; - const chars = [...label.replace(/ /g, "")]; - const runs = columnRuns(roi, splitHints, threshold, mergeHints); - if (runs.length !== chars.length) { - console.warn( - `segment mismatch for "${label}": ${runs.length} segments vs ${chars.length} chars — skipped`, - ); - return; - } - runs.forEach((run, i) => { - const ch = chars[i]!; - if (Array.isArray(collected)) { - collected.push([ch, cropGlyph(run, roi, threshold)]); - } else if (!collected.has(ch)) { - collected.set(ch, cropGlyph(run, roi, threshold)); - } - }); + if (label === "") return; + const chars = [...label.replace(/ /g, "")]; + const runs = columnRuns(roi, splitHints, threshold, mergeHints); + if (runs.length !== chars.length) { + console.warn( + `segment mismatch for "${label}": ${runs.length} segments vs ${chars.length} chars — skipped`, + ); + return; + } + runs.forEach((run, i) => { + const ch = chars[i]!; + if (Array.isArray(collected)) { + collected.push([ch, cropGlyph(run, roi, threshold)]); + } else if (!collected.has(ch)) { + collected.set(ch, cropGlyph(run, roi, threshold)); + } + }); } function writeAtlas( - name: string, - height: number, - collected: Map | [string, Mat][], + name: string, + height: number, + collected: Map | [string, Mat][], ): void { - const entries = collected instanceof Map ? [...collected.entries()] : collected; - const glyphs = entries.sort(([a], [b]) => a.localeCompare(b)); - if (glyphs.length === 0) { - console.warn(`${name}: nothing harvested, atlas not written`); - return; - } - const spacing = 2; - const totalW = glyphs.reduce((acc, [, m]) => acc + m.cols, 0) + spacing * (glyphs.length + 1); - const maxH = Math.max(...glyphs.map(([, m]) => m.rows)) + 2 * spacing; - const atlas = new cv.Mat(maxH, totalW, cv.CV_8UC1, new cv.Scalar(0)); + const entries = + collected instanceof Map ? [...collected.entries()] : collected; + const glyphs = entries.sort(([a], [b]) => a.localeCompare(b)); + if (glyphs.length === 0) { + console.warn(`${name}: nothing harvested, atlas not written`); + return; + } + const spacing = 2; + const totalW = + glyphs.reduce((acc, [, m]) => acc + m.cols, 0) + + spacing * (glyphs.length + 1); + const maxH = Math.max(...glyphs.map(([, m]) => m.rows)) + 2 * spacing; + const atlas = new cv.Mat(maxH, totalW, cv.CV_8UC1, new cv.Scalar(0)); - const meta: AtlasMeta = { height, glyphs: [] }; - let x = spacing; - for (const [char, m] of glyphs) { - const view = atlas.roi(new cv.Rect(x, spacing, m.cols, m.rows)); - m.copyTo(view); - view.delete(); - meta.glyphs.push({ char, x, y: spacing, w: m.cols, h: m.rows, source: "fixture" }); - x += m.cols + spacing; - } + const meta: AtlasMeta = { height, glyphs: [] }; + let x = spacing; + for (const [char, m] of glyphs) { + const view = atlas.roi(new cv.Rect(x, spacing, m.cols, m.rows)); + m.copyTo(view); + view.delete(); + meta.glyphs.push({ + char, + x, + y: spacing, + w: m.cols, + h: m.rows, + source: "fixture", + }); + x += m.cols + spacing; + } - const rgba = new cv.Mat(); - cv.cvtColor(atlas, rgba, cv.COLOR_GRAY2RGBA); - writePng(join(OUT_DIR, `${name}.png`), { - width: rgba.cols, - height: rgba.rows, - data: new Uint8ClampedArray(rgba.data), - }); - writeFileSync(join(OUT_DIR, `${name}.json`), JSON.stringify(meta, null, 2)); - atlas.delete(); - rgba.delete(); - console.log(`${name}: ${glyphs.length} glyphs -> ${OUT_DIR}/${name}.{png,json}`); + const rgba = new cv.Mat(); + cv.cvtColor(atlas, rgba, cv.COLOR_GRAY2RGBA); + writePng(join(OUT_DIR, `${name}.png`), { + width: rgba.cols, + height: rgba.rows, + data: new Uint8ClampedArray(rgba.data), + }); + writeFileSync(join(OUT_DIR, `${name}.json`), JSON.stringify(meta, null, 2)); + atlas.delete(); + rgba.delete(); + console.info( + `${name}: ${glyphs.length} glyphs -> ${OUT_DIR}/${name}.{png,json}`, + ); } mkdirSync(OUT_DIR, { recursive: true }); @@ -489,101 +556,113 @@ const lobbyEntries: [string, Mat][] = []; const lineEntries: [string, Mat][] = []; for (const source of SOURCES) { - console.log(`harvesting ${source.frame}`); - gray = await loadGray(source.frame); + console.info(`harvesting ${source.frame}`); + gray = await loadGray(source.frame); - const nameGlyphs: [string, Mat][] = []; - ROW_CENTERS.forEach((cy, row) => { - // name region may include paint digits for long names; stop where digits start - harvest( - { ...nameRoi(cy), w: 196 }, - source.names[row]!, - nameGlyphs, - source.nameSplitHints[row] ?? [], - ); - }); - nameEntries.push(...nameGlyphs); + const nameGlyphs: [string, Mat][] = []; + ROW_CENTERS.forEach((cy, row) => { + // name region may include paint digits for long names; stop where digits start + harvest( + { ...nameRoi(cy), w: 196 }, + source.names[row]!, + nameGlyphs, + source.nameSplitHints[row] ?? [], + ); + }); + nameEntries.push(...nameGlyphs); - const paintGlyphs = new Map(); - ROW_CENTERS.forEach((cy, row) => { - harvest(paintRoi(cy), source.paints[row]!, paintGlyphs); - }); - paintEntries.push(...paintGlyphs.entries()); + const paintGlyphs = new Map(); + ROW_CENTERS.forEach((cy, row) => { + harvest(paintRoi(cy), source.paints[row]!, paintGlyphs); + }); + paintEntries.push(...paintGlyphs.entries()); - const statGlyphs = new Map(); - ROW_CENTERS.forEach((cy, row) => { - for (const i of [0, 1, 2] as const) { - harvest(statRoi(cy, i), source.stats[row]![i]!, statGlyphs); - } - }); - statEntries.push(...statGlyphs.entries()); + const statGlyphs = new Map(); + ROW_CENTERS.forEach((cy, row) => { + for (const i of [0, 1, 2] as const) { + harvest(statRoi(cy, i), source.stats[row]![i]!, statGlyphs); + } + }); + statEntries.push(...statGlyphs.entries()); - const teamGlyphs = new Map(); - TEAM_SCORE_ROIS.forEach((roi, i) => { - harvest(roi, source.teamScores[i]!, teamGlyphs, [], TEAM_BIN_THRESHOLD); - }); - teamEntries.push(...teamGlyphs.entries()); + const teamGlyphs = new Map(); + TEAM_SCORE_ROIS.forEach((roi, i) => { + harvest(roi, source.teamScores[i]!, teamGlyphs, [], TEAM_BIN_THRESHOLD); + }); + teamEntries.push(...teamGlyphs.entries()); - // Header tags: lobby line (BlitzMain ~24px), then mode (BlitzBold ~35px) and - // stage (BlitzMain ~22px) side by side. - if (source.header) { - const { lobby, mode, stage } = source.header; - const lobbyGlyphs = new Map(); - harvest(lobby.roi, lobby.label, lobbyGlyphs, lobby.splitHints, lobby.threshold); - lobbyEntries.push(...lobbyGlyphs.entries()); + // Header tags: lobby line (BlitzMain ~24px), then mode (BlitzBold ~35px) and + // stage (BlitzMain ~22px) side by side. + if (source.header) { + const { lobby, mode, stage } = source.header; + const lobbyGlyphs = new Map(); + harvest( + lobby.roi, + lobby.label, + lobbyGlyphs, + lobby.splitHints, + lobby.threshold, + ); + lobbyEntries.push(...lobbyGlyphs.entries()); - const modeGlyphs = new Map(); - harvest(mode.roi, mode.label, modeGlyphs, mode.splitHints, mode.threshold); - const stageGlyphs = new Map(); - harvest(stage.roi, stage.label, stageGlyphs, stage.splitHints, stage.threshold); - lineEntries.push(...modeGlyphs.entries(), ...stageGlyphs.entries()); - } + const modeGlyphs = new Map(); + harvest(mode.roi, mode.label, modeGlyphs, mode.splitHints, mode.threshold); + const stageGlyphs = new Map(); + harvest( + stage.roi, + stage.label, + stageGlyphs, + stage.splitHints, + stage.threshold, + ); + lineEntries.push(...modeGlyphs.entries(), ...stageGlyphs.entries()); + } - gray.delete(); + gray.delete(); } // Replay-browser fixtures: the code line renders in FOT-RowdyStd, which no // live-scoreboard atlas covers. Letters land on different subpixel phases, // so keep every occurrence (array collector), like names. const REPLAY_SOURCES: { frame: string; code: string }[] = [ - { - frame: "scoreboard-replay/private-battle-splat-zones-hagglefish/frame.jpeg", - code: "R6KE-D064-3CXD-XVKL", - }, - { - frame: "scoreboard-replay/anarchy-open-rainmaker-knockout-museum/frame.png", - code: "RWYQ-4X37-M1EL-EGGQ", - }, - { - frame: "scoreboard-replay/private-battle-crableg-capital/frame.png", - code: "R1V4-PAHW-GGM2-PD9S", - }, - // Heavily compressed stream captures: font-rendered templates lose to - // fixture crops of lookalikes on these (E beat a real F by 0.1+), so the - // chars they cover (8 B F J N T U 5 among them) need crops at this - // fidelity. brinewater-1411/marlin stay out as generalization checks. - { - frame: "scoreboard-replay/x-battle-rainmaker-brinewater-1404/frame.png", - code: "R80B-00DL-WF4X-V3CA", - }, - { - frame: "scoreboard-replay/x-battle-rainmaker-brinewater-1416/frame.png", - code: "RUH3-3NEF-F5FY-PAJL", - }, - { - frame: "scoreboard-replay/x-battle-rainmaker-urchin-1434/frame.png", - code: "RUCT-5HNH-XWDC-J51U", - }, + { + frame: "scoreboard-replay/private-battle-splat-zones-hagglefish/frame.jpeg", + code: "R6KE-D064-3CXD-XVKL", + }, + { + frame: "scoreboard-replay/anarchy-open-rainmaker-knockout-museum/frame.png", + code: "RWYQ-4X37-M1EL-EGGQ", + }, + { + frame: "scoreboard-replay/private-battle-crableg-capital/frame.png", + code: "R1V4-PAHW-GGM2-PD9S", + }, + // Heavily compressed stream captures: font-rendered templates lose to + // fixture crops of lookalikes on these (E beat a real F by 0.1+), so the + // chars they cover (8 B F J N T U 5 among them) need crops at this + // fidelity. brinewater-1411/marlin stay out as generalization checks. + { + frame: "scoreboard-replay/x-battle-rainmaker-brinewater-1404/frame.png", + code: "R80B-00DL-WF4X-V3CA", + }, + { + frame: "scoreboard-replay/x-battle-rainmaker-brinewater-1416/frame.png", + code: "RUH3-3NEF-F5FY-PAJL", + }, + { + frame: "scoreboard-replay/x-battle-rainmaker-urchin-1434/frame.png", + code: "RUCT-5HNH-XWDC-J51U", + }, ]; const codeEntries: [string, Mat][] = []; for (const source of REPLAY_SOURCES) { - console.log(`harvesting ${source.frame}`); - gray = await loadGreen(source.frame); - const codeGlyphs: [string, Mat][] = []; - harvest(REPLAY_CODE_ROI, source.code, codeGlyphs); - codeEntries.push(...codeGlyphs); - gray.delete(); + console.info(`harvesting ${source.frame}`); + gray = await loadGreen(source.frame); + const codeGlyphs: [string, Mat][] = []; + harvest(REPLAY_CODE_ROI, source.code, codeGlyphs); + codeEntries.push(...codeGlyphs); + gray.delete(); } // Death splash-tag names (BlitzBold + Rowdy kana at ~46px). The atlas is @@ -592,49 +671,53 @@ for (const source of REPLAY_SOURCES) { // 42 here and come back to native after the load-time upscale. const TAG_BIN_THRESHOLD = 160; // TAG_NAME_BIN_THRESHOLD in death/index.ts const TAG_ATLAS_HEIGHT = 42; -const DEATH_TAG_SOURCES: { frame: string; label: string; mergeHints?: number[] }[] = [ - { - frame: "death/fxg-supaatan-slosher/frame.png", - label: "FxG スパータン", - // パ renders as two disconnected strokes; bridge them into one crop - mergeHints: [340], - }, - { - frame: "death/classic-squiffer-jp/frame.png", - label: "ごあんぜんに", - // に's bar and body never connect; bridge them into one crop - mergeHints: [431], - }, - { - frame: "death/wipeout-52-gal/frame.png", - label: "さんだいめドパかげ", - // だ (1px column gap), い, ド, パ, げ all segment as disconnected - // strokes/marks; bridge each into one crop - mergeHints: [176, 236, 366, 408, 528], - }, +const DEATH_TAG_SOURCES: { + frame: string; + label: string; + mergeHints?: number[]; +}[] = [ + { + frame: "death/fxg-supaatan-slosher/frame.png", + label: "FxG スパータン", + // パ renders as two disconnected strokes; bridge them into one crop + mergeHints: [340], + }, + { + frame: "death/classic-squiffer-jp/frame.png", + label: "ごあんぜんに", + // に's bar and body never connect; bridge them into one crop + mergeHints: [431], + }, + { + frame: "death/wipeout-52-gal/frame.png", + label: "さんだいめドパかげ", + // だ (1px column gap), い, ド, パ, げ all segment as disconnected + // strokes/marks; bridge each into one crop + mergeHints: [176, 236, 366, 408, 528], + }, ]; const tagEntries: [string, Mat][] = []; for (const source of DEATH_TAG_SOURCES) { - console.log(`harvesting ${source.frame}`); - gray = await loadTagBand(source.frame); - const tagGlyphs: [string, Mat][] = []; - harvest( - { x: 0, y: 0, w: gray.cols, h: gray.rows }, - source.label, - tagGlyphs, - [], - TAG_BIN_THRESHOLD, - source.mergeHints ?? [], - ); - gray.delete(); - const factor = TAG_ATLAS_HEIGHT / TAG_NAME_TEXT_HEIGHT; - for (const [char, mat] of tagGlyphs) { - const scaledMat = new cv.Mat(); - cv.resize(mat, scaledMat, new cv.Size(0, 0), factor, factor, cv.INTER_AREA); - mat.delete(); - tagEntries.push([char, scaledMat]); - } + console.info(`harvesting ${source.frame}`); + gray = await loadTagBand(source.frame); + const tagGlyphs: [string, Mat][] = []; + harvest( + { x: 0, y: 0, w: gray.cols, h: gray.rows }, + source.label, + tagGlyphs, + [], + TAG_BIN_THRESHOLD, + source.mergeHints ?? [], + ); + gray.delete(); + const factor = TAG_ATLAS_HEIGHT / TAG_NAME_TEXT_HEIGHT; + for (const [char, mat] of tagGlyphs) { + const scaledMat = new cv.Mat(); + cv.resize(mat, scaledMat, new cv.Size(0, 0), factor, factor, cv.INTER_AREA); + mat.delete(); + tagEntries.push([char, scaledMat]); + } } // JA death-message lines (condensed Kurokane/Rowdy blend at ~40px; the @@ -643,34 +726,38 @@ for (const source of DEATH_TAG_SOURCES) { // outside the burst stays out of the runs. const DEATH_JA_BIN_THRESHOLD = 190; // SPLAT_TEXT_BIN_THRESHOLD in death/rois.ts const DEATH_JA_SOURCES: { - frame: string; - lines: { roi: Roi; label: string; mergeHints?: number[] }[]; + frame: string; + lines: { roi: Roi; label: string; mergeHints?: number[] }[]; }[] = [ - { - frame: "death/classic-squiffer-jp/frame.png", - lines: [ - // ッ renders as two disconnected strokes; bridge them into one crop - { roi: { x: 825, y: 358, w: 275, h: 58 }, label: "スクイックリンα で", mergeHints: [917] }, - { roi: { x: 880, y: 410, w: 155, h: 56 }, label: "やられた!" }, - ], - }, + { + frame: "death/classic-squiffer-jp/frame.png", + lines: [ + // ッ renders as two disconnected strokes; bridge them into one crop + { + roi: { x: 825, y: 358, w: 275, h: 58 }, + label: "スクイックリンα で", + mergeHints: [917], + }, + { roi: { x: 880, y: 410, w: 155, h: 56 }, label: "やられた!" }, + ], + }, ]; const deathJaEntries: [string, Mat][] = []; for (const source of DEATH_JA_SOURCES) { - console.log(`harvesting ${source.frame}`); - gray = await loadGray(source.frame); - for (const line of source.lines) { - harvest( - line.roi, - line.label, - deathJaEntries, - [], - DEATH_JA_BIN_THRESHOLD, - line.mergeHints ?? [], - ); - } - gray.delete(); + console.info(`harvesting ${source.frame}`); + gray = await loadGray(source.frame); + for (const line of source.lines) { + harvest( + line.roi, + line.label, + deathJaEntries, + [], + DEATH_JA_BIN_THRESHOLD, + line.mergeHints ?? [], + ); + } + gray.delete(); } writeAtlas("scoreboard-names", 22, nameEntries); diff --git a/scripts/cv/build-glyph-atlas.ts b/scripts/cv/build-glyph-atlas.ts index 5818a8a9a..a85b5161f 100644 --- a/scripts/cv/build-glyph-atlas.ts +++ b/scripts/cv/build-glyph-atlas.ts @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/suspicious/noConsole: CLI script output */ /** * Build glyph atlases by rendering the Splatoon fonts at the exact * scoreboard render sizes, calibrated against the reference fixture: @@ -21,19 +22,19 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { createCanvas, GlobalFonts } from "@napi-rs/canvas"; import { - DEATH_MESSAGE_TEMPLATES, - LOCALIZED_WEAPON_NAMES, + DEATH_MESSAGE_TEMPLATES, + LOCALIZED_WEAPON_NAMES, } from "../../app/features/cv/core/detectors/death/localized-messages"; import { ALL_WEAPON_ENTRIES } from "../../app/features/cv/core/detectors/death/weapon-names"; import type { AtlasMeta } from "../../app/features/cv/core/glyphs"; -import { CV_ASSETS_DIR } from "../../app/features/cv/node/assets-dir"; import { - ALL_LOBBY_ENTRIES, - ALL_MODE_ENTRIES, - ALL_MODE_LABELS, - ALL_STAGE_ENTRIES, - RESULT_TAG_ENTRIES, + ALL_LOBBY_ENTRIES, + ALL_MODE_ENTRIES, + ALL_MODE_LABELS, + ALL_STAGE_ENTRIES, + RESULT_TAG_ENTRIES, } from "../../app/features/cv/core/localized"; +import { CV_ASSETS_DIR } from "../../app/features/cv/node/assets-dir"; import { readImage, writePng } from "../../app/features/cv/node/image-io"; import { readFontCoverage } from "./otf-cmap"; @@ -49,12 +50,12 @@ const FONTS_DIR = new URL("../../assets/fonts", import.meta.url).pathname; const OUT_DIR = join(CV_ASSETS_DIR, "glyphs"); const FONT_FILES = { - BlitzMain: "BlitzMain.otf", - BlitzBold: "BlitzBold.otf", - /** replay-browser code line and VICTORY/DEFEAT panel tags */ - Rowdy: "FOT-RowdyStd-EB.otf", - /** JA death message (together with Rowdy — see death-weapon-ja) */ - Kurokane: "FOT-KurokaneStd-EB.otf", + BlitzMain: "BlitzMain.otf", + BlitzBold: "BlitzBold.otf", + /** replay-browser code line and VICTORY/DEFEAT panel tags */ + Rowdy: "FOT-RowdyStd-EB.otf", + /** JA death message (together with Rowdy — see death-weapon-ja) */ + Kurokane: "FOT-KurokaneStd-EB.otf", } as const; /** @@ -67,35 +68,35 @@ const FONT_FILES = { const fontCoverage: Record boolean> = {}; for (const [family, file] of Object.entries(FONT_FILES)) { - const path = join(FONTS_DIR, file); - if (!existsSync(path)) { - console.error( - [ - `font not found: ${path}`, - "", - "The Splatoon fonts are proprietary and not committed. Get", - "Decrypted/BlitzMain.otf and Decrypted/BlitzBold.otf (e.g. from the", - "splatoon3-fonts repo) into assets/fonts/, or fall back to", - " pnpm cv:bootstrap-atlas", - ].join("\n"), - ); - process.exit(1); - } - GlobalFonts.registerFromPath(path, family); - fontCoverage[family] = readFontCoverage(path); + const path = join(FONTS_DIR, file); + if (!existsSync(path)) { + console.error( + [ + `font not found: ${path}`, + "", + "The Splatoon fonts are proprietary and not committed. Get", + "Decrypted/BlitzMain.otf and Decrypted/BlitzBold.otf (e.g. from the", + "splatoon3-fonts repo) into assets/fonts/, or fall back to", + " pnpm cv:bootstrap-atlas", + ].join("\n"), + ); + process.exit(1); + } + GlobalFonts.registerFromPath(path, family); + fontCoverage[family] = readFontCoverage(path); } const DIGITS = "0123456789"; function nameCharset(): string[] { - const chars: string[] = []; - for (let c = 33; c <= 126; c++) chars.push(String.fromCharCode(c)); // ASCII - for (let c = 0xa1; c <= 0xff; c++) chars.push(String.fromCharCode(c)); // Latin-1 - for (let c = 0x3041; c <= 0x3096; c++) chars.push(String.fromCharCode(c)); // hiragana - for (let c = 0x30a1; c <= 0x30fa; c++) chars.push(String.fromCharCode(c)); // katakana - chars.push("・", "ー", "、", "。", "「", "」"); - chars.push("★", "☆", "●", "♪"); // name decorations - return [...new Set(chars)]; + const chars: string[] = []; + for (let c = 33; c <= 126; c++) chars.push(String.fromCharCode(c)); // ASCII + for (let c = 0xa1; c <= 0xff; c++) chars.push(String.fromCharCode(c)); // Latin-1 + for (let c = 0x3041; c <= 0x3096; c++) chars.push(String.fromCharCode(c)); // hiragana + for (let c = 0x30a1; c <= 0x30fa; c++) chars.push(String.fromCharCode(c)); // katakana + chars.push("・", "ー", "、", "。", "「", "」"); + chars.push("★", "☆", "●", "♪"); // name decorations + return [...new Set(chars)]; } /** @@ -121,7 +122,8 @@ const NAME_GREEK = "ια"; // ι: "Rιppιng_H", α: "◇Dαrz™" (special-symb * duel it on ranking noise, and FF5E is the form the fixture labels attest. * Like NAME_GREEK, scoreboard-names only (not death-tag) until attested. */ -const NAME_SYMBOLS = "′‘’‚‛…″“”„←→↑↓⇒⇔˜€∞√∀⊂⊃∴∵∂№♭♀♂◎◇◆△▲▽▼†※™" + "『』【】〈〉《》〔〕々〆〇〃~"; +const NAME_SYMBOLS = + "′‘’‚‛…″“”„←→↑↓⇒⇔˜€∞√∀⊂⊃∴∵∂№♭♀♂◎◇◆△▲▽▼†※™" + "『』【】〈〉《》〔〕々〆〇〃~"; /** * Render the key char's glyph but emit it as the value char: in-game names @@ -131,32 +133,33 @@ const NAME_SYMBOLS = "′‘’‚‛…″“”„←→↑↓⇒⇔˜€∞ const RENDER_ALIASES: Record = { "●": "•" }; interface GlyphBitmap { - char: string; - w: number; - h: number; - /** grayscale, white-on-black (alpha coverage) */ - data: Uint8Array; - source: "fixture" | "font"; + char: string; + w: number; + h: number; + /** grayscale, white-on-black (alpha coverage) */ + data: Uint8Array; + source: "fixture" | "font"; } /** Carry fixture-harvested glyphs over from the existing atlas, if any. */ async function readFixtureGlyphs(name: string): Promise { - const pngPath = join(OUT_DIR, `${name}.png`); - const jsonPath = join(OUT_DIR, `${name}.json`); - if (!existsSync(pngPath) || !existsSync(jsonPath)) return []; - const meta = JSON.parse(readFileSync(jsonPath, "utf8")) as AtlasMeta; - const png = await readImage(pngPath); - return meta.glyphs - .filter((g) => g.source === "fixture") - .map((g) => { - const data = new Uint8Array(g.w * g.h); - for (let y = 0; y < g.h; y++) { - for (let x = 0; x < g.w; x++) { - data[y * g.w + x] = png.data[((g.y + y) * png.width + (g.x + x)) * 4]!; - } - } - return { char: g.char, w: g.w, h: g.h, data, source: "fixture" as const }; - }); + const pngPath = join(OUT_DIR, `${name}.png`); + const jsonPath = join(OUT_DIR, `${name}.json`); + if (!existsSync(pngPath) || !existsSync(jsonPath)) return []; + const meta = JSON.parse(readFileSync(jsonPath, "utf8")) as AtlasMeta; + const png = await readImage(pngPath); + return meta.glyphs + .filter((g) => g.source === "fixture") + .map((g) => { + const data = new Uint8Array(g.w * g.h); + for (let y = 0; y < g.h; y++) { + for (let x = 0; x < g.w; x++) { + data[y * g.w + x] = + png.data[((g.y + y) * png.width + (g.x + x)) * 4]!; + } + } + return { char: g.char, w: g.w, h: g.h, data, source: "fixture" as const }; + }); } /** @@ -164,145 +167,168 @@ async function readFixtureGlyphs(name: string): Promise { * condenses the glyph horizontally (the JA death message renders its font * squeezed to ~3/4 width in-game). */ -function renderGlyph(family: string, px: number, char: string, xScale = 1): GlyphBitmap | null { - const size = px * 4; - const canvas = createCanvas(size, size); - const ctx = canvas.getContext("2d"); - ctx.fillStyle = "white"; - ctx.font = `${px}px ${family}`; - ctx.textBaseline = "middle"; - ctx.scale(xScale, 1); - ctx.fillText(char, px / xScale, size / 2); - const img = ctx.getImageData(0, 0, size, size); - let x0 = size; - let x1 = -1; - let y0 = size; - let y1 = -1; - let ink = 0; - for (let y = 0; y < size; y++) { - for (let x = 0; x < size; x++) { - if (img.data[(y * size + x) * 4 + 3]! > 128) { - if (x < x0) x0 = x; - if (x > x1) x1 = x; - if (y < y0) y0 = y; - if (y > y1) y1 = y; - ink++; - } - } - } - if (x1 < 0 || ink < 4) return null; - const pad = 1; - const w = x1 - x0 + 1 + 2 * pad; - const h = y1 - y0 + 1 + 2 * pad; - const data = new Uint8Array(w * h); - for (let y = 0; y < h; y++) { - for (let x = 0; x < w; x++) { - const sx = x0 - pad + x; - const sy = y0 - pad + y; - if (sx < 0 || sy < 0 || sx >= size || sy >= size) continue; - data[y * w + x] = img.data[(sy * size + sx) * 4 + 3]!; - } - } - return { char, w, h, data, source: "font" }; +function renderGlyph( + family: string, + px: number, + char: string, + xScale = 1, +): GlyphBitmap | null { + const size = px * 4; + const canvas = createCanvas(size, size); + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "white"; + ctx.font = `${px}px ${family}`; + ctx.textBaseline = "middle"; + ctx.scale(xScale, 1); + ctx.fillText(char, px / xScale, size / 2); + const img = ctx.getImageData(0, 0, size, size); + let x0 = size; + let x1 = -1; + let y0 = size; + let y1 = -1; + let ink = 0; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + if (img.data[(y * size + x) * 4 + 3]! > 128) { + if (x < x0) x0 = x; + if (x > x1) x1 = x; + if (y < y0) y0 = y; + if (y > y1) y1 = y; + ink++; + } + } + } + if (x1 < 0 || ink < 4) return null; + const pad = 1; + const w = x1 - x0 + 1 + 2 * pad; + const h = y1 - y0 + 1 + 2 * pad; + const data = new Uint8Array(w * h); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const sx = x0 - pad + x; + const sy = y0 - pad + y; + if (sx < 0 || sy < 0 || sx >= size || sy >= size) continue; + data[y * w + x] = img.data[(sy * size + sx) * 4 + 3]!; + } + } + return { char, w, h, data, source: "font" }; } /** Pack glyphs into rows (wrapping at maxWidth) and write PNG + JSON. */ function writeAtlas(name: string, height: number, glyphs: GlyphBitmap[]): void { - const spacing = 2; - const maxWidth = 1600; - const meta: AtlasMeta = { height, glyphs: [] }; - let x = spacing; - let y = spacing; - let rowH = 0; - let atlasW = 0; - for (const g of glyphs) { - if (x + g.w + spacing > maxWidth) { - x = spacing; - y += rowH + spacing; - rowH = 0; - } - meta.glyphs.push({ char: g.char, x, y, w: g.w, h: g.h, source: g.source }); - x += g.w + spacing; - rowH = Math.max(rowH, g.h); - atlasW = Math.max(atlasW, x); - } - const atlasH = y + rowH + spacing; + const spacing = 2; + const maxWidth = 1600; + const meta: AtlasMeta = { height, glyphs: [] }; + let x = spacing; + let y = spacing; + let rowH = 0; + let atlasW = 0; + for (const g of glyphs) { + if (x + g.w + spacing > maxWidth) { + x = spacing; + y += rowH + spacing; + rowH = 0; + } + meta.glyphs.push({ char: g.char, x, y, w: g.w, h: g.h, source: g.source }); + x += g.w + spacing; + rowH = Math.max(rowH, g.h); + atlasW = Math.max(atlasW, x); + } + const atlasH = y + rowH + spacing; - const pixels = new Uint8ClampedArray(atlasW * atlasH * 4); - for (let i = 3; i < pixels.length; i += 4) pixels[i] = 255; - meta.glyphs.forEach((m, i) => { - const g = glyphs[i]!; - for (let gy = 0; gy < g.h; gy++) { - for (let gx = 0; gx < g.w; gx++) { - const v = g.data[gy * g.w + gx]!; - const o = ((m.y + gy) * atlasW + (m.x + gx)) * 4; - pixels[o] = v; - pixels[o + 1] = v; - pixels[o + 2] = v; - } - } - }); - writePng(join(OUT_DIR, `${name}.png`), { width: atlasW, height: atlasH, data: pixels }); - writeFileSync(join(OUT_DIR, `${name}.json`), JSON.stringify(meta, null, 2)); - console.log(`${name}: ${glyphs.length} glyphs (${atlasW}x${atlasH}) -> ${name}.{png,json}`); + const pixels = new Uint8ClampedArray(atlasW * atlasH * 4); + for (let i = 3; i < pixels.length; i += 4) pixels[i] = 255; + meta.glyphs.forEach((m, i) => { + const g = glyphs[i]!; + for (let gy = 0; gy < g.h; gy++) { + for (let gx = 0; gx < g.w; gx++) { + const v = g.data[gy * g.w + gx]!; + const o = ((m.y + gy) * atlasW + (m.x + gx)) * 4; + pixels[o] = v; + pixels[o + 1] = v; + pixels[o + 2] = v; + } + } + }); + writePng(join(OUT_DIR, `${name}.png`), { + width: atlasW, + height: atlasH, + data: pixels, + }); + writeFileSync(join(OUT_DIR, `${name}.json`), JSON.stringify(meta, null, 2)); + console.info( + `${name}: ${glyphs.length} glyphs (${atlasW}x${atlasH}) -> ${name}.{png,json}`, + ); } interface AtlasPart { - family: string; - /** in-game render size varies by ±1px; multiple template sizes per char */ - pxs: number[]; - chars: string[]; - /** horizontal condensation applied in-game (default 1 = none) */ - xScale?: number; + family: string; + /** in-game render size varies by ±1px; multiple template sizes per char */ + pxs: number[]; + chars: string[]; + /** horizontal condensation applied in-game (default 1 = none) */ + xScale?: number; } -async function build(name: string, height: number, parts: AtlasPart[]): Promise { - const glyphs: GlyphBitmap[] = await readFixtureGlyphs(name); - const fixtureCount = glyphs.length; - const missing: string[] = []; - for (const { family, pxs, chars, xScale } of parts) { - for (const ch of chars) { - let found = false; - for (const px of pxs) { - const g = renderGlyph(family, px, ch, xScale ?? 1); - if (g) { - glyphs.push({ ...g, char: RENDER_ALIASES[ch] ?? ch }); - found = true; - } - } - if (!found) missing.push(ch); - } - } - if (missing.length > 0) { - console.log(`${name}: ${missing.length} chars not renderable, skipped: ${missing.join("")}`); - } - console.log(`${name}: carrying over ${fixtureCount} fixture-harvested glyphs`); - writeAtlas(name, height, glyphs); +async function build( + name: string, + height: number, + parts: AtlasPart[], +): Promise { + const glyphs: GlyphBitmap[] = await readFixtureGlyphs(name); + const fixtureCount = glyphs.length; + const missing: string[] = []; + for (const { family, pxs, chars, xScale } of parts) { + for (const ch of chars) { + let found = false; + for (const px of pxs) { + const g = renderGlyph(family, px, ch, xScale ?? 1); + if (g) { + glyphs.push({ ...g, char: RENDER_ALIASES[ch] ?? ch }); + found = true; + } + } + if (!found) missing.push(ch); + } + } + if (missing.length > 0) { + console.info( + `${name}: ${missing.length} chars not renderable, skipped: ${missing.join("")}`, + ); + } + console.info( + `${name}: carrying over ${fixtureCount} fixture-harvested glyphs`, + ); + writeAtlas(name, height, glyphs); } /** NAME_SYMBOLS chars the font actually maps (see the constant's comment). */ function nameSymbols(family: string): string[] { - const covers = fontCoverage[family]!; - return [...NAME_SYMBOLS].filter((ch) => covers(ch.codePointAt(0)!)); + const covers = fontCoverage[family]!; + return [...NAME_SYMBOLS].filter((ch) => covers(ch.codePointAt(0)!)); } /** Unique non-space characters across a set of known strings. */ function charsOf(entries: readonly string[]): string[] { - return [...new Set(entries.flatMap((e) => [...e.replace(/ /g, "")]))]; + return [...new Set(entries.flatMap((e) => [...e.replace(/ /g, "")]))]; } mkdirSync(OUT_DIR, { recursive: true }); await build("scoreboard-paint-digits", 29, [ - { family: "BlitzMain", pxs: [34], chars: [...DIGITS] }, + { family: "BlitzMain", pxs: [34], chars: [...DIGITS] }, +]); +await build("scoreboard-stat-digits", 17, [ + { family: "BlitzMain", pxs: [20], chars: [...DIGITS] }, +]); +await build("scoreboard-team-digits", 28, [ + { family: "BlitzBold", pxs: [36], chars: [...DIGITS] }, ]); -await build("scoreboard-stat-digits", 17, [{ family: "BlitzMain", pxs: [20], chars: [...DIGITS] }]); -await build("scoreboard-team-digits", 28, [{ family: "BlitzBold", pxs: [36], chars: [...DIGITS] }]); await build("scoreboard-names", 17, [ - { - family: "BlitzMain", - pxs: [19, 20], - chars: [...nameCharset(), ...NAME_GREEK, ...nameSymbols("BlitzMain")], - }, + { + family: "BlitzMain", + pxs: [19, 20], + chars: [...nameCharset(), ...NAME_GREEK, ...nameSymbols("BlitzMain")], + }, ]); /** * Localized closed-set charsets (all 14 game languages; the canonical @@ -316,11 +342,11 @@ await build("scoreboard-names", 17, [ * substitute into the atlas. */ function localizedChars(texts: readonly string[], family: string): string[] { - const covers = fontCoverage[family]!; - return charsOf(texts).filter((ch) => { - const cp = ch.codePointAt(0)!; - return cp < 0x250 && covers(cp); - }); + const covers = fontCoverage[family]!; + return charsOf(texts).filter((ch) => { + const cp = ch.codePointAt(0)!; + return cp < 0x250 && covers(cp); + }); } const lobbyTexts = ALL_LOBBY_ENTRIES.map((e) => e.text); @@ -331,19 +357,39 @@ const resultTexts = RESULT_TAG_ENTRIES.map((e) => e.text); // header: lobby tag is BlitzMain ~24px; the mode/stage line mixes bold mode // text with regular stage text, so its atlas carries both faces await build("scoreboard-header-lobby", 19, [ - { family: "BlitzMain", pxs: [23, 24], chars: localizedChars(lobbyTexts, "BlitzMain") }, + { + family: "BlitzMain", + pxs: [23, 24], + chars: localizedChars(lobbyTexts, "BlitzMain"), + }, ]); await build("scoreboard-header-line", 24, [ - { family: "BlitzBold", pxs: [34, 35], chars: localizedChars(modeTexts, "BlitzBold") }, - { family: "BlitzMain", pxs: [22, 23], chars: localizedChars(stageTexts, "BlitzMain") }, + { + family: "BlitzBold", + pxs: [34, 35], + chars: localizedChars(modeTexts, "BlitzBold"), + }, + { + family: "BlitzMain", + pxs: [22, 23], + chars: localizedChars(stageTexts, "BlitzMain"), + }, ]); // replay browser: the code line and the VICTORY/DEFEAT panel tags render in // FOT-RowdyStd-EB (tight height 25px at ~32px, 30px at ~38px) await build("scoreboard-replay-code", 25, [ - { family: "Rowdy", pxs: [31, 32, 33], chars: [...DIGITS, ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ", "-"] }, + { + family: "Rowdy", + pxs: [31, 32, 33], + chars: [...DIGITS, ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ", "-"], + }, ]); await build("scoreboard-replay-result", 30, [ - { family: "Rowdy", pxs: [37, 38], chars: localizedChars(resultTexts, "Rowdy") }, + { + family: "Rowdy", + pxs: [37, 38], + chars: localizedChars(resultTexts, "Rowdy"), + }, ]); // map-start intro splash: the big mode title on the center splat is // BlitzBold (~76px tight caps at 1080p; a px sweep against the fixture reads @@ -352,14 +398,18 @@ await build("scoreboard-replay-result", 30, [ // too and is read with the stage atlas rescaled to its ~48px height, so its // chars ride along. await build("map-start-mode", 76, [ - { family: "BlitzBold", pxs: [99, 100, 101], chars: localizedChars(modeTexts, "BlitzBold") }, + { + family: "BlitzBold", + pxs: [99, 100, 101], + chars: localizedChars(modeTexts, "BlitzBold"), + }, ]); await build("map-start-stage", 40, [ - { - family: "BlitzMain", - pxs: [46, 47, 48], - chars: localizedChars([...stageTexts, ...ALL_MODE_LABELS], "BlitzMain"), - }, + { + family: "BlitzMain", + pxs: [46, 47, 48], + chars: localizedChars([...stageTexts, ...ALL_MODE_LABELS], "BlitzMain"), + }, ]); // death screen: the localized "Splatted by !" burst message (tight // caps ~28px; the face reads between the two Blitz cuts at capture @@ -367,13 +417,27 @@ await build("map-start-stage", 40, [ // splash-tag name, which is BlitzBold for latin but the angular Rowdy face // for kana const deathWeaponTexts = [ - ...ALL_WEAPON_ENTRIES.map((e) => e.name), - ...Object.values(LOCALIZED_WEAPON_NAMES).flatMap((names) => names.map((n) => n.text)), - ...DEATH_MESSAGE_TEMPLATES.flatMap((t) => [t.constText, t.weaponPre, t.weaponPost]), + ...ALL_WEAPON_ENTRIES.map((e) => e.name), + ...Object.values(LOCALIZED_WEAPON_NAMES).flatMap((names) => + names.map((n) => n.text), + ), + ...DEATH_MESSAGE_TEMPLATES.flatMap((t) => [ + t.constText, + t.weaponPre, + t.weaponPost, + ]), ]; await build("death-weapon", 34, [ - { family: "BlitzMain", pxs: [40, 41], chars: localizedChars(deathWeaponTexts, "BlitzMain") }, - { family: "BlitzBold", pxs: [43, 44], chars: localizedChars(deathWeaponTexts, "BlitzBold") }, + { + family: "BlitzMain", + pxs: [40, 41], + chars: localizedChars(deathWeaponTexts, "BlitzMain"), + }, + { + family: "BlitzBold", + pxs: [43, 44], + chars: localizedChars(deathWeaponTexts, "BlitzBold"), + }, ]); // JA death message, a separate atlas read only by the JA line ROIs: mixing // kana into the Latin set would shift its median width (breaking wide- @@ -385,30 +449,33 @@ await build("death-weapon", 34, [ // (LACT-450, .52ガロン) ride along in both faces. Attested-fixture rule as // everywhere: KO/ZH names stay in the closed sets without an atlas. const deathJaTexts = [ - ...(LOCALIZED_WEAPON_NAMES.JPja ?? []).map((n) => n.text), - ...DEATH_MESSAGE_TEMPLATES.filter((t) => t.langs.some((l) => l.endsWith("ja"))).flatMap((t) => [ - t.constText, - t.weaponPre, - t.weaponPost, - ]), + ...(LOCALIZED_WEAPON_NAMES.JPja ?? []).map((n) => n.text), + ...DEATH_MESSAGE_TEMPLATES.filter((t) => + t.langs.some((l) => l.endsWith("ja")), + ).flatMap((t) => [t.constText, t.weaponPre, t.weaponPost]), ]; function coveredChars(texts: readonly string[], family: string): string[] { - const covers = fontCoverage[family]!; - return charsOf(texts).filter((ch) => covers(ch.codePointAt(0)!)); + const covers = fontCoverage[family]!; + return charsOf(texts).filter((ch) => covers(ch.codePointAt(0)!)); } await build("death-weapon-ja", 40, [ - { - family: "Kurokane", - pxs: [44, 46], - xScale: 0.75, - chars: coveredChars(deathJaTexts, "Kurokane"), - }, - { family: "Rowdy", pxs: [38, 40], xScale: 0.8, chars: coveredChars(deathJaTexts, "Rowdy") }, + { + family: "Kurokane", + pxs: [44, 46], + xScale: 0.75, + chars: coveredChars(deathJaTexts, "Kurokane"), + }, + { + family: "Rowdy", + pxs: [38, 40], + xScale: 0.8, + chars: coveredChars(deathJaTexts, "Rowdy"), + }, ]); // nominal 42 with the detector scaling to 46 reads strictly better than a // native-46 render (the cubic upscale slightly fattens strokes the way the // in-game compositing does; a native render splits か into two segments) await build("death-tag-name", 42, [ - { family: "BlitzBold", pxs: [53, 54], chars: nameCharset() }, - { family: "Rowdy", pxs: [52, 54], chars: nameCharset() }, + { family: "BlitzBold", pxs: [53, 54], chars: nameCharset() }, + { family: "Rowdy", pxs: [52, 54], chars: nameCharset() }, ]); diff --git a/scripts/cv/build-localized-entries.ts b/scripts/cv/build-localized-entries.ts index 5b1c10db1..b65a7b246 100644 --- a/scripts/cv/build-localized-entries.ts +++ b/scripts/cv/build-localized-entries.ts @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/suspicious/noConsole: CLI script output */ /** * Generate the localized closed sets from the splat3 repo's language dumps * (https://github.com/Leanny/splat3, data/language/_full.json), so @@ -26,21 +27,22 @@ */ import { readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import gameMisc from "../../locales/en/game-misc.json"; -import { stageIds } from "../../app/modules/in-game-lists/stage-ids"; -import type { ModeShort, StageId } from "../../app/modules/in-game-lists/types"; import { ALL_WEAPON_ENTRIES } from "../../app/features/cv/core/detectors/death/weapon-names"; import type { CvLobby } from "../../app/features/cv/cv-types"; +import { stageIds } from "../../app/modules/in-game-lists/stage-ids"; +import type { ModeShort, StageId } from "../../app/modules/in-game-lists/types"; +import gameMisc from "../../locales/en/game-misc.json"; -const SPLAT3_DIR = process.argv[2] ?? new URL("../../../splat3", import.meta.url).pathname; +const SPLAT3_DIR = + process.argv[2] ?? new URL("../../../splat3", import.meta.url).pathname; const LANG_DIR = join(SPLAT3_DIR, "data", "language"); const OUT_ENTRIES = new URL( - "../../app/features/cv/core/localized-entries.ts", - import.meta.url, + "../../app/features/cv/core/localized-entries.ts", + import.meta.url, ).pathname; const OUT_MESSAGES = new URL( - "../../app/features/cv/core/detectors/death/localized-messages.ts", - import.meta.url, + "../../app/features/cv/core/detectors/death/localized-messages.ts", + import.meta.url, ).pathname; const CANONICAL_LANG = "USen"; @@ -48,66 +50,70 @@ const misc = gameMisc as Record; /** VSRuleName key -> ModeShort; USen values validate against the en locale. */ const RULE_KEYS: Record = { - Pnt: "TW", - Var: "SZ", - Vlf: "TC", - Vgl: "RM", - Vcl: "CB", + Pnt: "TW", + Var: "SZ", + Vlf: "TC", + Vgl: "RM", + Vcl: "CB", }; /** MatchMode key -> lobby code; USen values validate against these names. */ const LOBBY_KEYS: Record = { - XMatch: "X", - Bankara: "SERIES", - BankaraOpen: "OPEN", - Private: "PRIVATE", + XMatch: "X", + Bankara: "SERIES", + BankaraOpen: "OPEN", + Private: "PRIVATE", }; /** the English lobby tags as the game shows them, for USen validation */ const LOBBY_ENGLISH: Record = { - X: "X Battle", - SERIES: "Anarchy Battle (Series)", - OPEN: "Anarchy Battle (Open)", - PRIVATE: "Private Battle", + X: "X Battle", + SERIES: "Anarchy Battle (Series)", + OPEN: "Anarchy Battle (Open)", + PRIVATE: "Private Battle", }; type LangDump = Record>; function loadLang(lang: string): LangDump { - return JSON.parse(readFileSync(join(LANG_DIR, `${lang}_full.json`), "utf8")) as LangDump; + return JSON.parse( + readFileSync(join(LANG_DIR, `${lang}_full.json`), "utf8"), + ) as LangDump; } /** Drop [size=...]/[color=...]-style markup and collapse whitespace. */ function clean(s: string): string { - return s - .replace(/\[[^\]]*\]/g, "") - .replace(/[ \t]+/g, " ") - .trim(); + return s + .replace(/\[[^\]]*\]/g, "") + .replace(/[ \t]+/g, " ") + .trim(); } /** The case/space/diacritic-insensitive key entries are deduped on (mirrors text.ts). */ function foldKey(s: string): string { - return s - .toLowerCase() - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/\s+/g, ""); + return s + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/\s+/g, ""); } /** sendou.ink locales use the ASCII apostrophe; the game dumps use ’. */ function normalizeApostrophes(s: string): string { - return s.replace(/[’‘]/g, "'"); + return s.replace(/[’‘]/g, "'"); } const languages = [ - ...new Set( - readdirSync(LANG_DIR) - .filter((f) => f.endsWith("_full.json")) - .map((f) => f.replace("_full.json", "")), - ), + ...new Set( + readdirSync(LANG_DIR) + .filter((f) => f.endsWith("_full.json")) + .map((f) => f.replace("_full.json", "")), + ), ].sort(); if (!languages.includes(CANONICAL_LANG)) { - throw new Error(`canonical language ${CANONICAL_LANG} not found in ${LANG_DIR}`); + throw new Error( + `canonical language ${CANONICAL_LANG} not found in ${LANG_DIR}`, + ); } const dumps = new Map(languages.map((l) => [l, loadLang(l)])); @@ -116,108 +122,115 @@ const usen = dumps.get(CANONICAL_LANG)!; // ---- validate the canonical sets against USen ------------------------------ for (const [key, mode] of Object.entries(RULE_KEYS)) { - const value = clean(usen["CommonMsg/VS/VSRuleName"]![key]!); - const expected = misc[`MODE_LONG_${mode}`]!; - if (value !== expected) - throw new Error(`USen rule ${key} is "${value}", expected "${expected}"`); + const value = clean(usen["CommonMsg/VS/VSRuleName"]![key]!); + const expected = misc[`MODE_LONG_${mode}`]!; + if (value !== expected) + throw new Error(`USen rule ${key} is "${value}", expected "${expected}"`); } for (const [key, lobby] of Object.entries(LOBBY_KEYS)) { - const value = clean(usen["CommonMsg/MatchMode"]![key]!); - const expected = LOBBY_ENGLISH[lobby as CvLobby]; - if (value !== expected) - throw new Error(`USen lobby ${key} is "${value}", expected "${expected}"`); + const value = clean(usen["CommonMsg/MatchMode"]![key]!); + const expected = LOBBY_ENGLISH[lobby as CvLobby]; + if (value !== expected) + throw new Error(`USen lobby ${key} is "${value}", expected "${expected}"`); } /** English stage name (per the sendou.ink en locale) -> StageId. */ const stageIdByEnglishName = new Map( - stageIds.map((stageId) => [misc[`STAGE_${stageId}`]!, stageId]), + stageIds.map((stageId) => [misc[`STAGE_${stageId}`]!, stageId]), ); /** VSStageName key -> StageId, via the USen values. */ const stageKeys = new Map(); for (const [key, value] of Object.entries(usen["CommonMsg/VS/VSStageName"]!)) { - const stageId = stageIdByEnglishName.get(value); - if (stageId !== undefined) stageKeys.set(key, stageId); + const stageId = stageIdByEnglishName.get(value); + if (stageId !== undefined) stageKeys.set(key, stageId); } for (const [name, stageId] of stageIdByEnglishName) { - if (![...stageKeys.values()].includes(stageId)) { - throw new Error(`stage "${name}" (id ${stageId}) not found in USen VSStageName`); - } + if (![...stageKeys.values()].includes(stageId)) { + throw new Error( + `stage "${name}" (id ${stageId}) not found in USen VSStageName`, + ); + } } // ---- per-language closed sets ---------------------------------------------- interface LocalizedLobby { - text: string; - lobby: CvLobby; + text: string; + lobby: CvLobby; } interface LocalizedMode { - text: string; - mode: ModeShort; + text: string; + mode: ModeShort; } interface LocalizedStage { - text: string; - stageId: StageId; + text: string; + stageId: StageId; } interface LanguageEntries { - lang: string; - modeLabel: string; - victory: string; - defeat: string; - lobbies: LocalizedLobby[]; - modes: LocalizedMode[]; - modeWraps: LocalizedMode[]; - stages: LocalizedStage[]; + lang: string; + modeLabel: string; + victory: string; + defeat: string; + lobbies: LocalizedLobby[]; + modes: LocalizedMode[]; + modeWraps: LocalizedMode[]; + stages: LocalizedStage[]; } const languageEntries: LanguageEntries[] = []; for (const lang of languages) { - const d = dumps.get(lang)!; - const rules = d["CommonMsg/VS/VSRuleName"]!; - const modes: LocalizedMode[] = []; - const modeWraps: LocalizedMode[] = []; - for (const [key, mode] of Object.entries(RULE_KEYS)) { - modes.push({ text: clean(rules[key]!), mode }); - // the intro splash renders the _2L wrap variant, hyphens included - const wrap = clean(rules[`${key}_2L`]!.replace(/\n/g, " ")); - if (foldKey(wrap) !== foldKey(rules[key]!)) modeWraps.push({ text: wrap, mode }); - } - languageEntries.push({ - lang, - modeLabel: clean(d["LayoutMsg/Lobby_MenuMode_00"]!.T_Rule_00!), - victory: clean(d["LayoutMsg/Mng_Result_00"]!.T_Win_00!), - defeat: clean(d["LayoutMsg/Mng_Result_00"]!.T_Lose_00!), - lobbies: Object.entries(LOBBY_KEYS).map(([key, lobby]) => ({ - text: clean(d["CommonMsg/MatchMode"]![key]!), - lobby, - })), - modes, - modeWraps, - stages: [...stageKeys.entries()].map(([key, stageId]) => ({ - text: clean(d["CommonMsg/VS/VSStageName"]![key]!), - stageId, - })), - }); + const d = dumps.get(lang)!; + const rules = d["CommonMsg/VS/VSRuleName"]!; + const modes: LocalizedMode[] = []; + const modeWraps: LocalizedMode[] = []; + for (const [key, mode] of Object.entries(RULE_KEYS)) { + modes.push({ text: clean(rules[key]!), mode }); + // the intro splash renders the _2L wrap variant, hyphens included + const wrap = clean(rules[`${key}_2L`]!.replace(/\n/g, " ")); + if (foldKey(wrap) !== foldKey(rules[key]!)) + modeWraps.push({ text: wrap, mode }); + } + languageEntries.push({ + lang, + modeLabel: clean(d["LayoutMsg/Lobby_MenuMode_00"]!.T_Rule_00!), + victory: clean(d["LayoutMsg/Mng_Result_00"]!.T_Win_00!), + defeat: clean(d["LayoutMsg/Mng_Result_00"]!.T_Lose_00!), + lobbies: Object.entries(LOBBY_KEYS).map(([key, lobby]) => ({ + text: clean(d["CommonMsg/MatchMode"]![key]!), + lobby, + })), + modes, + modeWraps, + stages: [...stageKeys.entries()].map(([key, stageId]) => ({ + text: clean(d["CommonMsg/VS/VSStageName"]![key]!), + stageId, + })), + }); } // A localized string that means one thing in language A and another in // language B would snap ambiguously in the flattened unions — reject. for (const category of ["lobbies", "modes", "modeWraps", "stages"] as const) { - const seen = new Map(); - for (const entries of languageEntries) { - for (const entry of entries[category]) { - const value = - "lobby" in entry ? entry.lobby : "mode" in entry ? entry.mode : entry.stageId; - const k = foldKey(entry.text); - const prior = seen.get(k); - if (prior !== undefined && prior !== value) { - throw new Error( - `${category}: "${entry.text}" maps to both "${prior}" and "${value}" across languages`, - ); - } - seen.set(k, value); - } - } + const seen = new Map(); + for (const entries of languageEntries) { + for (const entry of entries[category]) { + const value = + "lobby" in entry + ? entry.lobby + : "mode" in entry + ? entry.mode + : entry.stageId; + const k = foldKey(entry.text); + const prior = seen.get(k); + if (prior !== undefined && prior !== value) { + throw new Error( + `${category}: "${entry.text}" maps to both "${prior}" and "${value}" across languages`, + ); + } + seen.set(k, value); + } + } } // ---- death message templates ----------------------------------------------- @@ -227,94 +240,110 @@ const PLACEHOLDER = /\[group=[^\]]*\]/; const SENTINEL = "\u0000"; interface DeathTemplate { - langs: string[]; - weaponLine: 1 | 2; - constText: string; - weaponPre: string; - weaponPost: string; + langs: string[]; + weaponLine: 1 | 2; + constText: string; + weaponPre: string; + weaponPost: string; } const templates: DeathTemplate[] = []; for (const lang of languages) { - const raw = dumps.get(lang)!["LayoutMsg/VS_Beaten_00"]!["999"]!; - const lines = raw.split("\n").map((l) => clean(l.replace(PLACEHOLDER, SENTINEL))); - if (lines.length !== 2) throw new Error(`${lang}: death message is not two lines: ${raw}`); - const weaponIndex = lines.findIndex((l) => l.includes(SENTINEL)); - if (weaponIndex < 0) throw new Error(`${lang}: no weapon placeholder: ${raw}`); - const weaponLine = (weaponIndex + 1) as 1 | 2; - const [pre, post] = lines[weaponLine - 1]!.split(SENTINEL) as [string, string]; - const constText = lines[weaponLine % 2]!; - const existing = templates.find( - (t) => - t.weaponLine === weaponLine && - t.constText === constText && - t.weaponPre === pre && - t.weaponPost === post, - ); - if (existing) existing.langs.push(lang); - else templates.push({ langs: [lang], weaponLine, constText, weaponPre: pre, weaponPost: post }); + const raw = dumps.get(lang)!["LayoutMsg/VS_Beaten_00"]!["999"]!; + const lines = raw + .split("\n") + .map((l) => clean(l.replace(PLACEHOLDER, SENTINEL))); + if (lines.length !== 2) + throw new Error(`${lang}: death message is not two lines: ${raw}`); + const weaponIndex = lines.findIndex((l) => l.includes(SENTINEL)); + if (weaponIndex < 0) + throw new Error(`${lang}: no weapon placeholder: ${raw}`); + const weaponLine = (weaponIndex + 1) as 1 | 2; + const [pre, post] = lines[weaponLine - 1]!.split(SENTINEL) as [ + string, + string, + ]; + const constText = lines[weaponLine % 2]!; + const existing = templates.find( + (t) => + t.weaponLine === weaponLine && + t.constText === constText && + t.weaponPre === pre && + t.weaponPost === post, + ); + if (existing) existing.langs.push(lang); + else + templates.push({ + langs: [lang], + weaponLine, + constText, + weaponPre: pre, + weaponPost: post, + }); } // ---- localized weapon names -------------------------------------------------- const WEAPON_MSGS = [ - "CommonMsg/Weapon/WeaponName_Main", - "CommonMsg/Weapon/WeaponName_Sub", - "CommonMsg/Weapon/WeaponName_Special", + "CommonMsg/Weapon/WeaponName_Main", + "CommonMsg/Weapon/WeaponName_Sub", + "CommonMsg/Weapon/WeaponName_Special", ]; const canonicalWeaponNames = new Set(ALL_WEAPON_ENTRIES.map((e) => e.name)); /** codename -> canonical entry name, via the USen value. */ const weaponCodenames = new Map(); for (const msg of WEAPON_MSGS) { - for (const [codename, value] of Object.entries(usen[msg]!)) { - const name = normalizeApostrophes(value); - if (canonicalWeaponNames.has(name)) weaponCodenames.set(`${msg} ${codename}`, name); - } + for (const [codename, value] of Object.entries(usen[msg]!)) { + const name = normalizeApostrophes(value); + if (canonicalWeaponNames.has(name)) + weaponCodenames.set(`${msg} ${codename}`, name); + } } const unmapped = [...canonicalWeaponNames].filter( - (n) => ![...weaponCodenames.values()].includes(n), + (n) => ![...weaponCodenames.values()].includes(n), ); if (unmapped.length > 0) { - console.warn( - `WARNING: ${unmapped.length} weapon entries have no splat3 codename: ${unmapped.join(", ")}`, - ); + console.warn( + `WARNING: ${unmapped.length} weapon entries have no splat3 codename: ${unmapped.join(", ")}`, + ); } /** lang -> localized names that differ from the canonical English name. */ -const localizedWeaponNames: Record = {}; +const localizedWeaponNames: Record = + {}; for (const lang of languages) { - const d = dumps.get(lang)!; - const seen = new Set(); - const entries: { text: string; name: string }[] = []; - for (const [key, name] of weaponCodenames) { - const [msg, codename] = key.split(" ") as [string, string]; - const text = clean(d[msg]![codename] ?? ""); - if (!text || text === "-") continue; - const k = foldKey(text); - if (seen.has(k) || k === foldKey(name)) continue; - seen.add(k); - entries.push({ text, name }); - } - if (entries.length > 0) localizedWeaponNames[lang] = entries; + const d = dumps.get(lang)!; + const seen = new Set(); + const entries: { text: string; name: string }[] = []; + for (const [key, name] of weaponCodenames) { + const [msg, codename] = key.split(" ") as [string, string]; + const text = clean(d[msg]![codename] ?? ""); + if (!text || text === "-") continue; + const k = foldKey(text); + if (seen.has(k) || k === foldKey(name)) continue; + seen.add(k); + entries.push({ text, name }); + } + if (entries.length > 0) localizedWeaponNames[lang] = entries; } // ---- emit -------------------------------------------------------------------- const banner = (extra: string) => - `/** + `/** * GENERATED by scripts/cv/build-localized-entries.ts from the splat3 repo's * language dumps — do not edit by hand; regenerate when the game adds * content. ${extra} */`; writeFileSync( - OUT_ENTRIES, - `${banner( - `Localized versus-UI strings for every game language, each + OUT_ENTRIES, + `${banner( + `Localized versus-UI strings for every game language, each * mapped to the sendou.ink id it means (core/localized.ts derives the * flattened match sets detectors snap OCR output against).`, - )} + )} import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import type { CvLobby } from "../cv-types"; @@ -348,20 +377,20 @@ export interface LanguageEntries { } export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = ${JSON.stringify( - languageEntries, - null, - 2, - )}; + languageEntries, + null, + 2, + )}; `, ); writeFileSync( - OUT_MESSAGES, - `${banner( - `Per-language death-burst message templates and localized + OUT_MESSAGES, + `${banner( + `Per-language death-burst message templates and localized * weapon names: the "Splatted by\\n!" burst puts the weapon on line * 1 or 2 depending on language, with language-specific text around it.`, - )} + )} export interface DeathMessageTemplate { /** languages sharing this exact template */ @@ -376,10 +405,10 @@ export interface DeathMessageTemplate { } export const DEATH_MESSAGE_TEMPLATES: readonly DeathMessageTemplate[] = ${JSON.stringify( - templates, - null, - 2, - )}; + templates, + null, + 2, + )}; export interface LocalizedWeaponName { text: string; @@ -397,11 +426,11 @@ export const LOCALIZED_WEAPON_NAMES: Readonly< `, ); -console.log( - `localized-entries: ${languages.length} languages, ` + - `${languageEntries.reduce((n, l) => n + l.stages.length, 0)} stage strings`, +console.info( + `localized-entries: ${languages.length} languages, ` + + `${languageEntries.reduce((n, l) => n + l.stages.length, 0)} stage strings`, ); -console.log( - `localized-messages: ${templates.length} death templates, ` + - `${Object.values(localizedWeaponNames).reduce((n, e) => n + e.length, 0)} localized weapon names`, +console.info( + `localized-messages: ${templates.length} death templates, ` + + `${Object.values(localizedWeaponNames).reduce((n, e) => n + e.length, 0)} localized weapon names`, ); diff --git a/scripts/cv/build-planner-signatures.ts b/scripts/cv/build-planner-signatures.ts index 90e508478..772f72fb8 100644 --- a/scripts/cv/build-planner-signatures.ts +++ b/scripts/cv/build-planner-signatures.ts @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/suspicious/noConsole: CLI script output */ /** * Build the planner signature atlas from the assets repo's planner renders. * @@ -17,84 +18,94 @@ import { mkdirSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { loadOpenCV } from "../../app/features/cv/core/cv"; import { - PLANNER_SIG_H, - PLANNER_SIG_W, - type PlannerManifest, - plannerSignature, + PLANNER_SIG_H, + PLANNER_SIG_W, + type PlannerManifest, + plannerSignature, } from "../../app/features/cv/core/detectors/minimap/stage"; -import { type FrameData, normalizeFrame, toMat } from "../../app/features/cv/core/image"; +import { + type FrameData, + normalizeFrame, + toMat, +} from "../../app/features/cv/core/image"; import { CV_ASSETS_DIR } from "../../app/features/cv/node/assets-dir"; import { readImage, writePng } from "../../app/features/cv/node/image-io"; /** which render variant the signatures are built from */ const PLANNER_TYPE = process.env.CV_PLANNER_TYPE ?? "MINI"; const SRC_DIR = - process.env.CV_PLANNER_MAPS_DIR ?? - new URL("../../../assets/assets/planner-maps", import.meta.url).pathname; -const OUT_DIR = process.env.CV_PLANNER_OUT_DIR ?? join(CV_ASSETS_DIR, "planner"); + process.env.CV_PLANNER_MAPS_DIR ?? + new URL("../../../assets/assets/planner-maps", import.meta.url).pathname; +const OUT_DIR = + process.env.CV_PLANNER_OUT_DIR ?? join(CV_ASSETS_DIR, "planner"); const COLS = 5; await loadOpenCV(); const files = readdirSync(SRC_DIR) - .filter((f) => f.endsWith(`-${PLANNER_TYPE}.png`)) - .sort((a, b) => { - const [sa, ma] = a.split("-"); - const [sb, mb] = b.split("-"); - return Number(sa) - Number(sb) || ma!.localeCompare(mb!); - }); + .filter((f) => f.endsWith(`-${PLANNER_TYPE}.png`)) + .sort((a, b) => { + const [sa, ma] = a.split("-"); + const [sb, mb] = b.split("-"); + return Number(sa) - Number(sb) || ma!.localeCompare(mb!); + }); if (files.length === 0) { - throw new Error(`no ${PLANNER_TYPE} planner PNGs in ${SRC_DIR}`); + throw new Error(`no ${PLANNER_TYPE} planner PNGs in ${SRC_DIR}`); } const rows = Math.ceil(files.length / COLS); const atlasW = COLS * PLANNER_SIG_W; const atlasH = rows * PLANNER_SIG_H; const atlas: FrameData = { - width: atlasW, - height: atlasH, - data: new Uint8ClampedArray(atlasW * atlasH * 4), + width: atlasW, + height: atlasH, + data: new Uint8ClampedArray(atlasW * atlasH * 4), }; const keys: string[] = []; for (let i = 0; i < files.length; i++) { - const file = files[i]!; - const key = file.replace(`-${PLANNER_TYPE}.png`, ""); - keys.push(key); + const file = files[i]!; + const key = file.replace(`-${PLANNER_TYPE}.png`, ""); + keys.push(key); - const img = toMat(await readImage(join(SRC_DIR, file))); - const norm = normalizeFrame(img); - const sig = plannerSignature(norm); - img.delete(); - norm.delete(); + const img = toMat(await readImage(join(SRC_DIR, file))); + const norm = normalizeFrame(img); + const sig = plannerSignature(norm); + img.delete(); + norm.delete(); - // quantize to uint8 (load re-normalizes to unit L2, so the scale is free) - let max = 0; - for (const v of sig) if (v > max) max = v; - const scale = max > 0 ? 255 / max : 0; + // quantize to uint8 (load re-normalizes to unit L2, so the scale is free) + let max = 0; + for (const v of sig) if (v > max) max = v; + const scale = max > 0 ? 255 / max : 0; - const tx = (i % COLS) * PLANNER_SIG_W; - const ty = Math.floor(i / COLS) * PLANNER_SIG_H; - for (let y = 0; y < PLANNER_SIG_H; y++) { - for (let x = 0; x < PLANNER_SIG_W; x++) { - const v = Math.round(sig[y * PLANNER_SIG_W + x]! * scale); - const o = ((ty + y) * atlasW + (tx + x)) * 4; - atlas.data[o] = v; - atlas.data[o + 1] = v; - atlas.data[o + 2] = v; - atlas.data[o + 3] = 255; - } - } + const tx = (i % COLS) * PLANNER_SIG_W; + const ty = Math.floor(i / COLS) * PLANNER_SIG_H; + for (let y = 0; y < PLANNER_SIG_H; y++) { + for (let x = 0; x < PLANNER_SIG_W; x++) { + const v = Math.round(sig[y * PLANNER_SIG_W + x]! * scale); + const o = ((ty + y) * atlasW + (tx + x)) * 4; + atlas.data[o] = v; + atlas.data[o + 1] = v; + atlas.data[o + 2] = v; + atlas.data[o + 3] = 255; + } + } } mkdirSync(OUT_DIR, { recursive: true }); writePng(join(OUT_DIR, "signatures.png"), atlas); const manifest: PlannerManifest = { - width: PLANNER_SIG_W, - height: PLANNER_SIG_H, - cols: COLS, - keys, + width: PLANNER_SIG_W, + height: PLANNER_SIG_H, + cols: COLS, + keys, }; -writeFileSync(join(OUT_DIR, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); +writeFileSync( + join(OUT_DIR, "manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, +); -console.log(`packed ${files.length} signatures into ${atlasW}x${atlasH} atlas -> ${OUT_DIR}`); +console.info( + `packed ${files.length} signatures into ${atlasW}x${atlasH} atlas -> ${OUT_DIR}`, +); diff --git a/scripts/cv/dump-crops.ts b/scripts/cv/dump-crops.ts index 7495735c0..acde070ae 100644 --- a/scripts/cv/dump-crops.ts +++ b/scripts/cv/dump-crops.ts @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/suspicious/noConsole: CLI script output */ /** * ROI calibration helper: normalize a frame to 1080p, then dump crops * (optionally scaled up) and/or a grid overlay for visual inspection. @@ -9,15 +10,20 @@ import { mkdirSync } from "node:fs"; import { join } from "node:path"; import { loadOpenCV } from "../../app/features/cv/core/cv"; -import { cropRoi, matToFrameData, normalizeFrame, toMat } from "../../app/features/cv/core/image"; +import { + cropRoi, + matToFrameData, + normalizeFrame, + toMat, +} from "../../app/features/cv/core/image"; import { readImage, writePng } from "../../app/features/cv/node/image-io"; const [imagePath, outDir, ...specs] = process.argv.slice(2); if (!imagePath || !outDir || specs.length === 0) { - console.error( - "usage: vite-node -c scripts/cv/vite-node.config.ts scripts/cv/dump-crops.ts (grid | x,y,w,h[,scale][:label])...", - ); - process.exit(1); + console.error( + "usage: vite-node -c scripts/cv/vite-node.config.ts scripts/cv/dump-crops.ts (grid | x,y,w,h[,scale][:label])...", + ); + process.exit(1); } const cv = await loadOpenCV(); @@ -28,53 +34,60 @@ const frame = normalizeFrame(src); src.delete(); for (const spec of specs) { - if (spec === "grid") { - const overlay = frame.clone(); - for (let x = 0; x < overlay.cols; x += 20) { - const major = x % 100 === 0; - cv.line( - overlay, - new cv.Point(x, 0), - new cv.Point(x, overlay.rows), - new cv.Scalar(255, 0, major ? 0 : 255, 255), - major ? 2 : 1, - ); - } - for (let y = 0; y < overlay.rows; y += 20) { - const major = y % 100 === 0; - cv.line( - overlay, - new cv.Point(0, y), - new cv.Point(overlay.cols, y), - new cv.Scalar(255, 0, major ? 0 : 255, 255), - major ? 2 : 1, - ); - } - writePng(join(outDir, "grid.png"), matToFrameData(overlay)); - overlay.delete(); - continue; - } - const [rect = "", label] = spec.split(":"); - const [x, y, w, h, scale = 1] = rect.split(",").map(Number); - if ([x, y, w, h].some((v) => !Number.isFinite(v))) { - console.error(`bad spec: ${spec}`); - continue; - } - const cx = Math.max(0, Math.min(x!, frame.cols - 1)); - const cy = Math.max(0, Math.min(y!, frame.rows - 1)); - const crop = cropRoi(frame, { - x: cx, - y: cy, - w: Math.min(w!, frame.cols - cx), - h: Math.min(h!, frame.rows - cy), - }); - const out = new cv.Mat(); - cv.resize(crop, out, new cv.Size(0, 0), Number(scale), Number(scale), cv.INTER_NEAREST); - const name = label ?? `crop-${x}-${y}-${w}x${h}`; - writePng(join(outDir, `${name}.png`), matToFrameData(out)); - crop.delete(); - out.delete(); + if (spec === "grid") { + const overlay = frame.clone(); + for (let x = 0; x < overlay.cols; x += 20) { + const major = x % 100 === 0; + cv.line( + overlay, + new cv.Point(x, 0), + new cv.Point(x, overlay.rows), + new cv.Scalar(255, 0, major ? 0 : 255, 255), + major ? 2 : 1, + ); + } + for (let y = 0; y < overlay.rows; y += 20) { + const major = y % 100 === 0; + cv.line( + overlay, + new cv.Point(0, y), + new cv.Point(overlay.cols, y), + new cv.Scalar(255, 0, major ? 0 : 255, 255), + major ? 2 : 1, + ); + } + writePng(join(outDir, "grid.png"), matToFrameData(overlay)); + overlay.delete(); + continue; + } + const [rect = "", label] = spec.split(":"); + const [x, y, w, h, scale = 1] = rect.split(",").map(Number); + if ([x, y, w, h].some((v) => !Number.isFinite(v))) { + console.error(`bad spec: ${spec}`); + continue; + } + const cx = Math.max(0, Math.min(x!, frame.cols - 1)); + const cy = Math.max(0, Math.min(y!, frame.rows - 1)); + const crop = cropRoi(frame, { + x: cx, + y: cy, + w: Math.min(w!, frame.cols - cx), + h: Math.min(h!, frame.rows - cy), + }); + const out = new cv.Mat(); + cv.resize( + crop, + out, + new cv.Size(0, 0), + Number(scale), + Number(scale), + cv.INTER_NEAREST, + ); + const name = label ?? `crop-${x}-${y}-${w}x${h}`; + writePng(join(outDir, `${name}.png`), matToFrameData(out)); + crop.delete(); + out.delete(); } frame.delete(); -console.log(`wrote crops to ${outDir}`); +console.info(`wrote crops to ${outDir}`); diff --git a/scripts/cv/otf-cmap.ts b/scripts/cv/otf-cmap.ts index 6211f6ec8..606c45382 100644 --- a/scripts/cv/otf-cmap.ts +++ b/scripts/cv/otf-cmap.ts @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/suspicious/noConsole: CLI script output */ /** * Minimal OpenType cmap reader: which codepoints does a font actually map? * @@ -11,49 +12,50 @@ import { readFileSync } from "node:fs"; export function readFontCoverage(path: string): (codepoint: number) => boolean { - const buf = readFileSync(path); - const numTables = buf.readUInt16BE(4); - let cmapOffset = -1; - for (let i = 0; i < numTables; i++) { - const rec = 12 + i * 16; - if (buf.toString("latin1", rec, rec + 4) === "cmap") { - cmapOffset = buf.readUInt32BE(rec + 8); - break; - } - } - if (cmapOffset < 0) throw new Error(`${path}: no cmap table`); + const buf = readFileSync(path); + const numTables = buf.readUInt16BE(4); + let cmapOffset = -1; + for (let i = 0; i < numTables; i++) { + const rec = 12 + i * 16; + if (buf.toString("latin1", rec, rec + 4) === "cmap") { + cmapOffset = buf.readUInt32BE(rec + 8); + break; + } + } + if (cmapOffset < 0) throw new Error(`${path}: no cmap table`); - // prefer a full-repertoire format-12 subtable, else the BMP format 4 - const encodingCount = buf.readUInt16BE(cmapOffset + 2); - let best = -1; - let bestFormat = -1; - for (let i = 0; i < encodingCount; i++) { - const rec = cmapOffset + 4 + i * 8; - const subtable = cmapOffset + buf.readUInt32BE(rec + 4); - const format = buf.readUInt16BE(subtable); - if (format === 12 || (format === 4 && bestFormat !== 12)) { - best = subtable; - bestFormat = format; - } - } - if (best < 0) throw new Error(`${path}: no format 4/12 cmap subtable`); + // prefer a full-repertoire format-12 subtable, else the BMP format 4 + const encodingCount = buf.readUInt16BE(cmapOffset + 2); + let best = -1; + let bestFormat = -1; + for (let i = 0; i < encodingCount; i++) { + const rec = cmapOffset + 4 + i * 8; + const subtable = cmapOffset + buf.readUInt32BE(rec + 4); + const format = buf.readUInt16BE(subtable); + if (format === 12 || (format === 4 && bestFormat !== 12)) { + best = subtable; + bestFormat = format; + } + } + if (best < 0) throw new Error(`${path}: no format 4/12 cmap subtable`); - const ranges: [number, number][] = []; - if (bestFormat === 12) { - const nGroups = buf.readUInt32BE(best + 12); - for (let g = 0; g < nGroups; g++) { - const rec = best + 16 + g * 12; - ranges.push([buf.readUInt32BE(rec), buf.readUInt32BE(rec + 4)]); - } - } else { - const segCount = buf.readUInt16BE(best + 6) / 2; - const endCodes = best + 14; - const startCodes = endCodes + segCount * 2 + 2; - for (let s = 0; s < segCount; s++) { - const start = buf.readUInt16BE(startCodes + s * 2); - const end = buf.readUInt16BE(endCodes + s * 2); - if (start !== 0xffff) ranges.push([start, end]); - } - } - return (codepoint) => ranges.some(([start, end]) => codepoint >= start && codepoint <= end); + const ranges: [number, number][] = []; + if (bestFormat === 12) { + const nGroups = buf.readUInt32BE(best + 12); + for (let g = 0; g < nGroups; g++) { + const rec = best + 16 + g * 12; + ranges.push([buf.readUInt32BE(rec), buf.readUInt32BE(rec + 4)]); + } + } else { + const segCount = buf.readUInt16BE(best + 6) / 2; + const endCodes = best + 14; + const startCodes = endCodes + segCount * 2 + 2; + for (let s = 0; s < segCount; s++) { + const start = buf.readUInt16BE(startCodes + s * 2); + const end = buf.readUInt16BE(endCodes + s * 2); + if (start !== 0xffff) ranges.push([start, end]); + } + } + return (codepoint) => + ranges.some(([start, end]) => codepoint >= start && codepoint <= end); } diff --git a/scripts/cv/overlay-rois.ts b/scripts/cv/overlay-rois.ts index e11a3fd4f..5ff81fa53 100644 --- a/scripts/cv/overlay-rois.ts +++ b/scripts/cv/overlay-rois.ts @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/suspicious/noConsole: CLI script output */ /** * Draw all scoreboard ROIs on a (normalized) frame for visual calibration. * Usage: vite-node -c scripts/cv/vite-node.config.ts scripts/cv/overlay-rois.ts [out.png] [scoreboard|scoreboard-replay] @@ -8,15 +9,21 @@ import * as mapStart from "../../app/features/cv/core/detectors/map-start/rois"; import * as minimap from "../../app/features/cv/core/detectors/minimap/rois"; import * as sb from "../../app/features/cv/core/detectors/scoreboard/rois"; import * as replay from "../../app/features/cv/core/detectors/scoreboard-replay/rois"; -import { matToFrameData, normalizeFrame, type Roi, toMat } from "../../app/features/cv/core/image"; +import { + matToFrameData, + normalizeFrame, + type Roi, + toMat, +} from "../../app/features/cv/core/image"; import { readImage, writePng } from "../../app/features/cv/node/image-io"; -const [imagePath, outPath = "roi-overlay.png", detector = "scoreboard"] = process.argv.slice(2); +const [imagePath, outPath = "roi-overlay.png", detector = "scoreboard"] = + process.argv.slice(2); if (!imagePath) { - console.error( - "usage: vite-node -c scripts/cv/vite-node.config.ts scripts/cv/overlay-rois.ts [out.png] [scoreboard|scoreboard-replay|death|map-start|minimap]", - ); - process.exit(1); + console.error( + "usage: vite-node -c scripts/cv/vite-node.config.ts scripts/cv/overlay-rois.ts [out.png] [scoreboard|scoreboard-replay|death|map-start|minimap]", + ); + process.exit(1); } const cv = await loadOpenCV(); @@ -25,87 +32,92 @@ const frame = normalizeFrame(src); src.delete(); function rect(m: Mat, roi: Roi, color: [number, number, number]) { - cv.rectangle( - m, - new cv.Point(roi.x, roi.y), - new cv.Point(roi.x + roi.w, roi.y + roi.h), - new cv.Scalar(...color, 255), - 1, - ); + cv.rectangle( + m, + new cv.Point(roi.x, roi.y), + new cv.Point(roi.x + roi.w, roi.y + roi.h), + new cv.Scalar(...color, 255), + 1, + ); } if (detector === "scoreboard") { - for (const cy of sb.ROW_CENTERS) { - rect(frame, sb.weaponRoi(cy), [255, 0, 0]); - rect(frame, sb.nameRoi(cy), [0, 255, 0]); - rect(frame, sb.paintRoi(cy), [0, 128, 255]); - for (const i of [0, 1, 2] as const) rect(frame, sb.statRoi(cy, i), [255, 0, 255]); - rect(frame, sb.gateDarkProbe(cy), [255, 255, 0]); - } - for (const roi of sb.TEAM_SCORE_ROIS) rect(frame, roi, [0, 128, 255]); - for (const roi of sb.GATE_PANEL_PROBES) rect(frame, roi, [255, 255, 0]); + for (const cy of sb.ROW_CENTERS) { + rect(frame, sb.weaponRoi(cy), [255, 0, 0]); + rect(frame, sb.nameRoi(cy), [0, 255, 0]); + rect(frame, sb.paintRoi(cy), [0, 128, 255]); + for (const i of [0, 1, 2] as const) + rect(frame, sb.statRoi(cy, i), [255, 0, 255]); + rect(frame, sb.gateDarkProbe(cy), [255, 255, 0]); + } + for (const roi of sb.TEAM_SCORE_ROIS) rect(frame, roi, [0, 128, 255]); + for (const roi of sb.GATE_PANEL_PROBES) rect(frame, roi, [255, 255, 0]); } else if (detector === "scoreboard-replay") { - for (const dx of replay.PANEL_XS) { - for (const cy of replay.ROW_CENTERS) { - rect(frame, replay.weaponRoi(cy, dx), [255, 0, 0]); - rect(frame, replay.nameRoi(cy, dx), [0, 255, 0]); - rect(frame, replay.paintRoi(cy, dx), [0, 128, 255]); - rect(frame, replay.paintSuffixRoi(cy, dx), [0, 255, 255]); - for (const i of [0, 1, 2] as const) rect(frame, replay.statRoi(cy, dx, i), [255, 0, 255]); - rect(frame, replay.gateFlatProbe(cy, dx), [255, 255, 0]); - } - rect(frame, replay.teamScoreRoi(dx), [0, 128, 255]); - rect(frame, replay.resultTagRoi(dx), [255, 128, 0]); - } - for (const roi of replay.MATCH_SCORE_ROIS) rect(frame, roi, [0, 128, 255]); - for (const roi of replay.GATE_GAP_PROBES) rect(frame, roi, [255, 255, 0]); - rect(frame, replay.HEADER_TOP_BAND, [0, 255, 0]); - rect(frame, replay.HEADER_BOTTOM_BAND, [0, 255, 0]); - rect(frame, replay.REPLAY_CODE_ROI, [0, 255, 0]); + for (const dx of replay.PANEL_XS) { + for (const cy of replay.ROW_CENTERS) { + rect(frame, replay.weaponRoi(cy, dx), [255, 0, 0]); + rect(frame, replay.nameRoi(cy, dx), [0, 255, 0]); + rect(frame, replay.paintRoi(cy, dx), [0, 128, 255]); + rect(frame, replay.paintSuffixRoi(cy, dx), [0, 255, 255]); + for (const i of [0, 1, 2] as const) + rect(frame, replay.statRoi(cy, dx, i), [255, 0, 255]); + rect(frame, replay.gateFlatProbe(cy, dx), [255, 255, 0]); + } + rect(frame, replay.teamScoreRoi(dx), [0, 128, 255]); + rect(frame, replay.resultTagRoi(dx), [255, 128, 0]); + } + for (const roi of replay.MATCH_SCORE_ROIS) rect(frame, roi, [0, 128, 255]); + for (const roi of replay.GATE_GAP_PROBES) rect(frame, roi, [255, 255, 0]); + rect(frame, replay.HEADER_TOP_BAND, [0, 255, 0]); + rect(frame, replay.HEADER_BOTTOM_BAND, [0, 255, 0]); + rect(frame, replay.REPLAY_CODE_ROI, [0, 255, 0]); } else if (detector === "death") { - rect(frame, death.SPLAT_LINE1_ROI, [0, 255, 0]); - rect(frame, death.WEAPON_LINE_ROI, [255, 0, 0]); - for (let row = 0; row < death.ABILITY_ROWS; row++) { - rect(frame, death.abilityMainRoi(row), [0, 128, 255]); - for (const slot of [0, 1, 2]) rect(frame, death.abilitySubRoi(row, slot), [255, 0, 255]); - } - rect(frame, death.TAG_NAME_OUTER, [0, 255, 0]); - for (const roi of [...death.GATE_BURST_PROBES, ...death.GATE_PANEL_PROBES]) { - rect(frame, roi, [255, 255, 0]); - } + rect(frame, death.SPLAT_LINE1_ROI, [0, 255, 0]); + rect(frame, death.WEAPON_LINE_ROI, [255, 0, 0]); + for (let row = 0; row < death.ABILITY_ROWS; row++) { + rect(frame, death.abilityMainRoi(row), [0, 128, 255]); + for (const slot of [0, 1, 2]) + rect(frame, death.abilitySubRoi(row, slot), [255, 0, 255]); + } + rect(frame, death.TAG_NAME_OUTER, [0, 255, 0]); + for (const roi of [...death.GATE_BURST_PROBES, ...death.GATE_PANEL_PROBES]) { + rect(frame, roi, [255, 255, 0]); + } } else if (detector === "map-start") { - rect(frame, mapStart.MODE_LABEL_ROI, [0, 255, 0]); - rect(frame, mapStart.MODE_BLOCK_ROI, [255, 0, 0]); - rect(frame, mapStart.STAGE_ROI, [0, 128, 255]); - rect(frame, mapStart.GATE_INK_BAND, [0, 255, 255]); - for (const roi of mapStart.GATE_DARK_PROBES) rect(frame, roi, [255, 255, 0]); + rect(frame, mapStart.MODE_LABEL_ROI, [0, 255, 0]); + rect(frame, mapStart.MODE_BLOCK_ROI, [255, 0, 0]); + rect(frame, mapStart.STAGE_ROI, [0, 128, 255]); + rect(frame, mapStart.GATE_INK_BAND, [0, 255, 255]); + for (const roi of mapStart.GATE_DARK_PROBES) rect(frame, roi, [255, 255, 0]); } else if (detector === "minimap") { - for (const card of minimap.CARD_LAYOUTS) { - rect(frame, card.name, [0, 255, 0]); - rect(frame, card.weapon, [255, 0, 0]); - rect(frame, card.subTile, [0, 255, 255]); - for (const [cx, cy] of card.badges) rect(frame, minimap.badgeRoi(cx, cy), [0, 128, 255]); - rect(frame, card.cross, [255, 0, 255]); - } - for (const cy of minimap.ENEMY_ROW_CYS) { - rect(frame, minimap.enemyWeaponRoi(cy), [255, 0, 0]); - rect(frame, minimap.enemySubTileRoi(cy), [0, 255, 255]); - for (const cx of minimap.ENEMY_BADGE_XS) rect(frame, minimap.badgeRoi(cx, cy), [0, 128, 255]); - rect(frame, minimap.enemyCrossRoi(cy), [255, 0, 255]); - } - for (const roi of [ - minimap.GATE_CLOSE_BRIGHT, - minimap.GATE_SPAWN_BRIGHT, - ...minimap.GATE_CLOSE_DARK_PROBES, - ...minimap.GATE_SPAWN_DARK_PROBES, - ]) { - rect(frame, roi, [255, 255, 0]); - } + for (const card of minimap.CARD_LAYOUTS) { + rect(frame, card.name, [0, 255, 0]); + rect(frame, card.weapon, [255, 0, 0]); + rect(frame, card.subTile, [0, 255, 255]); + for (const [cx, cy] of card.badges) + rect(frame, minimap.badgeRoi(cx, cy), [0, 128, 255]); + rect(frame, card.cross, [255, 0, 255]); + } + for (const cy of minimap.ENEMY_ROW_CYS) { + rect(frame, minimap.enemyWeaponRoi(cy), [255, 0, 0]); + rect(frame, minimap.enemySubTileRoi(cy), [0, 255, 255]); + for (const cx of minimap.ENEMY_BADGE_XS) + rect(frame, minimap.badgeRoi(cx, cy), [0, 128, 255]); + rect(frame, minimap.enemyCrossRoi(cy), [255, 0, 255]); + } + for (const roi of [ + minimap.GATE_CLOSE_BRIGHT, + minimap.GATE_SPAWN_BRIGHT, + ...minimap.GATE_CLOSE_DARK_PROBES, + ...minimap.GATE_SPAWN_DARK_PROBES, + ]) { + rect(frame, roi, [255, 255, 0]); + } } else { - console.error(`unknown detector "${detector}"`); - process.exit(1); + console.error(`unknown detector "${detector}"`); + process.exit(1); } writePng(outPath, matToFrameData(frame)); frame.delete(); -console.log(`wrote ${outPath}`); +console.info(`wrote ${outPath}`); diff --git a/scripts/cv/report.ts b/scripts/cv/report.ts index 4b9f3af1d..3106d13c8 100644 --- a/scripts/cv/report.ts +++ b/scripts/cv/report.ts @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/suspicious/noConsole: CLI script output */ /** * Accuracy report across all fixtures — separate from pass/fail testing. * Runs each detector over its own fixture directory and prints aggregate @@ -7,437 +8,506 @@ * Usage: pnpm cv:report */ import { loadOpenCV } from "../../app/features/cv/core/cv"; -import { createDeathDetector, type DeathData } from "../../app/features/cv/core/detectors/death/index"; import { - createMapStartDetector, - type MapStartData, + createDeathDetector, + type DeathData, +} from "../../app/features/cv/core/detectors/death/index"; +import { + createMapStartDetector, + type MapStartData, } from "../../app/features/cv/core/detectors/map-start/index"; -import { createMinimapDetector, type MinimapData } from "../../app/features/cv/core/detectors/minimap/index"; +import { + createMinimapDetector, + type MinimapData, +} from "../../app/features/cv/core/detectors/minimap/index"; import { createScoreboardDetector } from "../../app/features/cv/core/detectors/scoreboard/index"; import { - createScoreboardOwnDetector, - type ScoreboardOwnData, + createScoreboardOwnDetector, + type ScoreboardOwnData, } from "../../app/features/cv/core/detectors/scoreboard-own/index"; import { - createScoreboardReplayDetector, - type ScoreboardReplayData, + createScoreboardReplayDetector, + type ScoreboardReplayData, } from "../../app/features/cv/core/detectors/scoreboard-replay/index"; import type { Detector } from "../../app/features/cv/core/detectors/types"; -import { loadFixtures, runDetectorOnFixture } from "../../app/features/cv/node/fixtures"; +import { + loadFixtures, + runDetectorOnFixture, +} from "../../app/features/cv/node/fixtures"; import { loadScoreboardResources } from "../../app/features/cv/node/resources"; await loadOpenCV(); const resources = await loadScoreboardResources(); interface Config { - label: string; - detector: Detector>; - fixturesDir: string; - event: string; + label: string; + detector: Detector>; + fixturesDir: string; + event: string; } const configs: Config[] = [ - { - label: "scoreboard", - detector: createScoreboardDetector(resources), - fixturesDir: "scoreboard", - event: "Scoreboard", - }, - { - label: "scoreboard-replay", - detector: createScoreboardReplayDetector(resources), - fixturesDir: "scoreboard-replay", - event: "ScoreboardReplay", - }, + { + label: "scoreboard", + detector: createScoreboardDetector(resources), + fixturesDir: "scoreboard", + event: "Scoreboard", + }, + { + label: "scoreboard-replay", + detector: createScoreboardReplayDetector(resources), + fixturesDir: "scoreboard-replay", + event: "ScoreboardReplay", + }, ]; interface Tally { - ok: number; - total: number; + ok: number; + total: number; } /** Levenshtein distance for CER. */ function editDistance(a: string, b: string): number { - const dp = Array.from({ length: a.length + 1 }, (_, i) => { - const row = new Array(b.length + 1).fill(0); - row[0] = i; - return row; - }); - for (let j = 0; j <= b.length; j++) dp[0]![j] = j; - for (let i = 1; i <= a.length; i++) { - for (let j = 1; j <= b.length; j++) { - dp[i]![j] = Math.min( - dp[i - 1]![j]! + 1, - dp[i]![j - 1]! + 1, - dp[i - 1]![j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1), - ); - } - } - return dp[a.length]![b.length]!; + const dp = Array.from({ length: a.length + 1 }, (_, i) => { + const row = new Array(b.length + 1).fill(0); + row[0] = i; + return row; + }); + for (let j = 0; j <= b.length; j++) dp[0]![j] = j; + for (let i = 1; i <= a.length; i++) { + for (let j = 1; j <= b.length; j++) { + dp[i]![j] = Math.min( + dp[i - 1]![j]! + 1, + dp[i]![j - 1]! + 1, + dp[i - 1]![j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1), + ); + } + } + return dp[a.length]![b.length]!; } function pct(t: Tally): string { - if (t.total === 0) return " n/a"; - return `${((100 * t.ok) / t.total).toFixed(1).padStart(5)}% (${t.ok}/${t.total})`; + if (t.total === 0) return " n/a"; + return `${((100 * t.ok) / t.total).toFixed(1).padStart(5)}% (${t.ok}/${t.total})`; } for (const config of configs) { - // Shared negatives count toward gate accuracy (expectPositive is false). - const fixtures = [...loadFixtures(config.fixturesDir), ...loadFixtures("negative")]; + // Shared negatives count toward gate accuracy (expectPositive is false). + const fixtures = [ + ...loadFixtures(config.fixturesDir), + ...loadFixtures("negative"), + ]; - const tally = { - gate: { ok: 0, total: 0 } as Tally, - header: { ok: 0, total: 0 } as Tally, - timestamp: { ok: 0, total: 0 } as Tally, - replayCode: { ok: 0, total: 0 } as Tally, - scores: { ok: 0, total: 0 } as Tally, - matchScores: { ok: 0, total: 0 } as Tally, - weapons: { ok: 0, total: 0 } as Tally, - names: { ok: 0, total: 0 } as Tally, - paint: { ok: 0, total: 0 } as Tally, - stats: { ok: 0, total: 0 } as Tally, - }; - let charEdits = 0; - let charTotal = 0; - const nameMisses: string[] = []; + const tally = { + gate: { ok: 0, total: 0 } as Tally, + header: { ok: 0, total: 0 } as Tally, + timestamp: { ok: 0, total: 0 } as Tally, + replayCode: { ok: 0, total: 0 } as Tally, + scores: { ok: 0, total: 0 } as Tally, + matchScores: { ok: 0, total: 0 } as Tally, + weapons: { ok: 0, total: 0 } as Tally, + names: { ok: 0, total: 0 } as Tally, + paint: { ok: 0, total: 0 } as Tally, + stats: { ok: 0, total: 0 } as Tally, + }; + let charEdits = 0; + let charTotal = 0; + const nameMisses: string[] = []; - for (const fixture of fixtures) { - const { gate, events } = await runDetectorOnFixture(config.detector, fixture); - const expectPositive = fixture.expected.event === config.event; - tally.gate.total++; - if (gate.pass === expectPositive) tally.gate.ok++; - if (!expectPositive || !events[0]) continue; + for (const fixture of fixtures) { + const { gate, events } = await runDetectorOnFixture( + config.detector, + fixture, + ); + const expectPositive = fixture.expected.event === config.event; + 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.mode !== undefined) { - tally.header.total++; - if ( - event.data.lobby === (expected.lobby ?? null) && - event.data.mode === (expected.mode ?? null) && - event.data.stage === (expected.stage ?? null) - ) { - tally.header.ok++; - } - } - if (expected.timestamp !== undefined) { - tally.timestamp.total++; - if (event.data.timestamp === expected.timestamp) tally.timestamp.ok++; - } - if (expected.replayCode !== undefined) { - tally.replayCode.total++; - if (event.data.replayCode === expected.replayCode) tally.replayCode.ok++; - } - if (expected.scores) { - tally.scores.total++; - if (JSON.stringify(event.data.scores) === JSON.stringify(expected.scores)) tally.scores.ok++; - } - if (expected.matchScores) { - tally.matchScores.total++; - if (JSON.stringify(event.data.matchScores) === JSON.stringify(expected.matchScores)) { - tally.matchScores.ok++; - } - } - (expected.players ?? []).forEach((want, i) => { - const got = event.data.players?.[i]; - if (!got) return; - if (want.weaponId !== undefined) { - tally.weapons.total++; - if (got.weaponId === want.weaponId) tally.weapons.ok++; - } - if (want.name !== undefined) { - tally.names.total++; - const dist = editDistance(got.name, want.name); - charEdits += dist; - charTotal += want.name.length; - if (dist === 0) tally.names.ok++; - else nameMisses.push(`${fixture.name} row${i}: "${got.name}" != "${want.name}"`); - } - if (want.paint !== undefined) { - tally.paint.total++; - if (got.paint === want.paint) tally.paint.ok++; - } - if (want.ka !== undefined) { - tally.stats.total++; - if (got.ka === want.ka && got.d === (want.d ?? null) && got.s === (want.s ?? null)) { - tally.stats.ok++; - } - } - }); - } + const event = events[0]; + const expected = fixture.expected.data ?? {}; + if (expected.mode !== undefined) { + tally.header.total++; + if ( + event.data.lobby === (expected.lobby ?? null) && + event.data.mode === (expected.mode ?? null) && + event.data.stage === (expected.stage ?? null) + ) { + tally.header.ok++; + } + } + if (expected.timestamp !== undefined) { + tally.timestamp.total++; + if (event.data.timestamp === expected.timestamp) tally.timestamp.ok++; + } + if (expected.replayCode !== undefined) { + tally.replayCode.total++; + if (event.data.replayCode === expected.replayCode) tally.replayCode.ok++; + } + if (expected.scores) { + tally.scores.total++; + if (JSON.stringify(event.data.scores) === JSON.stringify(expected.scores)) + tally.scores.ok++; + } + if (expected.matchScores) { + tally.matchScores.total++; + if ( + JSON.stringify(event.data.matchScores) === + JSON.stringify(expected.matchScores) + ) { + tally.matchScores.ok++; + } + } + (expected.players ?? []).forEach((want, i) => { + const got = event.data.players?.[i]; + if (!got) return; + if (want.weaponId !== undefined) { + tally.weapons.total++; + if (got.weaponId === want.weaponId) tally.weapons.ok++; + } + if (want.name !== undefined) { + tally.names.total++; + const dist = editDistance(got.name, want.name); + charEdits += dist; + charTotal += want.name.length; + if (dist === 0) tally.names.ok++; + else + nameMisses.push( + `${fixture.name} row${i}: "${got.name}" != "${want.name}"`, + ); + } + if (want.paint !== undefined) { + tally.paint.total++; + if (got.paint === want.paint) tally.paint.ok++; + } + if (want.ka !== undefined) { + tally.stats.total++; + if ( + got.ka === want.ka && + got.d === (want.d ?? null) && + got.s === (want.s ?? null) + ) { + tally.stats.ok++; + } + } + }); + } - console.log(`\n=== ${config.label} (${fixtures.length} fixtures) ===`); - console.log(`gate ${pct(tally.gate)}`); - console.log(`header ${pct(tally.header)}`); - if (config.event === "ScoreboardReplay") { - console.log(`timestamp ${pct(tally.timestamp)}`); - console.log(`replayCode ${pct(tally.replayCode)}`); - } - console.log(`scores ${pct(tally.scores)}`); - if (config.event === "ScoreboardReplay") { - console.log(`matchScores ${pct(tally.matchScores)}`); - } - console.log(`weapons ${pct(tally.weapons)}`); - console.log(`names ${pct(tally.names)}`); - console.log(`paint ${pct(tally.paint)}`); - console.log(`stats ${pct(tally.stats)}`); - console.log( - `name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`, - ); - if (nameMisses.length > 0) { - console.log("name misses:"); - for (const m of nameMisses) console.log(` ${m}`); - } + console.info(`\n=== ${config.label} (${fixtures.length} fixtures) ===`); + console.info(`gate ${pct(tally.gate)}`); + console.info(`header ${pct(tally.header)}`); + if (config.event === "ScoreboardReplay") { + console.info(`timestamp ${pct(tally.timestamp)}`); + console.info(`replayCode ${pct(tally.replayCode)}`); + } + console.info(`scores ${pct(tally.scores)}`); + if (config.event === "ScoreboardReplay") { + console.info(`matchScores ${pct(tally.matchScores)}`); + } + console.info(`weapons ${pct(tally.weapons)}`); + console.info(`names ${pct(tally.names)}`); + console.info(`paint ${pct(tally.paint)}`); + console.info(`stats ${pct(tally.stats)}`); + console.info( + `name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`, + ); + if (nameMisses.length > 0) { + console.info("name misses:"); + for (const m of nameMisses) console.info(` ${m}`); + } } // Map-start fixtures only carry mode + stage, so they get their own pass. { - const detector = createMapStartDetector(resources); - const fixtures = loadFixtures("map-start"); - const tally = { - gate: { ok: 0, total: 0 } as Tally, - mode: { ok: 0, total: 0 } as Tally, - stage: { ok: 0, total: 0 } as Tally, - }; - const misses: string[] = []; + const detector = createMapStartDetector(resources); + const fixtures = loadFixtures("map-start"); + const tally = { + gate: { ok: 0, total: 0 } as Tally, + mode: { ok: 0, total: 0 } as Tally, + stage: { ok: 0, total: 0 } as Tally, + }; + const misses: string[] = []; - for (const fixture of fixtures) { - const { gate, events } = await runDetectorOnFixture(detector, fixture); - const expectPositive = fixture.expected.event === "MapStart"; - 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.mode !== undefined) { - tally.mode.total++; - if (event.data.mode === expected.mode) tally.mode.ok++; - else misses.push(`${fixture.name}: mode "${event.data.mode}" != "${expected.mode}"`); - } - if (expected.stage !== undefined) { - tally.stage.total++; - if (event.data.stage === expected.stage) tally.stage.ok++; - else misses.push(`${fixture.name}: stage "${event.data.stage}" != "${expected.stage}"`); - } - } + for (const fixture of fixtures) { + const { gate, events } = await runDetectorOnFixture( + detector, + fixture, + ); + const expectPositive = fixture.expected.event === "MapStart"; + 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.mode !== undefined) { + tally.mode.total++; + if (event.data.mode === expected.mode) tally.mode.ok++; + else + misses.push( + `${fixture.name}: mode "${event.data.mode}" != "${expected.mode}"`, + ); + } + if (expected.stage !== undefined) { + tally.stage.total++; + if (event.data.stage === expected.stage) tally.stage.ok++; + else + misses.push( + `${fixture.name}: stage "${event.data.stage}" != "${expected.stage}"`, + ); + } + } - console.log(`\n=== map-start (${fixtures.length} fixtures) ===`); - console.log(`gate ${pct(tally.gate)}`); - console.log(`mode ${pct(tally.mode)}`); - console.log(`stage ${pct(tally.stage)}`); - if (misses.length > 0) { - console.log("misses:"); - for (const m of misses) console.log(` ${m}`); - } + console.info(`\n=== map-start (${fixtures.length} fixtures) ===`); + console.info(`gate ${pct(tally.gate)}`); + console.info(`mode ${pct(tally.mode)}`); + console.info(`stage ${pct(tally.stage)}`); + if (misses.length > 0) { + console.info("misses:"); + for (const m of misses) console.info(` ${m}`); + } } // Scoreboard-own fixtures carry header + own weapon + ability grid. { - const detector = createScoreboardOwnDetector(resources); - const fixtures = [...loadFixtures("scoreboard-own"), ...loadFixtures("negative")]; - const tally = { - gate: { ok: 0, total: 0 } as Tally, - header: { ok: 0, total: 0 } as Tally, - weapon: { ok: 0, total: 0 } as Tally, - abilities: { ok: 0, total: 0 } as Tally, - }; - const misses: string[] = []; + const detector = createScoreboardOwnDetector(resources); + const fixtures = [ + ...loadFixtures("scoreboard-own"), + ...loadFixtures("negative"), + ]; + const tally = { + gate: { ok: 0, total: 0 } as Tally, + header: { ok: 0, total: 0 } as Tally, + weapon: { ok: 0, total: 0 } as Tally, + abilities: { ok: 0, total: 0 } as Tally, + }; + const misses: string[] = []; - for (const fixture of fixtures) { - const { gate, events } = await runDetectorOnFixture(detector, fixture); - const expectPositive = fixture.expected.event === "ScoreboardOwn"; - 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.mode !== undefined) { - tally.header.total++; - if ( - event.data.lobby === (expected.lobby ?? null) && - event.data.mode === (expected.mode ?? null) && - event.data.stage === (expected.stage ?? null) - ) { - tally.header.ok++; - } else { - misses.push( - `${fixture.name}: header "${event.data.lobby}/${event.data.mode}/${event.data.stage}"`, - ); - } - } - if (expected.weaponId !== undefined) { - tally.weapon.total++; - if (event.data.weaponId === expected.weaponId) tally.weapon.ok++; - else - misses.push( - `${fixture.name}: weapon ${event.data.weaponId} != ${expected.weaponId} ("${expected.weaponLabel ?? ""}")`, - ); - } - (expected.abilities ?? []).forEach((wantRow, row) => { - wantRow.forEach((want, slot) => { - tally.abilities.total++; - const got = event.data.abilities[row]?.[slot]; - if (got === want) tally.abilities.ok++; - else misses.push(`${fixture.name}: ability [${row}][${slot}] "${got}" != "${want}"`); - }); - }); - } + for (const fixture of fixtures) { + const { gate, events } = await runDetectorOnFixture( + detector, + fixture, + ); + const expectPositive = fixture.expected.event === "ScoreboardOwn"; + 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.mode !== undefined) { + tally.header.total++; + if ( + event.data.lobby === (expected.lobby ?? null) && + event.data.mode === (expected.mode ?? null) && + event.data.stage === (expected.stage ?? null) + ) { + tally.header.ok++; + } else { + misses.push( + `${fixture.name}: header "${event.data.lobby}/${event.data.mode}/${event.data.stage}"`, + ); + } + } + if (expected.weaponId !== undefined) { + tally.weapon.total++; + if (event.data.weaponId === expected.weaponId) tally.weapon.ok++; + else + misses.push( + `${fixture.name}: weapon ${event.data.weaponId} != ${expected.weaponId} ("${expected.weaponLabel ?? ""}")`, + ); + } + (expected.abilities ?? []).forEach((wantRow, row) => { + wantRow.forEach((want, slot) => { + tally.abilities.total++; + const got = event.data.abilities[row]?.[slot]; + if (got === want) tally.abilities.ok++; + else + misses.push( + `${fixture.name}: ability [${row}][${slot}] "${got}" != "${want}"`, + ); + }); + }); + } - console.log(`\n=== scoreboard-own (${fixtures.length} fixtures) ===`); - console.log(`gate ${pct(tally.gate)}`); - console.log(`header ${pct(tally.header)}`); - console.log(`weapon ${pct(tally.weapon)}`); - console.log(`abilities ${pct(tally.abilities)}`); - if (misses.length > 0) { - console.log("misses:"); - for (const m of misses) console.log(` ${m}`); - } + console.info(`\n=== scoreboard-own (${fixtures.length} fixtures) ===`); + console.info(`gate ${pct(tally.gate)}`); + console.info(`header ${pct(tally.header)}`); + console.info(`weapon ${pct(tally.weapon)}`); + console.info(`abilities ${pct(tally.abilities)}`); + if (misses.length > 0) { + console.info("misses:"); + for (const m of misses) console.info(` ${m}`); + } } // Minimap fixtures carry teammates/enemies, so they get their own pass. { - const detector = createMinimapDetector(resources); - const fixtures = [...loadFixtures("minimap"), ...loadFixtures("negative")]; - const tally = { - gate: { ok: 0, total: 0 } as Tally, - weapons: { ok: 0, total: 0 } as Tally, - names: { ok: 0, total: 0 } as Tally, - abilities: { ok: 0, total: 0 } as Tally, - stage: { ok: 0, total: 0 } as Tally, - }; - let charEdits = 0; - let charTotal = 0; - const misses: string[] = []; + const detector = createMinimapDetector(resources); + const fixtures = [...loadFixtures("minimap"), ...loadFixtures("negative")]; + const tally = { + gate: { ok: 0, total: 0 } as Tally, + weapons: { ok: 0, total: 0 } as Tally, + names: { ok: 0, total: 0 } as Tally, + abilities: { ok: 0, total: 0 } as Tally, + stage: { ok: 0, total: 0 } as Tally, + }; + let charEdits = 0; + let charTotal = 0; + const misses: string[] = []; - for (const fixture of fixtures) { - const { gate, events } = await runDetectorOnFixture(detector, fixture); - const expectPositive = fixture.expected.event === "Minimap"; - tally.gate.total++; - if (gate.pass === expectPositive) tally.gate.ok++; - if (!expectPositive || !events[0]) continue; - const event = events[0]; - const expected = fixture.expected.data ?? {}; + for (const fixture of fixtures) { + const { gate, events } = await runDetectorOnFixture( + detector, + fixture, + ); + const expectPositive = fixture.expected.event === "Minimap"; + tally.gate.total++; + if (gate.pass === expectPositive) tally.gate.ok++; + if (!expectPositive || !events[0]) continue; + const event = events[0]; + const expected = fixture.expected.data ?? {}; - const sides: [ - string, - { name?: string | null; weaponId?: number | null; abilities?: (string | null)[] }[], - { name?: string | null; weaponId: number | null; abilities: (string | null)[] }[], - ][] = [ - ["teammate", expected.teammates ?? [], event.data.teammates], - ["enemy", expected.enemies ?? [], event.data.enemies], - ]; - for (const [side, wants, gots] of sides) { - wants.forEach((want, i) => { - const got = gots[i]; - if (!got) return; - if (want.weaponId !== undefined) { - tally.weapons.total++; - if (got.weaponId === want.weaponId) tally.weapons.ok++; - else - misses.push( - `${fixture.name} ${side}${i}: weapon ${got.weaponId} != ${want.weaponId}`, - ); - } - if (want.name !== undefined && want.name !== null) { - tally.names.total++; - const gotName = got.name ?? ""; - const dist = editDistance(gotName, want.name); - charEdits += dist; - charTotal += want.name.length; - if (dist === 0) tally.names.ok++; - else misses.push(`${fixture.name} ${side}${i}: name "${gotName}" != "${want.name}"`); - } - (want.abilities ?? []).forEach((wantId, slot) => { - tally.abilities.total++; - const gotId = got.abilities[slot] ?? null; - if (gotId === wantId) tally.abilities.ok++; - else - misses.push(`${fixture.name} ${side}${i}: ability [${slot}] "${gotId}" != "${wantId}"`); - }); - }); - } - if (expected.stage !== undefined) { - tally.stage.total++; - if (event.data.stage === expected.stage) tally.stage.ok++; - else misses.push(`${fixture.name}: stage "${event.data.stage}" != "${expected.stage}"`); - } - } + const sides: [ + string, + { + name?: string | null; + weaponId?: number | null; + abilities?: (string | null)[]; + }[], + { + name?: string | null; + weaponId: number | null; + abilities: (string | null)[]; + }[], + ][] = [ + ["teammate", expected.teammates ?? [], event.data.teammates], + ["enemy", expected.enemies ?? [], event.data.enemies], + ]; + for (const [side, wants, gots] of sides) { + wants.forEach((want, i) => { + const got = gots[i]; + if (!got) return; + if (want.weaponId !== undefined) { + tally.weapons.total++; + if (got.weaponId === want.weaponId) tally.weapons.ok++; + else + misses.push( + `${fixture.name} ${side}${i}: weapon ${got.weaponId} != ${want.weaponId}`, + ); + } + if (want.name !== undefined && want.name !== null) { + tally.names.total++; + const gotName = got.name ?? ""; + const dist = editDistance(gotName, want.name); + charEdits += dist; + charTotal += want.name.length; + if (dist === 0) tally.names.ok++; + else + misses.push( + `${fixture.name} ${side}${i}: name "${gotName}" != "${want.name}"`, + ); + } + (want.abilities ?? []).forEach((wantId, slot) => { + tally.abilities.total++; + const gotId = got.abilities[slot] ?? null; + if (gotId === wantId) tally.abilities.ok++; + else + misses.push( + `${fixture.name} ${side}${i}: ability [${slot}] "${gotId}" != "${wantId}"`, + ); + }); + }); + } + if (expected.stage !== undefined) { + tally.stage.total++; + if (event.data.stage === expected.stage) tally.stage.ok++; + else + misses.push( + `${fixture.name}: stage "${event.data.stage}" != "${expected.stage}"`, + ); + } + } - console.log(`\n=== minimap (${fixtures.length} fixtures) ===`); - console.log(`gate ${pct(tally.gate)}`); - console.log(`weapons ${pct(tally.weapons)}`); - console.log(`names ${pct(tally.names)}`); - console.log(`abilities ${pct(tally.abilities)}`); - console.log(`stage ${pct(tally.stage)}`); - console.log( - `name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`, - ); - if (misses.length > 0) { - console.log("misses:"); - for (const m of misses) console.log(` ${m}`); - } + console.info(`\n=== minimap (${fixtures.length} fixtures) ===`); + console.info(`gate ${pct(tally.gate)}`); + console.info(`weapons ${pct(tally.weapons)}`); + console.info(`names ${pct(tally.names)}`); + console.info(`abilities ${pct(tally.abilities)}`); + console.info(`stage ${pct(tally.stage)}`); + 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}`); + } } // Death fixtures carry a different data shape (weapon text, ability grid, // splash-tag name), so they get their own pass instead of a Config entry. { - const detector = createDeathDetector(resources); - const fixtures = loadFixtures("death"); - const tally = { - gate: { ok: 0, total: 0 } as Tally, - weapon: { ok: 0, total: 0 } as Tally, - abilities: { ok: 0, total: 0 } as Tally, - names: { ok: 0, total: 0 } as Tally, - }; - let charEdits = 0; - let charTotal = 0; - const misses: string[] = []; + const detector = createDeathDetector(resources); + const fixtures = loadFixtures("death"); + const tally = { + gate: { ok: 0, total: 0 } as Tally, + weapon: { ok: 0, total: 0 } as Tally, + abilities: { ok: 0, total: 0 } as Tally, + names: { ok: 0, total: 0 } as Tally, + }; + let charEdits = 0; + let charTotal = 0; + const misses: string[] = []; - for (const fixture of fixtures) { - const { gate, events } = await runDetectorOnFixture(detector, fixture); - const expectPositive = fixture.expected.event === "Death"; - 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.weaponId !== undefined) { - tally.weapon.total++; - if (event.data.weaponId === expected.weaponId) tally.weapon.ok++; - else - misses.push( - `${fixture.name}: weapon ${event.data.weaponId} != ${expected.weaponId} ("${expected.weaponLabel ?? ""}")`, - ); - } - (expected.abilities ?? []).forEach((wantRow, row) => { - wantRow.forEach((want, slot) => { - tally.abilities.total++; - const got = event.data.abilities[row]?.[slot]; - if (got === want) tally.abilities.ok++; - else misses.push(`${fixture.name}: ability [${row}][${slot}] "${got}" != "${want}"`); - }); - }); - if (expected.name !== undefined) { - tally.names.total++; - const got = event.data.name ?? ""; - const dist = editDistance(got, expected.name); - charEdits += dist; - charTotal += expected.name.length; - if (dist === 0) tally.names.ok++; - else misses.push(`${fixture.name}: name "${got}" != "${expected.name}"`); - } - } + for (const fixture of fixtures) { + const { gate, events } = await runDetectorOnFixture( + detector, + fixture, + ); + const expectPositive = fixture.expected.event === "Death"; + 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.weaponId !== undefined) { + tally.weapon.total++; + if (event.data.weaponId === expected.weaponId) tally.weapon.ok++; + else + misses.push( + `${fixture.name}: weapon ${event.data.weaponId} != ${expected.weaponId} ("${expected.weaponLabel ?? ""}")`, + ); + } + (expected.abilities ?? []).forEach((wantRow, row) => { + wantRow.forEach((want, slot) => { + tally.abilities.total++; + const got = event.data.abilities[row]?.[slot]; + if (got === want) tally.abilities.ok++; + else + misses.push( + `${fixture.name}: ability [${row}][${slot}] "${got}" != "${want}"`, + ); + }); + }); + if (expected.name !== undefined) { + tally.names.total++; + const got = event.data.name ?? ""; + const dist = editDistance(got, expected.name); + charEdits += dist; + charTotal += expected.name.length; + if (dist === 0) tally.names.ok++; + else misses.push(`${fixture.name}: name "${got}" != "${expected.name}"`); + } + } - console.log(`\n=== death (${fixtures.length} fixtures) ===`); - console.log(`gate ${pct(tally.gate)}`); - console.log(`weapon ${pct(tally.weapon)}`); - console.log(`abilities ${pct(tally.abilities)}`); - console.log(`names ${pct(tally.names)}`); - console.log( - `name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`, - ); - if (misses.length > 0) { - console.log("misses:"); - for (const m of misses) console.log(` ${m}`); - } + console.info(`\n=== death (${fixtures.length} fixtures) ===`); + console.info(`gate ${pct(tally.gate)}`); + console.info(`weapon ${pct(tally.weapon)}`); + console.info(`abilities ${pct(tally.abilities)}`); + 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}`); + } } diff --git a/scripts/cv/run-fixtures.ts b/scripts/cv/run-fixtures.ts index bbd483fb6..3f6414035 100644 --- a/scripts/cv/run-fixtures.ts +++ b/scripts/cv/run-fixtures.ts @@ -1,3 +1,4 @@ +/** biome-ignore-all lint/suspicious/noConsole: CLI script output */ /** * CLI harness: run the ScoreboardDetector over fixtures and print everything * it saw — gate result, parsed fields, per-field scores, top weapon candidates. @@ -6,10 +7,13 @@ */ import { loadOpenCV } from "../../app/features/cv/core/cv"; import { - createScoreboardDetector, - type ScoreboardRowDebug, + createScoreboardDetector, + type ScoreboardRowDebug, } from "../../app/features/cv/core/detectors/scoreboard/index"; -import { loadFixtures, runDetectorOnFixture } from "../../app/features/cv/node/fixtures"; +import { + loadFixtures, + runDetectorOnFixture, +} from "../../app/features/cv/node/fixtures"; import { loadScoreboardResources } from "../../app/features/cv/node/resources"; const filter = process.argv[2]; @@ -17,28 +21,32 @@ const filter = process.argv[2]; await loadOpenCV(); const detector = createScoreboardDetector(await loadScoreboardResources()); -const fixtures = loadFixtures("scoreboard").filter((f) => !filter || f.name.includes(filter)); +const fixtures = loadFixtures("scoreboard").filter( + (f) => !filter || f.name.includes(filter), +); if (fixtures.length === 0) { - console.error("no fixtures matched"); - process.exit(1); + console.error("no fixtures matched"); + process.exit(1); } for (const fixture of fixtures) { - const { gate, events } = await runDetectorOnFixture(detector, fixture); - console.log(`\n=== ${fixture.name}`); - console.log(`gate: pass=${gate.pass} score=${gate.score.toFixed(3)}`); - for (const event of events) { - console.log(`event confidence=${event.confidence.toFixed(3)}`); - console.log(`scores: ${JSON.stringify(event.data.scores)}`); - const rows = (event.debug?.rows ?? []) as ScoreboardRowDebug[]; - event.data.players.forEach((p, i) => { - const dbg = rows[i]; - const top = dbg?.weapon?.top.map((t) => `${t.id}:${t.score.toFixed(2)}`).join(" "); - console.log( - ` row${i}: name="${p.name}"(${dbg?.nameScore.toFixed(2)}) ` + - `weapon=${p.weaponId} [${top}] paint=${p.paint}(${dbg?.paintScore.toFixed(2)}) ` + - `ka=${p.ka} d=${p.d} s=${p.s} statScores=${dbg?.statScores.map((s) => s.toFixed(2)).join(",")}`, - ); - }); - } + const { gate, events } = await runDetectorOnFixture(detector, fixture); + console.info(`\n=== ${fixture.name}`); + console.info(`gate: pass=${gate.pass} score=${gate.score.toFixed(3)}`); + for (const event of events) { + console.info(`event confidence=${event.confidence.toFixed(3)}`); + console.info(`scores: ${JSON.stringify(event.data.scores)}`); + const rows = (event.debug?.rows ?? []) as ScoreboardRowDebug[]; + event.data.players.forEach((p, i) => { + const dbg = rows[i]; + const top = dbg?.weapon?.top + .map((t) => `${t.id}:${t.score.toFixed(2)}`) + .join(" "); + console.info( + ` row${i}: name="${p.name}"(${dbg?.nameScore.toFixed(2)}) ` + + `weapon=${p.weaponId} [${top}] paint=${p.paint}(${dbg?.paintScore.toFixed(2)}) ` + + `ka=${p.ka} d=${p.d} s=${p.s} statScores=${dbg?.statScores.map((s) => s.toFixed(2)).join(",")}`, + ); + }); + } }