Fixes from testing

This commit is contained in:
Kalle
2026-09-23 22:35:59 +03:00
parent a33b37b5dd
commit e43cf89c66
9 changed files with 289 additions and 73 deletions

View File

@@ -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

View File

@@ -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) => (
<SessionHeader
actions={
@@ -114,16 +112,6 @@ export function LiveView() {
clipCounts={info.clipCounts}
fileBase={`scanner-${new Date(session?.startedAt ?? Date.now()).toISOString().slice(0, 10)}`}
/>
{debug ? (
<SendouButton
size="small"
variant="minimal"
icon={<Camera />}
onClick={saveCurrentFrameAsFixture}
>
Save frame as fixture
</SendouButton>
) : null}
<SendouButton
size="small"
variant="destructive"
@@ -132,7 +120,7 @@ export function LiveView() {
>
Stop
</SendouButton>
<SettingsPopover />
<SettingsPopover onSaveFrame={saveCurrentFrameAsFixture} />
</>
}
>

View File

@@ -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));

View File

@@ -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 = (
<div className={styles.head}>
<div className={styles.main}>
<span className={styles.number}>Game {number}</span>
<div className={clsx(styles.numberColumn, "line-height-tight")}>
<span className={styles.number}>Game {number}</span>
{scannedAt !== undefined ? (
<LocaleTime
date={new Date(scannedAt)}
options={{ timeStyle: "short" }}
className={styles.scannedAt}
/>
) : null}
</div>
{match.mode !== null ? (
<ModeImage mode={match.mode} size={26} className={styles.mode} />
) : null}

View File

@@ -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 (
<SendouPopover
@@ -120,18 +131,40 @@ export function SettingsPopover() {
<br />
Sessions: last 30 days or {MAX_SESSIONS} sessions.
</p>
{process.env.NODE_ENV === "development" ? (
{!debug || showSaveFrame || showFixturesLink ? (
<section className={styles.section}>
<span className={styles.label}>Debug</span>
<div className={styles.row}>
<Link
to={scannerSearchParams.href(SCANNER_PAGE, {
view: "fixtures",
})}
defaultShouldRevalidate={false}
>
Fixtures
</Link>
{!debug ? (
<SendouButton
size="small"
variant="minimal"
icon={<Bug />}
onClick={() => setDebugParam(true)}
>
Enable debug
</SendouButton>
) : null}
{showSaveFrame ? (
<SendouButton
size="small"
variant="minimal"
icon={<Camera />}
onClick={onSaveFrame}
>
Save frame as fixture
</SendouButton>
) : null}
{showFixturesLink ? (
<Link
to={scannerSearchParams.href(SCANNER_PAGE, {
view: "fixtures",
})}
defaultShouldRevalidate={false}
>
Fixtures
</Link>
) : null}
</div>
</section>
) : null}

View File

@@ -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<void> {
/** The video's current frame as frame.png, the fixture's raw input. */
export async function saveFrame(video: HTMLVideoElement): Promise<void> {
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",
}),
);
}

View File

@@ -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<DetectedEvent, number>();
const gates = new Map<string, GateResult>();
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<FixtureData>[]) {
latestParse = { type: event.type, data: event.data };
if (
(event.type === OBJECTIVE_EVENT_TYPE ||
event.type === PLAYER_STATUS_EVENT_TYPE) &&

View File

@@ -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<E extends DetectedEvent> {
match: ScannerMatch;
/** input events the match was built from, chronological — the send-status unit for callers */
@@ -155,8 +172,10 @@ export interface BuiltMatch<E extends DetectedEvent> {
/**
* 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<E extends DetectedEvent>(
events: readonly E[],
@@ -193,6 +212,13 @@ export function buildScannerMatches<E extends DetectedEvent>(
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<E extends DetectedEvent>(
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<ScoreboardBattleLogReplayData>)
: 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<E extends DetectedEvent>(
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<ScoreboardBattleLogReplayData>)
| undefined {
if (!scoreboard || !HISTORY_SCOREBOARD_EVENT_TYPES.includes(scoreboard.type))
return undefined;
return scoreboard.data as ScoreboardBattleLogData &
Partial<ScoreboardBattleLogReplayData>;
}
/**
* 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<E extends DetectedEvent>(
built: readonly BuiltMatch<E>[],
event: E,
): BuiltMatch<E> | 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[],

View File

@@ -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;