mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-22 11:05:09 -05:00
Rename cv -> scanner
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,5 +35,5 @@ dump
|
||||
|
||||
notepad.txt
|
||||
|
||||
# proprietary game fonts for the CV glyph-atlas builders (scripts/cv)
|
||||
# proprietary game fonts for the scanner glyph-atlas builders (scripts/scanner)
|
||||
/assets/fonts/
|
||||
|
||||
@@ -98,10 +98,10 @@
|
||||
- use the template `/github/pull_request_template.md`
|
||||
- do not mention claude or claude code in the description
|
||||
|
||||
## CV feature (app/features/cv)
|
||||
## Scanner feature (app/features/scanner)
|
||||
|
||||
- computer-vision match-event detection; full docs in `app/features/cv/README.md` — read it before touching detector/recognition code
|
||||
- computer-vision match-event detection; full docs in `app/features/scanner/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
|
||||
- fixture workflow: every live misread becomes a fixture under `app/features/scanner/tests/fixtures/`; ground-truth labels are hand-corrected by the maintainer and definitive over any matcher output
|
||||
- test with `pnpm test:scanner`; accuracy report with `pnpm scanner: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
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
|
||||
export const CV_TABS = ["live", "screenshot", "vod"] as const;
|
||||
|
||||
export type CvTab = (typeof CV_TABS)[number];
|
||||
|
||||
export const cvSearchParams = SearchParams.define({
|
||||
tab: SP.param(z.enum(CV_TABS), { default: "live", loader: false }),
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CvAbility, CvLobby } from "~/features/cv/cv-types";
|
||||
import type {
|
||||
ScannerAbility,
|
||||
ScannerLobby,
|
||||
} from "~/features/scanner/scanner-types";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
ModeShort,
|
||||
@@ -43,10 +46,10 @@ function testScoreboard({
|
||||
t?: number;
|
||||
mode?: ModeShort | null;
|
||||
stage?: StageId | null;
|
||||
lobby?: CvLobby | null;
|
||||
lobby?: ScannerLobby | null;
|
||||
names?: string[];
|
||||
weapons?: (MainWeaponId | null)[];
|
||||
abilities?: Record<number, CvAbility[][]>;
|
||||
abilities?: Record<number, ScannerAbility[][]>;
|
||||
povIndex?: number | null;
|
||||
} = {}): IngestedEventInput {
|
||||
return {
|
||||
@@ -103,7 +106,7 @@ describe("matchedScoreboards", () => {
|
||||
});
|
||||
|
||||
it("carries ingested player abilities through to the stored scoreboard", () => {
|
||||
const build: CvAbility[][] = [
|
||||
const build: ScannerAbility[][] = [
|
||||
["ISM", "ISS", "ISS", "ISS"],
|
||||
["QR", "QSJ", "QSJ", "QSJ"],
|
||||
["SSU", "RSU", "RSU", "RSU"],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CvAbility } from "~/features/cv/cv-types";
|
||||
import type { ScannerAbility } from "~/features/scanner/scanner-types";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
ModeShort,
|
||||
@@ -70,7 +70,7 @@ export interface IngestedScoreboardPlayer {
|
||||
s: number | null;
|
||||
paint: number | null;
|
||||
/** [head, clothes, shoes] ability rows gathered from the match's death screens */
|
||||
abilities?: CvAbility[][];
|
||||
abilities?: ScannerAbility[][];
|
||||
/** set only via povIndex attribution */
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
@@ -61,9 +61,9 @@ describe("prefillVodMatches", () => {
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts the `ingest` search param the CV VoD tab sends", () => {
|
||||
// what the CV VoD tab's "Upload as VoD" button puts in the URL
|
||||
// (~/features/cv/components/sendou-upload.ts): a { type?, matches }
|
||||
it("accepts the `ingest` search param the scanner VoD tab sends", () => {
|
||||
// what the scanner VoD tab's "Upload as VoD" button puts in the URL
|
||||
// (~/features/scanner/components/sendou-upload.ts): a { type?, matches }
|
||||
// payload in the compressed `ingest` param
|
||||
const href = vodsNewSearchParams.href("/vods/new", {
|
||||
ingest: { type: "CAST", matches: [testMatch()] },
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface PrefillVodMatch {
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the per-match rows a CV VoD scan sends into prefill data for the
|
||||
* Turns the per-match rows a scanner VoD scan sends into prefill data for the
|
||||
* /vods/new form. The rows already carry sendou ids (validated by
|
||||
* ingestVodPrefillSchema); this only renames fields into the form's shape.
|
||||
*/
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
cvAbilitySchema,
|
||||
cvDeathDataSchema,
|
||||
cvMapStartDataSchema,
|
||||
cvScoreboardDataSchema,
|
||||
cvScoreboardPlayerSchema,
|
||||
cvScoreboardReplayDataSchema,
|
||||
} from "~/features/cv/cv-schemas";
|
||||
scannerAbilitySchema,
|
||||
scannerDeathDataSchema,
|
||||
scannerMapStartDataSchema,
|
||||
scannerScoreboardDataSchema,
|
||||
scannerScoreboardPlayerSchema,
|
||||
scannerScoreboardReplayDataSchema,
|
||||
} from "~/features/scanner/scanner-schemas";
|
||||
import { id } from "~/utils/zod";
|
||||
|
||||
const INGEST_MAX_EVENTS_PER_REQUEST = 1000;
|
||||
|
||||
/**
|
||||
* The event data shapes come from the producer (~/features/cv/cv-schemas —
|
||||
* the single source of truth for the CV events domain); this module only
|
||||
* The event data shapes come from the producer (~/features/scanner/scanner-schemas —
|
||||
* the single source of truth for the scanner events domain); this module only
|
||||
* adds the ingest-specific envelope and enrichments.
|
||||
*/
|
||||
|
||||
/** [head, clothes, shoes] ability rows gathered from the match's death screens */
|
||||
const scoreboardPlayerSchema = cvScoreboardPlayerSchema.extend({
|
||||
abilities: z.array(z.array(cvAbilitySchema)).optional(),
|
||||
const scoreboardPlayerSchema = scannerScoreboardPlayerSchema.extend({
|
||||
abilities: z.array(z.array(scannerAbilitySchema)).optional(),
|
||||
});
|
||||
|
||||
const scoreboardDataSchema = cvScoreboardDataSchema.extend({
|
||||
const scoreboardDataSchema = scannerScoreboardDataSchema.extend({
|
||||
players: z.array(scoreboardPlayerSchema).length(8),
|
||||
});
|
||||
|
||||
const scoreboardReplayDataSchema = cvScoreboardReplayDataSchema.extend({
|
||||
const scoreboardReplayDataSchema = scannerScoreboardReplayDataSchema.extend({
|
||||
players: z.array(scoreboardPlayerSchema).length(8),
|
||||
});
|
||||
|
||||
@@ -54,11 +54,11 @@ const ingestedEventSchema = z.discriminatedUnion("type", [
|
||||
}),
|
||||
eventBaseSchema.extend({
|
||||
type: z.literal("Death"),
|
||||
data: cvDeathDataSchema,
|
||||
data: scannerDeathDataSchema,
|
||||
}),
|
||||
eventBaseSchema.extend({
|
||||
type: z.literal("MapStart"),
|
||||
data: cvMapStartDataSchema,
|
||||
data: scannerMapStartDataSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@ import {
|
||||
mainWeaponIdSchema,
|
||||
modeShortSchema,
|
||||
stageIdSchema,
|
||||
} from "~/features/cv/cv-schemas";
|
||||
} from "~/features/scanner/scanner-schemas";
|
||||
import { videoMatchTypes } from "~/features/vods/vods-constants";
|
||||
|
||||
/** One detected match of a CV VoD scan (~/features/cv/core/vod-matches.ts). */
|
||||
/** One detected match of a scanner VoD scan (~/features/scanner/core/vod-matches.ts). */
|
||||
const ingestVodMatchSchema = z.object({
|
||||
/** whole seconds into the video the match starts at */
|
||||
startsAt: z.number().int().min(0),
|
||||
@@ -25,7 +25,7 @@ const ingestVodMatchSchema = z.object({
|
||||
});
|
||||
|
||||
/**
|
||||
* The CV VoD tab's "Upload as VoD" button packs this into /vods/new's
|
||||
* The scanner VoD tab's "Upload as VoD" button packs this into /vods/new's
|
||||
* `ingest` search param (an `SP.json` param, compressed by the search-params
|
||||
* module) to prefill the form: the detected match rows, minus the submission
|
||||
* fields (YouTube URL, title, date) the user fills in the form. `type` is
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CV — Splatoon match-event detection
|
||||
# Scanner — Splatoon match-event detection
|
||||
|
||||
Browser app (route `/cv`, dev-only until promoted) that watches OBS Virtual
|
||||
Browser app (route `/scanner`, 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
|
||||
@@ -10,16 +10,16 @@ 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
|
||||
pnpm test:scanner # golden-file suite over tests/fixtures/ (Vitest, Node)
|
||||
pnpm scanner:report # accuracy table + name character error rate across fixtures
|
||||
pnpm scanner:fixtures [name-substring] # run detectors over matching fixtures, verbose
|
||||
pnpm scanner:bootstrap-atlas # harvest labeled fixture crops into the glyph atlases
|
||||
pnpm scanner:build-glyph-atlas # add the font-rendered charset (fonts required, see below)
|
||||
pnpm scanner:build-localized-entries # regen localized closed sets from ../splat3
|
||||
pnpm scanner: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
|
||||
The scanner scripts run through `vite-node -c scripts/scanner/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
|
||||
@@ -39,13 +39,13 @@ video file → capture/vod-frames (WebCodecs decode, seek fallback) [VoD tab]
|
||||
```
|
||||
|
||||
- `core/` is pure (mats in, events out) and must stay runnable in three
|
||||
contexts: the worker, the `/cv` Screenshot tab, and Node tests. Keep
|
||||
contexts: the worker, the `/scanner` 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`;
|
||||
not (schemas live in `scanner-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
|
||||
- The route (`routes/scanner.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.
|
||||
@@ -66,16 +66,16 @@ video file → capture/vod-frames (WebCodecs decode, seek fallback) [VoD tab]
|
||||
- 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.
|
||||
`scanner-schemas.ts` by compile-time asserts — extend both together.
|
||||
|
||||
## Assets (CDN) and fonts
|
||||
|
||||
Weapon/ability/special/sub template sources are the site's shared game
|
||||
icons in the **sendou-ink/assets repo** under `assets/img/**` (`.avif`; ids
|
||||
come from `~/modules/in-game-lists`, plus the CV-only `UNKNOWN` ability
|
||||
badge — `toCvAbility` narrows template ids back to sendou ids). The
|
||||
CV-specific sets — glyph atlases and the planner signature atlas — live in
|
||||
this repo under `public/cv/v1/**` (override with `CV_ASSETS_DIR`; the
|
||||
come from `~/modules/in-game-lists`, plus the scanner-only `UNKNOWN` ability
|
||||
badge — `toScannerAbility` narrows template ids back to sendou ids). The
|
||||
scanner-specific sets — glyph atlases and the planner signature atlas — live in
|
||||
this repo under `public/scanner/v1/**` (override with `SCANNER_ASSETS_DIR`; the
|
||||
version segment bumps on breaking atlas-format changes). xxx: the atlases
|
||||
are in `public/` only while the feature is in development — move them to
|
||||
the assets repo (and the worker back to the CDN base) later:
|
||||
@@ -84,15 +84,15 @@ the assets repo (and the worker back to the CDN base) later:
|
||||
(the base URL rides the worker init message; the DO Space needs CORS —
|
||||
GET, sendou.ink + localhost origins — because the worker `fetch()`es
|
||||
cross-origin, plain `<img>` consumers don't); atlases fetched same-origin
|
||||
from `/cv/v1/**`. For local dev against fresh icon regens, serve the
|
||||
from `/scanner/v1/**`. For local dev against fresh icon 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`.
|
||||
- Node (tests/scripts): atlases from `public/cv/v1`, icons from the
|
||||
- Node (tests/scripts): atlases from `public/scanner/v1`, icons from the
|
||||
`../assets` checkout directly, never the CDN. AVIF icons decode through
|
||||
`sharp` (`node/image-io.ts`) — `@napi-rs/canvas` mis-decodes AVIF
|
||||
partial-alpha pixels.
|
||||
- Atlas regens overwrite `public/cv/v1` in place and ship with the app
|
||||
- Atlas regens overwrite `public/scanner/v1` in place and ship with the app
|
||||
build; breaking format changes bump `v1`.
|
||||
|
||||
Fonts are proprietary and gitignored: `BlitzMain.otf`, `BlitzBold.otf`,
|
||||
@@ -101,10 +101,10 @@ 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`
|
||||
order: `scanner:bootstrap-atlas` (fixture crops win via tie-break) →
|
||||
`scanner:build-glyph-atlas`; localized sets via `scanner: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
|
||||
atlas via `scanner:build-planner-signatures` (reads the assets repo's
|
||||
`assets/planner-maps/`, MINI variant).
|
||||
|
||||
## Fixtures are the workflow
|
||||
@@ -5,11 +5,11 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Ability } from "~/components/Ability";
|
||||
import type { CvAbility } from "../cv-types";
|
||||
import type { ScannerAbility } from "../scanner-types";
|
||||
|
||||
const ROW_LABELS = ["head", "clothes", "shoes"] as const;
|
||||
|
||||
export function AbilityGrid({ abilities }: { abilities: CvAbility[][] }) {
|
||||
export function AbilityGrid({ abilities }: { abilities: ScannerAbility[][] }) {
|
||||
return (
|
||||
<table className="players">
|
||||
<tbody>
|
||||
@@ -32,7 +32,11 @@ export function AbilityGrid({ abilities }: { abilities: CvAbility[][] }) {
|
||||
* Click-to-toggle popover showing a player's ability grid; the trigger is
|
||||
* the head-main ability icon. Closes when the pointer leaves it.
|
||||
*/
|
||||
export function AbilityPopover({ abilities }: { abilities: CvAbility[][] }) {
|
||||
export function AbilityPopover({
|
||||
abilities,
|
||||
}: {
|
||||
abilities: ScannerAbility[][];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const trigger = abilities[0]?.[0];
|
||||
if (!trigger) return null;
|
||||
@@ -1,22 +1,26 @@
|
||||
import { Link } from "react-router";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useSearchParam } from "~/modules/search-params/hooks";
|
||||
import { CV_PAGE } from "~/utils/urls";
|
||||
import { CV_TABS, type CvTab, cvSearchParams } from "../cv-search-params";
|
||||
import { SCANNER_PAGE } from "~/utils/urls";
|
||||
import {
|
||||
SCANNER_TABS,
|
||||
type ScannerTab,
|
||||
scannerSearchParams,
|
||||
} from "../scanner-search-params";
|
||||
import { LivePage } from "./LivePage";
|
||||
import { ScreenshotPage } from "./ScreenshotPage";
|
||||
import type { SendouUser } from "./sendou-ingest";
|
||||
import { VodPage } from "./VodPage";
|
||||
import "./styles.css";
|
||||
|
||||
const TAB_LABELS: Record<CvTab, string> = {
|
||||
const TAB_LABELS: Record<ScannerTab, string> = {
|
||||
live: "Live",
|
||||
screenshot: "Screenshot",
|
||||
vod: "VoD",
|
||||
};
|
||||
|
||||
export function App() {
|
||||
const [tab] = useSearchParam(cvSearchParams, "tab");
|
||||
const [tab] = useSearchParam(scannerSearchParams, "tab");
|
||||
const rootUser = useUser();
|
||||
const sendouUser: SendouUser | null = rootUser
|
||||
? { id: rootUser.id, username: rootUser.username }
|
||||
@@ -32,13 +36,13 @@ export function App() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="cv-app">
|
||||
<div className="scanner-app">
|
||||
<header className="topbar">
|
||||
<nav>
|
||||
{CV_TABS.map((tabOption) => (
|
||||
{SCANNER_TABS.map((tabOption) => (
|
||||
<Link
|
||||
key={tabOption}
|
||||
to={cvSearchParams.href(CV_PAGE, { tab: tabOption })}
|
||||
to={scannerSearchParams.href(SCANNER_PAGE, { tab: tabOption })}
|
||||
className={tab === tabOption ? "active" : ""}
|
||||
>
|
||||
{TAB_LABELS[tabOption]}
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
SCOREBOARD_OWN_EVENT_TYPE,
|
||||
type ScoreboardOwnData,
|
||||
} from "../core/detectors/scoreboard-own/index";
|
||||
import { cvSearchParams } from "../cv-search-params";
|
||||
import { scannerSearchParams } from "../scanner-search-params";
|
||||
import type { SendStatus } from "../store/events";
|
||||
import { DeathCard } from "./DeathCard";
|
||||
import type { FixtureData } from "./fixture-export";
|
||||
@@ -54,7 +54,7 @@ export function EventCard(props: {
|
||||
onSend?: () => void;
|
||||
}) {
|
||||
const { type, t, confidence, data, thumbnail, detectedAt, getFrame } = props;
|
||||
const [, setTab] = useSearchParam(cvSearchParams, "tab");
|
||||
const [, setTab] = useSearchParam(scannerSearchParams, "tab");
|
||||
const onInspect = getFrame
|
||||
? () =>
|
||||
void getFrame().then((frame) => {
|
||||
@@ -5,12 +5,12 @@ import type {
|
||||
MinimapEnemy,
|
||||
MinimapTeammate,
|
||||
} from "../core/detectors/minimap/index";
|
||||
import type { CvAbility } from "../cv-types";
|
||||
import type { ScannerAbility } from "../scanner-types";
|
||||
import { saveFixtureFromEvent } from "./fixture-export";
|
||||
import { formatTime } from "./format";
|
||||
import { stageLabel } from "./labels";
|
||||
|
||||
function AbilityRow({ abilities }: { abilities: (CvAbility | null)[] }) {
|
||||
function AbilityRow({ abilities }: { abilities: (ScannerAbility | null)[] }) {
|
||||
return (
|
||||
<>
|
||||
{abilities.map((id, i) =>
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* English display labels for the ids CV events carry. UI-only: events and
|
||||
* detectors speak sendou ids (§ cv-types.ts); these helpers turn them back
|
||||
* English display labels for the ids scanner events carry. UI-only: events and
|
||||
* detectors speak sendou ids (§ scanner-types.ts); these helpers turn them back
|
||||
* into human-readable names for cards, CSV export, and the fixture
|
||||
* exporter's informational *Label fields.
|
||||
*/
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
ALL_WEAPON_ENTRIES,
|
||||
type WeaponType,
|
||||
} from "../core/detectors/death/weapon-names";
|
||||
import type { CvLobby } from "../cv-types";
|
||||
import type { ScannerLobby } from "../scanner-types";
|
||||
|
||||
const misc = gameMisc as Record<string, string>;
|
||||
|
||||
@@ -47,13 +47,13 @@ export function modeLabel(mode: ModeShort | null): string | null {
|
||||
return mode === null ? null : (misc[`MODE_LONG_${mode}`] ?? mode);
|
||||
}
|
||||
|
||||
const LOBBY_LABELS: Record<CvLobby, string> = {
|
||||
const LOBBY_LABELS: Record<ScannerLobby, string> = {
|
||||
X: "X Battle",
|
||||
SERIES: "Anarchy Battle (Series)",
|
||||
OPEN: "Anarchy Battle (Open)",
|
||||
PRIVATE: "Private Battle",
|
||||
};
|
||||
|
||||
export function lobbyLabel(lobby: CvLobby | null): string | null {
|
||||
export function lobbyLabel(lobby: ScannerLobby | null): string | null {
|
||||
return lobby === null ? null : LOBBY_LABELS[lobby];
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Browser client for sendou.ink's /ingest. The CV pages run inside
|
||||
* Browser client for sendou.ink's /ingest. The scanner pages run inside
|
||||
* sendou.ink itself, so requests are same-origin: the session cookie rides
|
||||
* along automatically and the logged-in user comes from the root loader
|
||||
* (useUser) instead of an identity probe. sendou.ink authenticates the
|
||||
@@ -1,9 +1,9 @@
|
||||
/*
|
||||
CV feature styles, ported from emberz. Scoped under .cv-app so nothing
|
||||
Scanner feature styles, ported from emberz. Scoped under .scanner-app so nothing
|
||||
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 .topbar {
|
||||
.scanner-app .topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
@@ -12,12 +12,12 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cv-app .topbar nav {
|
||||
.scanner-app .topbar nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cv-app .topbar nav a {
|
||||
.scanner-app .topbar nav a {
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-bold);
|
||||
@@ -26,21 +26,21 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
|
||||
.cv-app .topbar nav a:hover {
|
||||
.scanner-app .topbar nav a:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.cv-app .topbar nav a:focus-visible {
|
||||
.scanner-app .topbar nav a:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.cv-app .topbar nav a.active {
|
||||
.scanner-app .topbar nav a.active {
|
||||
color: var(--color-text-accent);
|
||||
background: var(--color-bg-high);
|
||||
}
|
||||
|
||||
.cv-app button {
|
||||
.scanner-app button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -60,23 +60,23 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
outline-color: var(--color-text-accent);
|
||||
}
|
||||
|
||||
.cv-app button:focus-visible {
|
||||
.scanner-app button:focus-visible {
|
||||
outline-style: solid;
|
||||
outline-width: 2px;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.cv-app button:active {
|
||||
.scanner-app button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.cv-app button:disabled {
|
||||
.scanner-app button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
transform: initial;
|
||||
}
|
||||
|
||||
.cv-app select {
|
||||
.scanner-app select {
|
||||
appearance: none;
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-field);
|
||||
@@ -97,17 +97,17 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cv-app select:focus-within {
|
||||
.scanner-app select:focus-within {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.cv-app select:disabled {
|
||||
.scanner-app select:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cv-app .controls {
|
||||
.scanner-app .controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
@@ -115,7 +115,7 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cv-app .status {
|
||||
.scanner-app .status {
|
||||
padding: 2px 12px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-2xs);
|
||||
@@ -123,50 +123,50 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
.cv-app .status.idle {
|
||||
.scanner-app .status.idle {
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.cv-app .status.watching {
|
||||
.scanner-app .status.watching {
|
||||
color: var(--color-info-high);
|
||||
border-color: var(--color-info-low);
|
||||
background: var(--color-info-low);
|
||||
}
|
||||
|
||||
.cv-app .status.detected {
|
||||
.scanner-app .status.detected {
|
||||
color: var(--color-success-high);
|
||||
border-color: var(--color-success-low);
|
||||
background: var(--color-success-low);
|
||||
}
|
||||
|
||||
.cv-app .live-layout {
|
||||
.scanner-app .live-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 640px) 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.cv-app video.preview,
|
||||
.cv-app canvas.preview {
|
||||
.scanner-app video.preview,
|
||||
.scanner-app canvas.preview {
|
||||
width: 100%;
|
||||
background: #000;
|
||||
border-radius: var(--radius-box);
|
||||
border: var(--border-width) solid var(--color-bg-high);
|
||||
}
|
||||
|
||||
.cv-app .feed {
|
||||
.scanner-app .feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cv-app .card {
|
||||
.scanner-app .card {
|
||||
background: var(--color-bg-high);
|
||||
border-radius: var(--radius-box);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.cv-app .card .meta {
|
||||
.scanner-app .card .meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
color: var(--color-text-high);
|
||||
@@ -176,25 +176,25 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cv-app .card img.thumb {
|
||||
.scanner-app .card img.thumb {
|
||||
width: 160px;
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
|
||||
.cv-app .teams {
|
||||
.scanner-app .teams {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* sendou.ink send status strip under a feed card */
|
||||
.cv-app .send-wrap {
|
||||
.scanner-app .send-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cv-app .send-strip {
|
||||
.scanner-app .send-strip {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
@@ -206,43 +206,43 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.cv-app .send-strip.sent {
|
||||
.scanner-app .send-strip.sent {
|
||||
color: var(--color-success-high);
|
||||
background: var(--color-success-low);
|
||||
}
|
||||
|
||||
.cv-app .send-strip.failed {
|
||||
.scanner-app .send-strip.failed {
|
||||
color: var(--color-error-high);
|
||||
background: var(--color-error-low);
|
||||
}
|
||||
|
||||
.cv-app .send-strip.queued,
|
||||
.cv-app .send-strip.sending {
|
||||
.scanner-app .send-strip.queued,
|
||||
.scanner-app .send-strip.sending {
|
||||
color: var(--color-info-high);
|
||||
background: var(--color-info-low);
|
||||
}
|
||||
|
||||
.cv-app .score {
|
||||
.scanner-app .score {
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
}
|
||||
|
||||
.cv-app .error {
|
||||
.scanner-app .error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.cv-app .send-strip .error {
|
||||
.scanner-app .send-strip .error {
|
||||
color: inherit;
|
||||
font-weight: var(--weight-body);
|
||||
}
|
||||
|
||||
.cv-app .team {
|
||||
.scanner-app .team {
|
||||
border-radius: var(--radius-field);
|
||||
padding: 8px;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.cv-app .team h3 {
|
||||
.scanner-app .team h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
@@ -250,31 +250,31 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.cv-app .team.win h3 {
|
||||
.scanner-app .team.win h3 {
|
||||
color: var(--color-error-high);
|
||||
}
|
||||
|
||||
.cv-app .team.lose h3 {
|
||||
.scanner-app .team.lose h3 {
|
||||
color: var(--color-info-high);
|
||||
}
|
||||
|
||||
.cv-app table.players {
|
||||
.scanner-app table.players {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.cv-app table.players td {
|
||||
.scanner-app table.players td {
|
||||
padding: 2px 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cv-app table.players td.num {
|
||||
.scanner-app table.players td.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cv-app img.weapon-icon {
|
||||
.scanner-app img.weapon-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
vertical-align: middle;
|
||||
@@ -282,18 +282,18 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.cv-app .weapon-cell {
|
||||
.scanner-app .weapon-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cv-app .ability-popover {
|
||||
.scanner-app .ability-popover {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.cv-app button.ability-trigger {
|
||||
.scanner-app button.ability-trigger {
|
||||
height: auto;
|
||||
padding: 2px;
|
||||
background: var(--color-bg-higher);
|
||||
@@ -301,13 +301,13 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.cv-app button.ability-trigger img {
|
||||
.scanner-app button.ability-trigger img {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.cv-app .ability-popover .popover {
|
||||
.scanner-app .ability-popover .popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
@@ -319,13 +319,13 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.5);
|
||||
}
|
||||
|
||||
.cv-app .vod-list {
|
||||
.scanner-app .vod-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cv-app .vod-item {
|
||||
.scanner-app .vod-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
@@ -334,7 +334,7 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.cv-app .vod-item .name {
|
||||
.scanner-app .vod-item .name {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-bold);
|
||||
overflow: hidden;
|
||||
@@ -342,11 +342,11 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cv-app .vod-item .score {
|
||||
.scanner-app .vod-item .score {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cv-app .dropzone {
|
||||
.scanner-app .dropzone {
|
||||
border: var(--border-width) dashed var(--color-border-high);
|
||||
border-radius: var(--radius-box);
|
||||
padding: 32px;
|
||||
@@ -357,29 +357,29 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cv-app .dropzone.over {
|
||||
.scanner-app .dropzone.over {
|
||||
border-color: var(--color-text-accent);
|
||||
color: var(--color-text-accent);
|
||||
}
|
||||
|
||||
.cv-app .screenshot-frame {
|
||||
.scanner-app .screenshot-frame {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.cv-app .screenshot-frame canvas {
|
||||
.scanner-app .screenshot-frame canvas {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-box);
|
||||
border: var(--border-width) solid var(--color-bg-high);
|
||||
}
|
||||
|
||||
.cv-app table.inspector {
|
||||
.scanner-app table.inspector {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.cv-app table.inspector th {
|
||||
.scanner-app table.inspector th {
|
||||
text-align: left;
|
||||
color: var(--color-text-high);
|
||||
font-size: var(--font-2xs);
|
||||
@@ -388,25 +388,25 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
border-bottom: var(--border-width) solid var(--color-border);
|
||||
}
|
||||
|
||||
.cv-app table.inspector td {
|
||||
.scanner-app table.inspector td {
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid var(--color-bg-high);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.cv-app table.inspector canvas {
|
||||
.scanner-app table.inspector canvas {
|
||||
display: block;
|
||||
background: #000;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.cv-app .candidates {
|
||||
.scanner-app .candidates {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cv-app .candidates .cand {
|
||||
.scanner-app .candidates .cand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -415,12 +415,12 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.cv-app .candidates .cand:first-child {
|
||||
.scanner-app .candidates .cand:first-child {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
/* internal navigation styled like the sibling action buttons */
|
||||
.cv-app .link-button {
|
||||
.scanner-app .link-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--field-padding);
|
||||
@@ -432,6 +432,6 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
|
||||
font-size: var(--font-sm);
|
||||
text-decoration: none;
|
||||
}
|
||||
.cv-app .link-button:focus-visible {
|
||||
.scanner-app .link-button:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* and weapon id.
|
||||
*/
|
||||
|
||||
import type { CvAbility } from "../cv-types";
|
||||
import type { ScannerAbility } from "../scanner-types";
|
||||
import { DEATH_EVENT_TYPE, type DeathData } from "./detectors/death/index";
|
||||
import { SCOREBOARD_EVENT_TYPES } from "./detectors/registry";
|
||||
import type {
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
import type { DetectedEvent } from "./detectors/types";
|
||||
|
||||
/** player row index (0-7) → [head, clothes, shoes] ability-id rows */
|
||||
export type PlayerAbilityMap = Map<number, CvAbility[][]>;
|
||||
export type PlayerAbilityMap = Map<number, ScannerAbility[][]>;
|
||||
|
||||
/**
|
||||
* Match a death's killer to a scoreboard player row. Both signals are OCR
|
||||
@@ -25,7 +25,7 @@ import type {
|
||||
SpecialWeaponId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import { type CvAbility, toCvAbility } from "../../../cv-types";
|
||||
import { type ScannerAbility, toScannerAbility } from "../../../scanner-types";
|
||||
import { getCV, type Mat, minMaxLoc } from "../../cv";
|
||||
import {
|
||||
type GlyphSet,
|
||||
@@ -88,7 +88,7 @@ export interface DeathData {
|
||||
* 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[][];
|
||||
abilities: ScannerAbility[][];
|
||||
/** killer's splash-tag name; null if unreadable */
|
||||
name: string | null;
|
||||
}
|
||||
@@ -630,18 +630,18 @@ export function createDeathDetector(
|
||||
|
||||
// 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 abilityRows: ScannerAbility[][] = [];
|
||||
const abilityDebug: (WeaponMatch | null)[][] = [];
|
||||
if (abilities) {
|
||||
for (let row = 0; row < ABILITY_ROWS; row++) {
|
||||
const ids: CvAbility[] = [];
|
||||
const ids: ScannerAbility[] = [];
|
||||
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");
|
||||
ids.push(toScannerAbility(main.id) ?? "UNKNOWN");
|
||||
debug.push(main);
|
||||
confidences.push(Math.max(0, main.score));
|
||||
for (let slot = 0; slot < ABILITY_SUB_XS.length; slot++) {
|
||||
@@ -661,7 +661,7 @@ export function createDeathDetector(
|
||||
inkThreshold: ABILITY_INK_THRESHOLD,
|
||||
});
|
||||
crop.delete();
|
||||
ids.push(toCvAbility(sub.id) ?? "UNKNOWN");
|
||||
ids.push(toScannerAbility(sub.id) ?? "UNKNOWN");
|
||||
debug.push(sub);
|
||||
confidences.push(Math.max(0, sub.score));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* GENERATED by scripts/cv/build-localized-entries.ts from the splat3 repo's
|
||||
* GENERATED by scripts/scanner/build-localized-entries.ts from the splat3 repo's
|
||||
* language dumps — do not edit by hand; regenerate when the game adds
|
||||
* content. Per-language death-burst message templates and localized
|
||||
* weapon names: the "Splatted by\n<weapon>!" burst puts the weapon on line
|
||||
@@ -22,7 +22,11 @@
|
||||
*/
|
||||
|
||||
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
|
||||
import { type CvAbility, toCvAbility, toMainWeaponId } from "../../../cv-types";
|
||||
import {
|
||||
type ScannerAbility,
|
||||
toMainWeaponId,
|
||||
toScannerAbility,
|
||||
} from "../../../scanner-types";
|
||||
import { getCV, type Mat } from "../../cv";
|
||||
import { type GlyphSet, scaleGlyphSet } from "../../glyphs";
|
||||
import {
|
||||
@@ -89,7 +93,7 @@ export interface MinimapTeammate {
|
||||
* 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)[];
|
||||
abilities: (ScannerAbility | null)[];
|
||||
}
|
||||
|
||||
export interface MinimapEnemy {
|
||||
@@ -100,7 +104,7 @@ export interface MinimapEnemy {
|
||||
name: string | null;
|
||||
/** readable even on struck rows: the cross-out spares the weapon icon */
|
||||
weaponId: MainWeaponId | null;
|
||||
abilities: (CvAbility | null)[];
|
||||
abilities: (ScannerAbility | null)[];
|
||||
}
|
||||
|
||||
export interface MinimapData {
|
||||
@@ -249,7 +253,7 @@ export function createMinimapDetector(
|
||||
inkThreshold: number,
|
||||
confidences: number[],
|
||||
debugRow: (WeaponMatch | null)[],
|
||||
): (CvAbility | null)[] {
|
||||
): (ScannerAbility | null)[] {
|
||||
if (!badges) return [null, null, null];
|
||||
return centers.map(([cx, cy]) => {
|
||||
const crop = cropRoi(rgb, badgeRoi(cx, cy));
|
||||
@@ -257,7 +261,9 @@ export function createMinimapDetector(
|
||||
crop.delete();
|
||||
debugRow.push(match);
|
||||
confidences.push(Math.max(0, match.score));
|
||||
return match.score >= ABILITY_MIN_SCORE ? toCvAbility(match.id) : null;
|
||||
return match.score >= ABILITY_MIN_SCORE
|
||||
? toScannerAbility(match.id)
|
||||
: null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -331,7 +337,7 @@ export function createMinimapDetector(
|
||||
let nameRaw = "";
|
||||
let weapon: WeaponMatch | null = null;
|
||||
const badgeDebug: (WeaponMatch | null)[] = [];
|
||||
let abilities: (CvAbility | null)[] = [];
|
||||
let abilities: (ScannerAbility | null)[] = [];
|
||||
if (!occluded) {
|
||||
const parsed = bestNameRead(gray, layout.name);
|
||||
if (parsed) {
|
||||
@@ -467,7 +473,7 @@ export function createMinimapDetector(
|
||||
let nameRaw = "";
|
||||
let weapon: WeaponMatch | null = null;
|
||||
const badgeDebug: (WeaponMatch | null)[] = [];
|
||||
let abilities: (CvAbility | null)[] = [];
|
||||
let abilities: (ScannerAbility | null)[] = [];
|
||||
if (!occluded) {
|
||||
if (nameGlyphs) {
|
||||
const band = copyRoi(gray, layout.name);
|
||||
@@ -567,7 +573,7 @@ export function createMinimapDetector(
|
||||
confidences.push(Math.max(0, weapon.score));
|
||||
}
|
||||
const badgeDebug: (WeaponMatch | null)[] = [];
|
||||
const abilities: (CvAbility | null)[] = occluded
|
||||
const abilities: (ScannerAbility | null)[] = occluded
|
||||
? []
|
||||
: matchBadges(
|
||||
rgb,
|
||||
@@ -17,11 +17,11 @@ import type {
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
type CvAbility,
|
||||
type CvLobby,
|
||||
toCvAbility,
|
||||
type ScannerAbility,
|
||||
type ScannerLobby,
|
||||
toMainWeaponId,
|
||||
} from "../../../cv-types";
|
||||
toScannerAbility,
|
||||
} from "../../../scanner-types";
|
||||
import { getCV, type Mat } from "../../cv";
|
||||
import { type GlyphSet, recognizeText, scaleGlyphSet } from "../../glyphs";
|
||||
import { copyRoi, cropRoi, maxBrightness, meanBrightness } from "../../image";
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
|
||||
export interface ScoreboardOwnData {
|
||||
/** from the header tag; null when unreadable */
|
||||
lobby: CvLobby | null;
|
||||
lobby: ScannerLobby | null;
|
||||
mode: ModeShort | null;
|
||||
stage: StageId | null;
|
||||
/** the player's main weapon; null if unreadable */
|
||||
@@ -60,7 +60,7 @@ export interface ScoreboardOwnData {
|
||||
* own gear abilities, [head, clothes, shoes] rows of
|
||||
* [main, sub, sub, sub] ability ids
|
||||
*/
|
||||
abilities: CvAbility[][];
|
||||
abilities: ScannerAbility[][];
|
||||
}
|
||||
|
||||
export const SCOREBOARD_OWN_EVENT_TYPE = "ScoreboardOwn";
|
||||
@@ -194,18 +194,18 @@ export function createScoreboardOwnDetector(
|
||||
}
|
||||
|
||||
// gear-card ability strips: [head, clothes, shoes] x [main, sub, sub, sub]
|
||||
const abilityRows: CvAbility[][] = [];
|
||||
const abilityRows: ScannerAbility[][] = [];
|
||||
const abilityDebug: (WeaponMatch | null)[][] = [];
|
||||
if (abilities) {
|
||||
for (let row = 0; row < GEAR_ROWS; row++) {
|
||||
const ids: CvAbility[] = [];
|
||||
const ids: ScannerAbility[] = [];
|
||||
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");
|
||||
ids.push(toScannerAbility(main.id) ?? "UNKNOWN");
|
||||
debug.push(main);
|
||||
confidences.push(Math.max(0, main.score));
|
||||
for (let slot = 0; slot < 3; slot++) {
|
||||
@@ -214,7 +214,7 @@ export function createScoreboardOwnDetector(
|
||||
inkThreshold: OWN_ABILITY_INK_THRESHOLD,
|
||||
});
|
||||
crop.delete();
|
||||
ids.push(toCvAbility(sub.id) ?? "UNKNOWN");
|
||||
ids.push(toScannerAbility(sub.id) ?? "UNKNOWN");
|
||||
debug.push(sub);
|
||||
confidences.push(Math.max(0, sub.score));
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
* sets shared with the live header.
|
||||
*/
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import type { CvLobby } from "../../../cv-types";
|
||||
import type { ScannerLobby } from "../../../scanner-types";
|
||||
import type { Mat } from "../../cv";
|
||||
import type { GlyphSet } from "../../glyphs";
|
||||
import { ALL_STAGE_ENTRIES, LOBBY_MODE_COMBOS } from "../../localized";
|
||||
@@ -20,7 +20,7 @@ import { HEADER_BOTTOM_BAND, HEADER_TOP_BAND } from "./rois";
|
||||
export interface ParsedReplayHeader {
|
||||
timestamp: string | null;
|
||||
stage: StageId | null;
|
||||
lobby: CvLobby | null;
|
||||
lobby: ScannerLobby | null;
|
||||
mode: ModeShort | null;
|
||||
/** min of the closed-set match scores that were attempted */
|
||||
confidence: number;
|
||||
@@ -118,7 +118,7 @@ export function parseReplayHeader(
|
||||
}
|
||||
}
|
||||
|
||||
let lobby: CvLobby | null = null;
|
||||
let lobby: ScannerLobby | null = null;
|
||||
let mode: ModeShort | null = null;
|
||||
if (bottomMatch && bottomMatch.score >= MIN_MATCH_SCORE) {
|
||||
lobby = bottomMatch.entry.lobby;
|
||||
@@ -11,7 +11,7 @@
|
||||
* sendou.ink ids regardless of the game's language.
|
||||
*/
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import type { CvLobby } from "../../../cv-types";
|
||||
import type { ScannerLobby } from "../../../scanner-types";
|
||||
import { getCV, type Mat } from "../../cv";
|
||||
import {
|
||||
type GlyphSet,
|
||||
@@ -24,7 +24,7 @@ import { closestBy } from "../../text";
|
||||
import { HEADER_LINE_BAND, HEADER_LOBBY_BAND } from "./rois";
|
||||
|
||||
export interface ParsedHeader {
|
||||
lobby: CvLobby | null;
|
||||
lobby: ScannerLobby | null;
|
||||
mode: ModeShort | null;
|
||||
stage: StageId | null;
|
||||
/** min of the closed-set match scores that were attempted */
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
ModeShort,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import type { CvLobby } from "../../../cv-types";
|
||||
import type { ScannerLobby } from "../../../scanner-types";
|
||||
import { getCV, type Mat } from "../../cv";
|
||||
import { type GlyphSet, scaleGlyphSet } from "../../glyphs";
|
||||
import { cropRoi, maxBrightness, meanBrightness } from "../../image";
|
||||
@@ -50,7 +50,7 @@ export interface ScoreboardPlayer {
|
||||
|
||||
export interface ScoreboardData {
|
||||
/** from the header tag; null when unreadable */
|
||||
lobby: CvLobby | null;
|
||||
lobby: ScannerLobby | null;
|
||||
mode: ModeShort | null;
|
||||
stage: StageId | null;
|
||||
/** [winning team total, losing team total] as shown ("500 p") */
|
||||
@@ -6,7 +6,7 @@
|
||||
* glyph sets (replay rows are smaller, so it passes rescaled sets), and two
|
||||
* match options.
|
||||
*/
|
||||
import { toMainWeaponId } from "../../../cv-types";
|
||||
import { toMainWeaponId } from "../../../scanner-types";
|
||||
import type { Mat } from "../../cv";
|
||||
import type { GlyphSet } from "../../glyphs";
|
||||
import { cropRoi, type Roi } from "../../image";
|
||||
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* GENERATED by scripts/cv/build-localized-entries.ts from the splat3 repo's
|
||||
* GENERATED by scripts/scanner/build-localized-entries.ts from the splat3 repo's
|
||||
* language dumps — do not edit by hand; regenerate when the game adds
|
||||
* content. 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";
|
||||
import type { ScannerLobby } from "../scanner-types";
|
||||
|
||||
export interface LocalizedLobby {
|
||||
text: string;
|
||||
lobby: CvLobby;
|
||||
lobby: ScannerLobby;
|
||||
}
|
||||
|
||||
export interface LocalizedMode {
|
||||
@@ -8,7 +8,7 @@
|
||||
* would show.
|
||||
*/
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import type { CvLobby } from "../cv-types";
|
||||
import type { ScannerLobby } from "../scanner-types";
|
||||
import {
|
||||
LANGUAGE_ENTRIES,
|
||||
type LocalizedLobby,
|
||||
@@ -85,7 +85,7 @@ export const MODE_STAGE_COMBOS: readonly ModeStageCombo[] = dedupe(
|
||||
|
||||
export interface LobbyModeCombo {
|
||||
text: string;
|
||||
lobby: CvLobby;
|
||||
lobby: ScannerLobby;
|
||||
mode: ModeShort;
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ export async function assembleScoreboardResources(
|
||||
icons("special-weapons", specialWeaponIds),
|
||||
icons("sub-weapons", subWeaponIds),
|
||||
// UNKNOWN is the garbled-badge template: it competes in matching and
|
||||
// wins on unreadable slots (see CvAbility in cv-types.ts)
|
||||
// wins on unreadable slots (see ScannerAbility in scanner-types.ts)
|
||||
icons("abilities", [
|
||||
...abilityList.map((ability) => ability.name),
|
||||
"UNKNOWN",
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Where Node-side code (tests, atlas builders) reads and writes the CV
|
||||
* asset sets. CV_ASSETS_DIR holds the CV-specific atlases (glyphs, planner
|
||||
* Where Node-side code (tests, atlas builders) reads and writes the scanner
|
||||
* asset sets. SCANNER_ASSETS_DIR holds the scanner-specific atlases (glyphs, planner
|
||||
* signatures; overridable with the env var of the same name) — its version
|
||||
* segment must match the worker-side ATLAS_BASE (worker/resources.ts).
|
||||
* GAME_IMG_DIR is the sibling sendou-ink/assets checkout's shared `img/**`
|
||||
@@ -8,9 +8,9 @@
|
||||
* xxx: atlases temporarily live in this repo's public/ while the feature
|
||||
* is in development; move them to the assets repo later
|
||||
*/
|
||||
export const CV_ASSETS_DIR =
|
||||
process.env.CV_ASSETS_DIR ??
|
||||
new URL("../../../../public/cv/v1", import.meta.url).pathname;
|
||||
export const SCANNER_ASSETS_DIR =
|
||||
process.env.SCANNER_ASSETS_DIR ??
|
||||
new URL("../../../../public/scanner/v1", import.meta.url).pathname;
|
||||
|
||||
export const GAME_IMG_DIR = new URL(
|
||||
"../../../../../assets/assets/img",
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Fixture discovery and detector execution for tests and tools.
|
||||
*
|
||||
* A fixture is a directory under app/features/cv/tests/fixtures/<detector>/<case-name>/ containing
|
||||
* A fixture is a directory under app/features/scanner/tests/fixtures/<detector>/<case-name>/ containing
|
||||
* frame.png or frame.jpg (raw capture, any resolution — normalization happens
|
||||
* inside the pipeline under test) and expected.json.
|
||||
*/
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
GateResult,
|
||||
} from "../core/detectors/types";
|
||||
import { normalizeFrame, toMat } from "../core/image";
|
||||
import type { CvAbility, CvLobby } from "../cv-types";
|
||||
import type { ScannerAbility, ScannerLobby } from "../scanner-types";
|
||||
import { readImage } from "./image-io";
|
||||
|
||||
export const FIXTURES_DIR = new URL("../tests/fixtures", import.meta.url)
|
||||
@@ -40,7 +40,7 @@ interface ExpectedMinimapTeammate {
|
||||
/** informational for the human corrector; tests compare weaponId */
|
||||
weaponLabel?: string | null;
|
||||
weaponId?: MainWeaponId | null;
|
||||
abilities?: (CvAbility | null)[];
|
||||
abilities?: (ScannerAbility | null)[];
|
||||
}
|
||||
|
||||
interface ExpectedMinimapEnemy {
|
||||
@@ -49,7 +49,7 @@ interface ExpectedMinimapEnemy {
|
||||
/** informational for the human corrector; tests compare weaponId */
|
||||
weaponLabel?: string | null;
|
||||
weaponId?: MainWeaponId | null;
|
||||
abilities?: (CvAbility | null)[];
|
||||
abilities?: (ScannerAbility | null)[];
|
||||
}
|
||||
|
||||
interface ExpectedScoreboard {
|
||||
@@ -62,7 +62,7 @@ interface ExpectedScoreboard {
|
||||
| "Minimap"
|
||||
| "none";
|
||||
data?: {
|
||||
lobby?: CvLobby;
|
||||
lobby?: ScannerLobby;
|
||||
mode?: ModeShort;
|
||||
stage?: StageId;
|
||||
/** informational for the human corrector; tests compare `stage` */
|
||||
@@ -86,7 +86,7 @@ interface ExpectedScoreboard {
|
||||
weaponId?: number | null;
|
||||
weaponType?: "MAIN" | "SUB" | "SPECIAL";
|
||||
/** Death + ScoreboardOwn: 3 gear rows of [main, sub, sub, sub] ability ids */
|
||||
abilities?: CvAbility[][];
|
||||
abilities?: ScannerAbility[][];
|
||||
/** Death only: killer's splash-tag name */
|
||||
name?: string;
|
||||
/** Minimap only: casted 8-player spectator map screen (not parsed yet) */
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Node IO for ScoreboardResources: reads the CV asset sets from the local
|
||||
* Node IO for ScoreboardResources: reads the scanner asset sets from the local
|
||||
* sendou-ink/assets checkout (tests and atlas builders never touch the
|
||||
* CDN). Game icons come from the checkout's shared `img/**` tree, the
|
||||
* CV-specific atlases from `cv/v1/**`. What the bundle contains — every
|
||||
* scanner-specific atlases from `scanner/v1/**`. What the bundle contains — every
|
||||
* key, template option set, and atlas name — lives in core/resources.ts,
|
||||
* shared with the worker's HTTP loader.
|
||||
*/
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import type { ScoreboardResources } from "../core/detectors/scoreboard/index";
|
||||
import { type AtlasMeta, type GlyphSet, loadGlyphSet } from "../core/glyphs";
|
||||
import { assembleScoreboardResources } from "../core/resources";
|
||||
import { CV_ASSETS_DIR as ASSETS_DIR, GAME_IMG_DIR } from "./assets-dir";
|
||||
import { SCANNER_ASSETS_DIR as ASSETS_DIR, GAME_IMG_DIR } from "./assets-dir";
|
||||
import { readImage } from "./image-io";
|
||||
|
||||
/** Decode eagerly, defer the (CPU-heavy) glyph slicing to first access. */
|
||||
@@ -7,14 +7,14 @@ import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
|
||||
// `builds` powers the empty/UNKNOWN ability label in <Ability />; the weapon
|
||||
// and game-misc namespaces the CV cards rely on are always loaded.
|
||||
// and game-misc namespaces the scanner cards rely on are always loaded.
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["builds"],
|
||||
};
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
title: "CV",
|
||||
title: "Scanner",
|
||||
description:
|
||||
"Detect Splatoon 3 match events (scoreboards, deaths, map screens) from live OBS footage, VoDs, and screenshots",
|
||||
location: args.location,
|
||||
@@ -25,12 +25,12 @@ 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 ScannerApp = lazy(() =>
|
||||
import("~/features/scanner/components/App").then((m) => ({ default: m.App })),
|
||||
);
|
||||
|
||||
export default function CvPage() {
|
||||
export default function ScannerPage() {
|
||||
const isHydrated = useHydrated();
|
||||
|
||||
return <Main bigger>{isHydrated ? <CvApp /> : <Placeholder />}</Main>;
|
||||
return <Main bigger>{isHydrated ? <ScannerApp /> : <Placeholder />}</Main>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Zod schemas for the CV events domain — the single source of truth shared
|
||||
* by the producer (the CV detectors/UI in this feature) and the validator
|
||||
* Zod schemas for the scanner events domain — the single source of truth shared
|
||||
* by the producer (the scanner detectors/UI in this feature) and the validator
|
||||
* (features/ingest). Every domain field is a sendou.ink id type; the
|
||||
* compile-time asserts at the bottom pin each schema to the corresponding
|
||||
* detector output interface so producer and validator cannot drift.
|
||||
@@ -26,11 +26,11 @@ import type {
|
||||
ScoreboardPlayer,
|
||||
} from "./core/detectors/scoreboard/index";
|
||||
import type { ScoreboardReplayData } from "./core/detectors/scoreboard-replay/index";
|
||||
import { CV_LOBBIES } from "./cv-types";
|
||||
import { SCANNER_LOBBIES } from "./scanner-types";
|
||||
|
||||
const detectionText = z.string().max(500);
|
||||
|
||||
const cvLobbySchema = z.enum(CV_LOBBIES);
|
||||
const scannerLobbySchema = z.enum(SCANNER_LOBBIES);
|
||||
export const modeShortSchema = z.enum(modesShort);
|
||||
export const stageIdSchema = z.literal(stageIds);
|
||||
export const mainWeaponIdSchema = z.literal(mainWeaponIds);
|
||||
@@ -39,12 +39,12 @@ 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([
|
||||
export const scannerAbilitySchema = z.union([
|
||||
z.literal(abilityNames),
|
||||
z.literal("UNKNOWN"),
|
||||
]);
|
||||
|
||||
export const cvScoreboardPlayerSchema = z.object({
|
||||
export const scannerScoreboardPlayerSchema = z.object({
|
||||
name: detectionText,
|
||||
/** sendou main-weapon id; null when the row's weapon was unreadable */
|
||||
weaponId: mainWeaponIdSchema.nullable(),
|
||||
@@ -54,32 +54,33 @@ export const cvScoreboardPlayerSchema = z.object({
|
||||
s: z.number().nullable(),
|
||||
});
|
||||
|
||||
export const cvScoreboardDataSchema = z.object({
|
||||
lobby: cvLobbySchema.nullable(),
|
||||
export const scannerScoreboardDataSchema = z.object({
|
||||
lobby: scannerLobbySchema.nullable(),
|
||||
mode: modeShortSchema.nullable(),
|
||||
stage: stageIdSchema.nullable(),
|
||||
scores: z.tuple([z.number().nullable(), z.number().nullable()]),
|
||||
players: z.array(cvScoreboardPlayerSchema).length(8),
|
||||
players: z.array(scannerScoreboardPlayerSchema).length(8),
|
||||
povIndex: z.number().int().min(0).max(7).nullable(),
|
||||
});
|
||||
|
||||
export const cvScoreboardReplayDataSchema = cvScoreboardDataSchema.extend({
|
||||
timestamp: detectionText.nullable(),
|
||||
replayCode: detectionText.nullable(),
|
||||
matchScores: z.tuple([z.number().nullable(), z.number().nullable()]),
|
||||
});
|
||||
export const scannerScoreboardReplayDataSchema =
|
||||
scannerScoreboardDataSchema.extend({
|
||||
timestamp: detectionText.nullable(),
|
||||
replayCode: detectionText.nullable(),
|
||||
matchScores: z.tuple([z.number().nullable(), z.number().nullable()]),
|
||||
});
|
||||
|
||||
export const cvDeathDataSchema = z.object({
|
||||
export const scannerDeathDataSchema = z.object({
|
||||
/** sendou weapon id (main/sub/special id space per weaponType) */
|
||||
weaponId: z
|
||||
.union([mainWeaponIdSchema, subWeaponIdSchema, specialWeaponIdSchema])
|
||||
.nullable(),
|
||||
weaponType: z.enum(["MAIN", "SUB", "SPECIAL"]).nullable(),
|
||||
abilities: z.array(z.array(cvAbilitySchema)),
|
||||
abilities: z.array(z.array(scannerAbilitySchema)),
|
||||
name: detectionText.nullable(),
|
||||
});
|
||||
|
||||
export const cvMapStartDataSchema = z.object({
|
||||
export const scannerMapStartDataSchema = z.object({
|
||||
mode: modeShortSchema.nullable(),
|
||||
stage: stageIdSchema.nullable(),
|
||||
});
|
||||
@@ -95,19 +96,22 @@ type MutuallyAssignable<A, B> = [A] extends [B]
|
||||
// `true satisfies …` fails to compile the moment a schema and its detector
|
||||
// interface disagree in either direction.
|
||||
true satisfies MutuallyAssignable<
|
||||
z.infer<typeof cvScoreboardPlayerSchema>,
|
||||
z.infer<typeof scannerScoreboardPlayerSchema>,
|
||||
ScoreboardPlayer
|
||||
>;
|
||||
true satisfies MutuallyAssignable<
|
||||
z.infer<typeof cvScoreboardDataSchema>,
|
||||
z.infer<typeof scannerScoreboardDataSchema>,
|
||||
ScoreboardData
|
||||
>;
|
||||
true satisfies MutuallyAssignable<
|
||||
z.infer<typeof cvScoreboardReplayDataSchema>,
|
||||
z.infer<typeof scannerScoreboardReplayDataSchema>,
|
||||
ScoreboardReplayData
|
||||
>;
|
||||
true satisfies MutuallyAssignable<z.infer<typeof cvDeathDataSchema>, DeathData>;
|
||||
true satisfies MutuallyAssignable<
|
||||
z.infer<typeof cvMapStartDataSchema>,
|
||||
z.infer<typeof scannerDeathDataSchema>,
|
||||
DeathData
|
||||
>;
|
||||
true satisfies MutuallyAssignable<
|
||||
z.infer<typeof scannerMapStartDataSchema>,
|
||||
MapStartData
|
||||
>;
|
||||
@@ -3,16 +3,16 @@ import {
|
||||
assertDecodesToDefault,
|
||||
assertRoundTrips,
|
||||
} from "~/modules/search-params/search-params-test-utils";
|
||||
import { cvSearchParams } from "./cv-search-params";
|
||||
import { scannerSearchParams } from "./scanner-search-params";
|
||||
|
||||
describe("cvSearchParams", () => {
|
||||
describe("scannerSearchParams", () => {
|
||||
it("round-trips", () => {
|
||||
assertRoundTrips(cvSearchParams, {
|
||||
assertRoundTrips(scannerSearchParams, {
|
||||
tab: ["live", "screenshot", "vod"],
|
||||
});
|
||||
});
|
||||
|
||||
it("malformed values decode to defaults", () => {
|
||||
assertDecodesToDefault(cvSearchParams, "tab", [["garbage"], ["LIVE"]]);
|
||||
assertDecodesToDefault(scannerSearchParams, "tab", [["garbage"], ["LIVE"]]);
|
||||
});
|
||||
});
|
||||
11
app/features/scanner/scanner-search-params.ts
Normal file
11
app/features/scanner/scanner-search-params.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
|
||||
export const SCANNER_TABS = ["live", "screenshot", "vod"] as const;
|
||||
|
||||
export type ScannerTab = (typeof SCANNER_TABS)[number];
|
||||
|
||||
export const scannerSearchParams = SearchParams.define({
|
||||
tab: SP.param(z.enum(SCANNER_TABS), { default: "live", loader: false }),
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Shared domain vocabulary for the CV feature: events, snap tables, and
|
||||
* Shared domain vocabulary for the scanner feature: events, snap tables, and
|
||||
* constants speak sendou.ink's id types (ModeShort, StageId, weapon ids,
|
||||
* Ability) — canonical English strings live only inside the OCR snap
|
||||
* layer and never leave a detector.
|
||||
@@ -9,15 +9,15 @@ 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;
|
||||
export type CvLobby = (typeof CV_LOBBIES)[number];
|
||||
export const SCANNER_LOBBIES = ["X", "SERIES", "OPEN", "PRIVATE"] as const;
|
||||
export type ScannerLobby = (typeof SCANNER_LOBBIES)[number];
|
||||
|
||||
/**
|
||||
* A detected gear ability: a sendou ability id, or the explicit
|
||||
* unrecognized marker (the UNKNOWN template in the shared img/abilities
|
||||
* set — distinct from null, which means the badge was covered/absent).
|
||||
*/
|
||||
export type CvAbility = Ability | "UNKNOWN"; // xxx: why not AbilityWithUnknown
|
||||
export type ScannerAbility = Ability | "UNKNOWN"; // xxx: why not AbilityWithUnknown
|
||||
|
||||
const MAIN_WEAPON_ID_SET: ReadonlySet<number> = new Set(mainWeaponIds);
|
||||
const ABILITY_SET: ReadonlySet<string> = new Set(abilities.map((a) => a.name));
|
||||
@@ -28,8 +28,8 @@ export function toMainWeaponId(id: number | string): MainWeaponId | null {
|
||||
return MAIN_WEAPON_ID_SET.has(n) ? (n as MainWeaponId) : null;
|
||||
}
|
||||
|
||||
/** Narrow an ability-template id to a CvAbility; null when unknown. */
|
||||
export function toCvAbility(id: string): CvAbility | null {
|
||||
/** Narrow an ability-template id to a ScannerAbility; null when unknown. */
|
||||
export function toScannerAbility(id: string): ScannerAbility | null {
|
||||
if (id === "UNKNOWN") return id;
|
||||
return ABILITY_SET.has(id) ? (id as Ability) : null;
|
||||
}
|
||||
@@ -14,15 +14,15 @@ import {
|
||||
} 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 type { ScannerAbility } from "../scanner-types";
|
||||
import test from "./node-test-compat";
|
||||
|
||||
const GRID_A: CvAbility[][] = [
|
||||
const GRID_A: ScannerAbility[][] = [
|
||||
["ISM", "ISM", "ISM", "ISM"],
|
||||
["RSU", "RSU", "RSU", "RSU"],
|
||||
["SSU", "SSU", "SSU", "SSU"],
|
||||
];
|
||||
const GRID_B: CvAbility[][] = [
|
||||
const GRID_B: ScannerAbility[][] = [
|
||||
["QR", "QR", "QR", "QR"],
|
||||
["QSJ", "QSJ", "QSJ", "QSJ"],
|
||||
["IRU", "IRU", "IRU", "IRU"],
|
||||
@@ -36,7 +36,7 @@ function death(
|
||||
t: number,
|
||||
name: string | null,
|
||||
weaponId: MainWeaponId | null,
|
||||
abilities: CvAbility[][] = GRID_A,
|
||||
abilities: ScannerAbility[][] = GRID_A,
|
||||
): DetectedEvent<DeathData> {
|
||||
return {
|
||||
type: DEATH_EVENT_TYPE,
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
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 type { ScannerAbility, ScannerLobby } from "../scanner-types";
|
||||
import test from "./node-test-compat";
|
||||
|
||||
const NAMES = ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"];
|
||||
@@ -24,7 +24,7 @@ function mapStart(t: number): DetectedEvent {
|
||||
function death(
|
||||
t: number,
|
||||
name: string,
|
||||
abilities: CvAbility[][] = [["ISM", "ISS", "ISS", "ISS"]],
|
||||
abilities: ScannerAbility[][] = [["ISM", "ISS", "ISS", "ISS"]],
|
||||
): DetectedEvent {
|
||||
const data: DeathData = {
|
||||
weaponId: null,
|
||||
@@ -37,7 +37,7 @@ function death(
|
||||
|
||||
function scoreboard(
|
||||
t: number,
|
||||
{ lobby = "PRIVATE" as CvLobby | null } = {},
|
||||
{ lobby = "PRIVATE" as ScannerLobby | null } = {},
|
||||
): DetectedEvent {
|
||||
const data: ScoreboardData = {
|
||||
lobby,
|
||||
@@ -76,7 +76,7 @@ test("groups map start, deaths and scoreboard into one batch", () => {
|
||||
});
|
||||
|
||||
test("enriches the scoreboard players with abilities from the batch's deaths", () => {
|
||||
const build: CvAbility[][] = [
|
||||
const build: ScannerAbility[][] = [
|
||||
["ISM", "ISS", "ISS", "ISS"],
|
||||
["QR", "QSJ", "QSJ", "QSJ"],
|
||||
["SSU", "RSU", "RSU", "RSU"],
|
||||
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 4.1 MiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user