diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index 9bf6475b4..040bb9bc5 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -63,11 +63,12 @@ opens it, for anyone, through the same handoff Inspect uses. `Matches` (`core/csv/matches.ts`, one row per game, the rows the cards render) and `Raw detections` (`core/csv/events.ts`, one row per event). Column names stay English keys. -- **Debug gate** (`use-debug.ts`: DEV/ADMIN role or `?debug=true`): - `Save frame as fixture`, `?telemetry=true`, and the `Raw detections` - disclosure inside a match card (the per-event cards with Inspect). The - screenshot view (`ScreenshotPage.tsx`) is not gated; the fixtures view is - dev-only. +- **Debug gate** (`use-debug.ts`: DEV/ADMIN role or `?debug=true`, which + Settings → Debug → `Enable debug` sets): + `Save frame as fixture` (Settings → Debug, live only), `?telemetry=true`, + and the `Raw detections` disclosure inside a match card (the per-event + cards with Inspect). The screenshot view (`ScreenshotPage.tsx`) is not + gated; the fixtures view is dev-only. ## Clips @@ -188,7 +189,12 @@ sequenceDiagram - `core/match-builder.ts` turns a timeline into `ScannerMatch`es: a MapStart opens a match, a scoreboard closes one (claiming the last 8 min of deaths when the intro was missed), minimaps group per map by confirmed stage - change and >5 min gap. An event belongs to at most one match; deaths + change and >5 min gap. A battle history screen (battle log, replay + browser) showing a game already built — same scoreboard fingerprint (the + order-free paint/K+A/deaths/specials lines), recording time within 20 min + of its play time — joins that match's sources instead of forming a new + one, so browsing the log after playing neither adds a card nor re-uploads + (the match was already sent). An event belongs to at most one match; deaths reveal enemy builds (`ability-harvest.ts`), the personal results screen (`ScoreboardOwn`, seen within `OWN_RESULTS_WINDOW_SECONDS` of a closed match's scoreboard) completes the POV player's full build, and minimap diff --git a/app/features/scanner/components/LiveView.tsx b/app/features/scanner/components/LiveView.tsx index 592a67d25..d557f1c86 100644 --- a/app/features/scanner/components/LiveView.tsx +++ b/app/features/scanner/components/LiveView.tsx @@ -3,7 +3,7 @@ * capture preview, the LIVE/IDLE status line, Stop — over the same * SessionView. The capture itself lives in live-session.ts and outlives this view. */ -import { Camera, Square } from "lucide-react"; +import { Square } from "lucide-react"; import { SendouButton } from "~/components/elements/Button"; import { LocaleTime } from "~/components/LocaleTime"; import { useUser } from "~/features/auth/core/user"; @@ -27,7 +27,6 @@ import { matchContaining } from "./sendou-ingest"; import type { ScanEvent } from "./session-data"; import { useScannerSettings } from "./settings"; import { sendLive } from "./upload"; -import { useDebug } from "./use-debug"; /** a game is "being read" while its newest event is this fresh */ const READING_WINDOW_MS = 60_000; @@ -39,7 +38,6 @@ export function LiveView() { const settings = useScannerSettings(); const user = useUser(); const [, setParams] = useSearchParamsTyped(scannerSearchParams); - const debug = useDebug(); const session = currentSession(feed); const events = session?.events ?? []; @@ -55,7 +53,7 @@ export function LiveView() { newest.match.winner === null && Date.now() - (session?.endedAt ?? 0) < READING_WINDOW_MS; const uploadNote = !user - ? "Upload off · log in" + ? "Upload off (log in)" : settings.upload ? "Upload on ✓" : "Upload off"; @@ -99,7 +97,7 @@ export function LiveView() { if (id !== undefined) void sendLive(matchContaining(id)); }} getFrame={frameLoader} - emptyText="Play a game — it shows up here as soon as its intro or results screen is read." + emptyText="Play a game — it shows up here once its results screen is read." header={(info) => ( - {debug ? ( - } - onClick={saveCurrentFrameAsFixture} - > - Save frame as fixture - - ) : null} Stop - + } > diff --git a/app/features/scanner/components/MatchCard.module.css b/app/features/scanner/components/MatchCard.module.css index dbb40aac0..2d80ba3f1 100644 --- a/app/features/scanner/components/MatchCard.module.css +++ b/app/features/scanner/components/MatchCard.module.css @@ -82,8 +82,15 @@ gap: var(--s-2) var(--s-3); } +.numberColumn { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-0-5); +} + .number { - font-size: var(--font-2xs); + font-size: 0.55rem; font-weight: var(--weight-semi); text-transform: uppercase; letter-spacing: 0.08em; @@ -91,6 +98,12 @@ white-space: nowrap; } +.scannedAt { + font-size: var(--font-2xs); + color: var(--color-text); + white-space: nowrap; +} + .mode { flex-shrink: 0; filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.5)); diff --git a/app/features/scanner/components/MatchCard.tsx b/app/features/scanner/components/MatchCard.tsx index af13e03b2..958720159 100644 --- a/app/features/scanner/components/MatchCard.tsx +++ b/app/features/scanner/components/MatchCard.tsx @@ -14,6 +14,7 @@ import { Ability } from "~/components/Ability"; import { SendouButton } from "~/components/elements/Button"; import { GameTimeline } from "~/components/GameTimeline"; import { ModeImage, WeaponImage } from "~/components/Image"; +import { LocaleTime } from "~/components/LocaleTime"; import { matchScoresFromObjective } from "~/components/objective-timeline-utils"; import { StageBannerBox } from "~/components/StageBannerBox"; import { abilities as ALL_ABILITIES } from "~/modules/in-game-lists/abilities"; @@ -121,6 +122,9 @@ export function MatchCard({ const result = matchResult(match); const pov = povPlayer(match); const matchOrigin = timelineOrigin(match); + const scannedAt = built.sources.find( + (event) => event.detectedAt !== undefined, + )?.detectedAt; const meta = [ kind === "vod" && match.startsAt !== null ? `at ${formatPosition(match.startsAt - originT)}` @@ -143,7 +147,16 @@ export function MatchCard({ const head = (
- Game {number} +
+ Game {number} + {scannedAt !== undefined ? ( + + ) : null} +
{match.mode !== null ? ( ) : null} diff --git a/app/features/scanner/components/SettingsPopover.tsx b/app/features/scanner/components/SettingsPopover.tsx index a4dd4dda3..0e1127d91 100644 --- a/app/features/scanner/components/SettingsPopover.tsx +++ b/app/features/scanner/components/SettingsPopover.tsx @@ -1,10 +1,10 @@ /** * The settings popover, opened from ⚙ on the landing and the live header: - * the upload and clip toggles, the retention notes, and in development the - * fixtures link. The source lives on the landing's Live card, the one place + * the upload and clip toggles, the retention notes, and the debug tools + * (enabling debug mode, saving the live frame, the fixtures link in development). The source lives on the landing's Live card, the one place * it must be right. */ -import { Settings } from "lucide-react"; +import { Bug, Camera, Settings } from "lucide-react"; import { Link } from "react-router"; import { SendouButton } from "~/components/elements/Button"; import { @@ -13,6 +13,7 @@ import { } from "~/components/elements/ChipRadio"; import { SendouPopover } from "~/components/elements/Popover"; import { SendouSwitch } from "~/components/elements/Switch"; +import { useSearchParam } from "~/modules/search-params/hooks"; import { SCANNER_PAGE } from "~/utils/urls"; import { MAX_SESSIONS } from "../core/sessions"; import { scannerSearchParams } from "../scanner-search-params"; @@ -25,13 +26,23 @@ import { useScannerSettings, } from "./settings"; import { isLoggedIn } from "./upload"; +import { useDebug } from "./use-debug"; /** a step of one frame-ish: fine enough to tune by ear, coarse enough to reach a second in a few clicks */ const AUDIO_OFFSET_STEP_MS = 25; -export function SettingsPopover() { +export function SettingsPopover({ + onSaveFrame, +}: { + /** set while capturing: downloads the current live frame */ + onSaveFrame?: () => void; +}) { const settings = useScannerSettings(); const loggedIn = isLoggedIn(); + const debug = useDebug(); + const [, setDebugParam] = useSearchParam(scannerSearchParams, "debug"); + const showFixturesLink = process.env.NODE_ENV === "development"; + const showSaveFrame = debug && onSaveFrame !== undefined; return ( Sessions: last 30 days or {MAX_SESSIONS} sessions.

- {process.env.NODE_ENV === "development" ? ( + {!debug || showSaveFrame || showFixturesLink ? (
Debug
- - Fixtures - + {!debug ? ( + } + onClick={() => setDebugParam(true)} + > + Enable debug + + ) : null} + {showSaveFrame ? ( + } + onClick={onSaveFrame} + > + Save frame as fixture + + ) : null} + {showFixturesLink ? ( + + Fixtures + + ) : null}
) : null} diff --git a/app/features/scanner/components/fixture-export.ts b/app/features/scanner/components/fixture-export.ts index bd91f9713..a97302514 100644 --- a/app/features/scanner/components/fixture-export.ts +++ b/app/features/scanner/components/fixture-export.ts @@ -1,5 +1,5 @@ /** - * "Save as fixture": downloads the raw captured frame as PNG plus an + * Fixture downloads: the raw captured frame as PNG, and for a detection an * expected.json prefilled from the detector's output, so labeling is review-and-correct. */ @@ -279,10 +279,8 @@ export function saveFixtureFromEvent( ); } -export async function saveFixture( - video: HTMLVideoElement, - latest: { type: string; data: FixtureData } | null, -): Promise { +/** The video's current frame as frame.png, the fixture's raw input. */ +export async function saveFrame(video: HTMLVideoElement): Promise { const canvas = document.createElement("canvas"); canvas.width = video.videoWidth; canvas.height = video.videoHeight; @@ -292,10 +290,4 @@ export async function saveFixture( ); 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/scanner/components/live-session.ts b/app/features/scanner/components/live-session.ts index 2aac95817..f1e2f3676 100644 --- a/app/features/scanner/components/live-session.ts +++ b/app/features/scanner/components/live-session.ts @@ -52,7 +52,7 @@ import { refreshFeed, subscribeFeed, } from "./events-feed"; -import { type FixtureData, saveFixture } from "./fixture-export"; +import { type FixtureData, saveFrame } from "./fixture-export"; import { matchContaining, retryableUnlinkedMatches, @@ -141,7 +141,6 @@ let unsubscribeFeed: (() => void) | null = null; let timeline = new TimelineBuilder(); const storedIds = new WeakMap(); const gates = new Map(); -let latestParse: { type: string; data: FixtureData } | null = null; // the open match is known to be a non-SZ mode, so counter reads are // misreads of another mode's overlay and are not collected at all let objectiveBlocked = false; @@ -315,10 +314,10 @@ export function stopCapture(): void { void trimEvents().catch(() => {}); } -/** Debug: the current frame plus the latest parse as a fixture download. */ +/** Debug: the current capture frame as a PNG download. */ export function saveCurrentFrameAsFixture(): void { if (!video) return; - void saveFixture(video, latestParse); + void saveFrame(video); } function release(): void { @@ -389,7 +388,6 @@ function onResult( }); if (!result.gate.pass) return; for (const event of result.events as DetectedEvent[]) { - latestParse = { type: event.type, data: event.data }; if ( (event.type === OBJECTIVE_EVENT_TYPE || event.type === PLAYER_STATUS_EVENT_TYPE) && diff --git a/app/features/scanner/core/match-builder.ts b/app/features/scanner/core/match-builder.ts index ae67cfaee..b5d90ba16 100644 --- a/app/features/scanner/core/match-builder.ts +++ b/app/features/scanner/core/match-builder.ts @@ -146,6 +146,23 @@ const KILL_SAME_ROW_MIN_SIMILARITY = 0.7; */ const OWN_RESULTS_WINDOW_SECONDS = 90; +/** Battle history screens: browsing them after playing shows games the timeline already holds. */ +const HISTORY_SCOREBOARD_EVENT_TYPES: readonly string[] = [ + SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE, + SCOREBOARD_BATTLE_LOG_EVENT_TYPE, + QUICK_SCOREBOARD_BATTLE_LOG_EVENT_TYPE, +]; + +/** Paint totals a board must read to fingerprint its game; fewer could collide between games. */ +const FINGERPRINT_MIN_PAINT_READ = 6; + +/** + * How far a history screen's recording time may sit from the earlier read of + * the same game: it is on the console clock and marks the game's start, while + * a results screen's time is the PC clock at the game's end. + */ +const REVISIT_PLAYED_AT_TOLERANCE_MS = 20 * 60 * 1000; + export interface BuiltMatch { match: ScannerMatch; /** input events the match was built from, chronological — the send-status unit for callers */ @@ -155,8 +172,10 @@ export interface BuiltMatch { /** * Splits a timeline into ScannerMatch objects, chronological. A personal * results screen identifies no match of its own but completes the POV - * player's build on the match whose results screen it follows. Every input - * event ends up in at most one match's `sources`. + * player's build on the match whose results screen it follows. A battle + * history screen showing an already built game (same scoreboard fingerprint, + * recording time not contradicting it) joins that match's `sources` instead of + * forming a new one. Every input event ends up in at most one match's `sources`. */ export function buildScannerMatches( events: readonly E[], @@ -193,6 +212,13 @@ export function buildScannerMatches( orphanStripWeapons = []; orphanKills = []; } else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) { + const revisited = revisitedMatch(built, event); + if (revisited) { + // the game already has its match, and the one being played (if + // any) keeps gathering events + revisited.sources.push(event); + continue; + } if (!open) { open = startMatch(); open.deaths = orphanDeaths.filter( @@ -471,15 +497,6 @@ function toBuiltMatch( const board = open.scoreboard?.data as ScoreboardData | undefined; const start = open.mapStart?.data as MapStartData | undefined; - // the replay-browser and both battle log screens carry the recording - // timestamp; only the former a replay code - const timestamped = - open.scoreboard?.type === SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE || - open.scoreboard?.type === SCOREBOARD_BATTLE_LOG_EVENT_TYPE || - open.scoreboard?.type === QUICK_SCOREBOARD_BATTLE_LOG_EVENT_TYPE - ? (open.scoreboard.data as ScoreboardBattleLogData & - Partial) - : undefined; const deaths = open.deaths.map((event) => event.data as DeathData); const objectives = open.objectives.map((event) => ({ t: event.t, @@ -531,14 +548,14 @@ function toBuiltMatch( startsAt: sources.length > 0 ? Math.max(0, Math.floor(sources[0]!.t)) : null, endsAt: floorOrNull(open.scoreboard?.t ?? open.minimaps.at(-1)?.t), - playedAt: playedAt(open.scoreboard, timestamped), + playedAt: playedAt(open.scoreboard), lobby: board?.lobby ?? null, mode, stage: board?.stage ?? start?.stage ?? leadingStage(open.stageVotes), matchScores: board?.matchScores.some((score) => score !== null) ? board.matchScores : null, - replayCode: timestamped?.replayCode ?? null, + replayCode: historyData(open.scoreboard)?.replayCode ?? null, // layout alone cannot flag a broadcast (S3 POV footage draws both narrow // strip geometries), so only the spectator map screen or badge-proven // strips count; a results screen that identified the POV seat disproves @@ -1324,12 +1341,10 @@ function bestCount( * closing scoreboard's detection time — read structurally off richer event * records (StoredEvent) so the builder stays generic. */ -function playedAt( - scoreboard: DetectedEvent | null, - timestamped: ScoreboardBattleLogData | undefined, -): number | null { +function playedAt(scoreboard: DetectedEvent | null): number | null { if (!scoreboard) return null; const detectedAt = (scoreboard as { detectedAt?: number }).detectedAt ?? null; + const timestamped = historyData(scoreboard); if (timestamped?.timestamp) { const recorded = parseReplayTimestamp(timestamped.timestamp, { now: detectedAt ?? undefined, @@ -1339,6 +1354,77 @@ function playedAt( return detectedAt; } +/** The replay-browser and both battle log screens carry the recording timestamp; only the former a replay code. */ +function historyData( + scoreboard: DetectedEvent | null, +): + | (ScoreboardBattleLogData & Partial) + | undefined { + if (!scoreboard || !HISTORY_SCOREBOARD_EVENT_TYPES.includes(scoreboard.type)) + return undefined; + return scoreboard.data as ScoreboardBattleLogData & + Partial; +} + +/** + * The earlier match a battle history screen shows again: its closing board + * has the same fingerprint and its play time doesn't contradict the screen's + * recording time (either may be unknown, e.g. on VoD scans). + */ +function revisitedMatch( + built: readonly BuiltMatch[], + event: E, +): BuiltMatch | undefined { + if (!HISTORY_SCOREBOARD_EVENT_TYPES.includes(event.type)) return undefined; + const fingerprint = scoreboardFingerprint(event.data as ScoreboardData); + if (fingerprint === null) return undefined; + const recordedAt = playedAt(event); + + return built.findLast((candidate) => { + const board = candidate.sources.find((source) => + SCOREBOARD_EVENT_TYPES.includes(source.type), + ); + if ( + !board || + scoreboardFingerprint(board.data as ScoreboardData) !== fingerprint + ) { + return false; + } + return ( + recordedAt === null || + candidate.match.playedAt === null || + Math.abs(recordedAt - candidate.match.playedAt) <= + REVISIT_PLAYED_AT_TOLERANCE_MS + ); + }); +} + +/** + * A game's identity off its board: each team's stat lines (paint, K+A, deaths, + * specials) as an order-free multiset, teams order-free too (a history screen + * can misplace the winner panel). Paint totals practically never repeat + * between games; names (OCR wobble) and weapons (icon sizes differ per + * screen) are left out. Null when too few paint totals were read to tell games apart. + */ +function scoreboardFingerprint(board: ScoreboardData): string | null { + const paintsRead = board.players.filter( + (player) => player.paint !== null, + ).length; + if (paintsRead < FINGERPRINT_MIN_PAINT_READ) return null; + + const teamKey = (players: ScoreboardData["players"]) => + players + .map((player) => [player.paint, player.ka, player.d, player.s].join(":")) + .sort() + .join(","); + return [ + teamKey(board.players.slice(0, PLAYERS_PER_TEAM)), + teamKey(board.players.slice(PLAYERS_PER_TEAM)), + ] + .sort() + .join("|"); +} + function teamsFromScoreboard( board: ScoreboardData, deaths: readonly DeathData[], diff --git a/app/features/scanner/tests/logic/match-builder.test.ts b/app/features/scanner/tests/logic/match-builder.test.ts index 175e92e16..39139ee3b 100644 --- a/app/features/scanner/tests/logic/match-builder.test.ts +++ b/app/features/scanner/tests/logic/match-builder.test.ts @@ -86,6 +86,7 @@ function scoreboard( weapons: weaponIds = ALL as (MainWeaponId | null)[], povIndex = 0 as number | null, matchScores = [100, 47] as [number | null, number | null], + paints = [] as (number | null)[], } = {}, ): DetectedEvent { const data: ScoreboardData = { @@ -96,7 +97,7 @@ function scoreboard( players: weaponIds.map((weaponId, i) => ({ name: NAMES[i] ?? `p${i}`, weaponId, - paint: 1000, + paint: paints.length > 0 ? (paints[i] ?? null) : 1000, ka: 10, d: 5, s: 2, @@ -125,9 +126,9 @@ function replayScoreboard( function battleLogScoreboard( t: number, - { timestamp = null as string | null } = {}, + { timestamp = null as string | null, paints = [] as (number | null)[] } = {}, ): DetectedEvent & { detectedAt?: number } { - const base = scoreboard(t).data as ScoreboardData; + const base = scoreboard(t, { paints }).data as ScoreboardData; const data: ScoreboardBattleLogData = { ...base, timestamp, @@ -738,6 +739,92 @@ test("without a replay timestamp, playedAt falls back to the scoreboard's detect assert.equal(built[0]!.match.playedAt, 1_700_000_000_000); }); +const GAME_PAINTS = [1204, 987, 1530, 842, 1102, 765, 1311, 690]; +const OTHER_GAME_PAINTS = [1188, 1003, 1421, 901, 1250, 612, 1377, 745]; +const PLAYED_AT = new Date(2025, 11, 25, 21, 34).getTime(); + +function playedGame(): DetectedEvent[] { + const results = scoreboard(300, { paints: GAME_PAINTS }) as DetectedEvent & { + detectedAt?: number; + }; + results.detectedAt = PLAYED_AT; + return [mapStart(0), death(100, "l1"), results]; +} + +test("a battle log view of an already built game joins its match", () => { + const view = battleLogScoreboard(900, { + timestamp: "25.12.2025 21:30", + paints: GAME_PAINTS, + }); + const built = buildScannerMatches([...playedGame(), view]); + assert.equal(built.length, 1); + assert.equal(built[0]!.sources.at(-1), view); + assert.equal(built[0]!.match.playedAt, PLAYED_AT); +}); + +test("a battle log view with the winner panel misplaced still joins its match", () => { + const swapped = [...GAME_PAINTS.slice(4), ...GAME_PAINTS.slice(0, 4)]; + const built = buildScannerMatches([ + ...playedGame(), + battleLogScoreboard(900, { paints: swapped }), + ]); + assert.equal(built.length, 1); +}); + +test("a battle log view of another game forms its own match", () => { + const built = buildScannerMatches([ + ...playedGame(), + battleLogScoreboard(900, { paints: OTHER_GAME_PAINTS }), + ]); + assert.equal(built.length, 2); +}); + +test("a battle log view whose recording time contradicts the earlier read forms its own match", () => { + const built = buildScannerMatches([ + ...playedGame(), + battleLogScoreboard(900, { + timestamp: "25.12.2025 19:30", + paints: GAME_PAINTS, + }), + ]); + assert.equal(built.length, 2); +}); + +test("a battle log view with too few paint totals read forms its own match", () => { + const sparse = GAME_PAINTS.map((paint, i) => (i < 5 ? paint : null)); + const results = scoreboard(300, { paints: sparse }); + const built = buildScannerMatches([ + mapStart(0), + results, + battleLogScoreboard(900, { paints: sparse }), + ]); + assert.equal(built.length, 2); +}); + +test("a battle log view does not close the match still gathering events", () => { + const built = buildScannerMatches([ + ...playedGame(), + mapStart(1000), + death(1100, "l2"), + battleLogScoreboard(1150, { paints: GAME_PAINTS }), + scoreboard(1300, { paints: OTHER_GAME_PAINTS }), + ]); + assert.equal(built.length, 2); + assert.deepEqual( + built[1]!.sources.map((e) => e.t), + [1000, 1100, 1300], + ); +}); + +test("a results screen repeating an earlier board is a new game", () => { + const built = buildScannerMatches([ + ...playedGame(), + mapStart(1000), + scoreboard(1300, { paints: GAME_PAINTS }), + ]); + assert.equal(built.length, 2); +}); + test("a minimap-only match has no playedAt and no winner", () => { const built = buildScannerMatches([minimap(70), minimap(120)]); const match = built[0]!.match;