mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-24 04:07:14 -05:00
Fixes from testing
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user