mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-13 06:36:49 -05:00
1465 lines
49 KiB
TypeScript
1465 lines
49 KiB
TypeScript
/**
|
|
* Groups a detected-event timeline into ScannerMatch objects
|
|
* (scanner-match.ts). A MapStart opens a match, a scoreboard-type event
|
|
* closes one, and deaths in between belong to it; a scoreboard with no
|
|
* preceding MapStart claims the last 8 minutes of deaths. Without delimiters
|
|
* (casted footage) minimaps group per map by stage change and time gap. A
|
|
* match is emitted only when a scoreboard or minimaps back it, regardless of
|
|
* lobby/outcome — `ingestSkipReasons` filters those. Deaths are harvested
|
|
* onto player rows as enemy builds (ability-harvest.ts).
|
|
*/
|
|
import type {
|
|
AbilityWithUnknown,
|
|
MainWeaponId,
|
|
StageId,
|
|
} from "~/modules/in-game-lists/types";
|
|
import {
|
|
type GearMains,
|
|
harvestAbilities,
|
|
harvestCardMains,
|
|
} from "./ability-harvest";
|
|
import { DEATH_EVENT_TYPE, type DeathData } from "./detectors/death/index";
|
|
import { KILL_EVENT_TYPE, type KillData } from "./detectors/kill/index";
|
|
import {
|
|
MAP_START_EVENT_TYPE,
|
|
type MapStartData,
|
|
} from "./detectors/map-start/index";
|
|
import {
|
|
MINIMAP_EVENT_TYPE,
|
|
type MinimapData,
|
|
} from "./detectors/minimap/index";
|
|
import {
|
|
OBJECTIVE_EVENT_TYPE,
|
|
type ObjectiveData,
|
|
} from "./detectors/objective/index";
|
|
import {
|
|
PLAYER_STATUS_EVENT_TYPE,
|
|
type PlayerStatusData,
|
|
type PlayerStatusFlags,
|
|
} from "./detectors/objective/player-status";
|
|
import {
|
|
STRIP_WEAPONS_EVENT_TYPE,
|
|
type StripWeaponsData,
|
|
} from "./detectors/objective/strip-weapons";
|
|
import { SCOREBOARD_EVENT_TYPES } from "./detectors/registry";
|
|
import type { ScoreboardData } from "./detectors/scoreboard/index";
|
|
import {
|
|
SCOREBOARD_BATTLE_LOG_EVENT_TYPE,
|
|
type ScoreboardBattleLogData,
|
|
} from "./detectors/scoreboard-battle-log/index";
|
|
import {
|
|
SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE,
|
|
type ScoreboardBattleLogReplayData,
|
|
} from "./detectors/scoreboard-battle-log-replay/index";
|
|
import type { DetectedEvent } from "./detectors/types";
|
|
import { hueDistance, hueOf, type InkRgb } from "./ink-color";
|
|
import { parseReplayTimestamp } from "./replay-time";
|
|
import type {
|
|
ScannerMatch,
|
|
ScannerMatchKill,
|
|
ScannerMatchObjective,
|
|
ScannerMatchObjectiveSample,
|
|
ScannerMatchPlayer,
|
|
ScannerMatchPlayerStatus,
|
|
ScannerMatchPlayerStatusSample,
|
|
ScannerMatchTeam,
|
|
} from "./scanner-match";
|
|
import {
|
|
applyPermutation,
|
|
IDENTITY_PERMUTATION,
|
|
nameSlotRowPermutation,
|
|
type SlotRowPermutation,
|
|
weaponSlotRowPermutation,
|
|
} from "./slot-row-assignment";
|
|
import { editDistance, matchKey } from "./text";
|
|
|
|
/** The lobby header value private battles (tournament games) carry. */
|
|
const TOURNAMENT_LOBBY = "PRIVATE";
|
|
|
|
/** How far back a scoreboard with no MapStart claims deaths: matches run well under 8 min. */
|
|
const FALLBACK_WINDOW_SECONDS = 480;
|
|
|
|
/** Minimaps further apart than this cannot be the same game, even on the same stage. */
|
|
const MATCH_GAP_SECONDS = 300;
|
|
|
|
const PLAYERS_PER_TEAM = 4;
|
|
|
|
/**
|
|
* Slack before calling a match a disconnect: a counter read is a snapshot of
|
|
* moving numbers, and the results screen is read seconds after the last whistle.
|
|
*/
|
|
const EARLY_END_MARGIN_SECONDS = 10;
|
|
|
|
/**
|
|
* Minimum hue distance between the two team inks before color orients counter
|
|
* reads: attested pairs measure >130° apart, so a closer seed pair is a misread
|
|
* and orientation falls back to the as-read arrangement.
|
|
*/
|
|
const MIN_TEAM_HUE_SEPARATION = 30;
|
|
|
|
/**
|
|
* A read whose projected clock zero (`t + time`) sits further than this from
|
|
* the match's dominant projection came off a broadcast replay. Live projections
|
|
* jitter a couple of seconds (both clocks round to whole seconds); attested
|
|
* replay wipes land a minute or more away.
|
|
*/
|
|
const REPLAY_ANCHOR_TOLERANCE_SECONDS = 10;
|
|
|
|
/**
|
|
* Dead-flag runs whose flank-to-flank span is shorter than these are flipped
|
|
* to their surroundings: the fastest respawn is 3.5s, so no true dead stretch
|
|
* is shorter, while a respawned player CAN be re-splatted fast (spawncamps),
|
|
* so the alive floor stays a conservative 2s. Judging by the flank-to-flank
|
|
* span keeps sparse sampling honest: a lone dead read between far-apart reads
|
|
* spans wide and is left alone.
|
|
*/
|
|
const DEAD_RUN_MIN_SECONDS = 3.5;
|
|
const ALIVE_RUN_MIN_SECONDS = 2;
|
|
|
|
/**
|
|
* Regaining a used special takes at least this long (nothing charges off ~10s
|
|
* of painting even with max Special Charge Up), so a not-ready run flanked by
|
|
* ready reads closer than this, with no death inside, is a misread gap (the
|
|
* ready wash pulses through a dim trough; overlays clip icons) and is bridged.
|
|
*/
|
|
const SPECIAL_REGAIN_MIN_SECONDS = 10;
|
|
|
|
/**
|
|
* Kill-feed stack reads further apart than this show independent rows even
|
|
* when the names repeat: a splatted player respawns in ~8.5s, so a repeated
|
|
* name inside it is the same row still up. How long a row stays up is
|
|
* unattested beyond single frames; a row outliving this would count twice.
|
|
*/
|
|
const KILL_ROW_LIFETIME_SECONDS = 8;
|
|
|
|
/** Name similarity (1 - edits / length) at which two stack reads show the same row. */
|
|
const KILL_SAME_ROW_MIN_SIMILARITY = 0.7;
|
|
|
|
export interface BuiltMatch<E extends DetectedEvent> {
|
|
match: ScannerMatch;
|
|
/** input events the match was built from, chronological — the send-status unit for callers */
|
|
sources: E[];
|
|
}
|
|
|
|
/**
|
|
* Splits a timeline into ScannerMatch objects, chronological. Event types that
|
|
* identify no match (ScoreboardOwn) are ignored. Every input event ends up in
|
|
* at most one match's `sources`.
|
|
*/
|
|
export function buildScannerMatches<E extends DetectedEvent>(
|
|
events: readonly E[],
|
|
): BuiltMatch<E>[] {
|
|
const sorted = events.toSorted((a, b) => a.t - b.t);
|
|
const built: BuiltMatch<E>[] = [];
|
|
const nextStage = buildNextStageMap(sorted);
|
|
|
|
let open: OpenMatch<E> | null = null;
|
|
// deaths/objective/status reads seen with no match open to anchor them yet
|
|
let orphanDeaths: E[] = [];
|
|
let orphanObjectives: E[] = [];
|
|
let orphanPlayerStatuses: E[] = [];
|
|
let orphanStripWeapons: E[] = [];
|
|
let orphanKills: E[] = [];
|
|
const finalize = (): void => {
|
|
if (!open) return;
|
|
if (open.scoreboard || open.minimaps.length > 0) {
|
|
built.push(toBuiltMatch(open));
|
|
}
|
|
open = null;
|
|
};
|
|
|
|
for (const event of sorted) {
|
|
if (event.type === MAP_START_EVENT_TYPE) {
|
|
// a new match intro abandons any match whose scoreboard was missed
|
|
finalize();
|
|
open = startMatch();
|
|
open.mapStart = event;
|
|
vote(open.stageVotes, (event.data as MapStartData).stage);
|
|
orphanDeaths = [];
|
|
orphanObjectives = [];
|
|
orphanPlayerStatuses = [];
|
|
orphanStripWeapons = [];
|
|
orphanKills = [];
|
|
} else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) {
|
|
if (!open) {
|
|
open = startMatch();
|
|
open.deaths = orphanDeaths.filter(
|
|
(death) => event.t - death.t <= FALLBACK_WINDOW_SECONDS,
|
|
);
|
|
open.objectives = orphanObjectives.filter(
|
|
(objective) => event.t - objective.t <= FALLBACK_WINDOW_SECONDS,
|
|
);
|
|
open.playerStatuses = orphanPlayerStatuses.filter(
|
|
(status) => event.t - status.t <= FALLBACK_WINDOW_SECONDS,
|
|
);
|
|
open.stripWeapons = orphanStripWeapons.filter(
|
|
(read) => event.t - read.t <= FALLBACK_WINDOW_SECONDS,
|
|
);
|
|
open.kills = orphanKills.filter(
|
|
(read) => event.t - read.t <= FALLBACK_WINDOW_SECONDS,
|
|
);
|
|
}
|
|
open.scoreboard = event;
|
|
vote(open.stageVotes, (event.data as ScoreboardData).stage);
|
|
finalize();
|
|
orphanDeaths = [];
|
|
orphanObjectives = [];
|
|
orphanPlayerStatuses = [];
|
|
orphanStripWeapons = [];
|
|
orphanKills = [];
|
|
} else if (event.type === MINIMAP_EVENT_TYPE) {
|
|
const stage = (event.data as MinimapData).stage;
|
|
if (open) {
|
|
// a stage change only splits when the next read doesn't refute it: a
|
|
// lone disagreeing frame is a misread folded in as a minority vote
|
|
const current = leadingStage(open.stageVotes);
|
|
const stageChanged =
|
|
current !== null &&
|
|
stage !== null &&
|
|
stage !== current &&
|
|
(nextStage.get(event) ?? stage) === stage;
|
|
const gapTooBig =
|
|
open.lastMinimapT !== null &&
|
|
event.t - open.lastMinimapT > MATCH_GAP_SECONDS;
|
|
if (stageChanged || gapTooBig) finalize();
|
|
}
|
|
open ??= startMatch();
|
|
open.minimaps.push(event);
|
|
open.lastMinimapT = event.t;
|
|
vote(open.stageVotes, stage);
|
|
} else if (event.type === DEATH_EVENT_TYPE) {
|
|
(open?.deaths ?? orphanDeaths).push(event);
|
|
} else if (event.type === OBJECTIVE_EVENT_TYPE) {
|
|
(open?.objectives ?? orphanObjectives).push(event);
|
|
} else if (event.type === PLAYER_STATUS_EVENT_TYPE) {
|
|
(open?.playerStatuses ?? orphanPlayerStatuses).push(event);
|
|
} else if (event.type === STRIP_WEAPONS_EVENT_TYPE) {
|
|
(open?.stripWeapons ?? orphanStripWeapons).push(event);
|
|
} else if (event.type === KILL_EVENT_TYPE) {
|
|
(open?.kills ?? orphanKills).push(event);
|
|
}
|
|
}
|
|
finalize();
|
|
|
|
return built;
|
|
}
|
|
|
|
/** Why a built match is held back from /ingest; absent = it is sent. */
|
|
export type IngestSkipReason =
|
|
/** not a tournament (Private Battle) game */
|
|
| "lobby"
|
|
/** a disconnect ended it before it could be decided */
|
|
| "disconnect";
|
|
|
|
/**
|
|
* Which built matches are not worth sending to /ingest, and why: non-tournament
|
|
* lobbies (unread lobbies get the benefit of the doubt), and games a disconnect
|
|
* cut short — counter reads show the game couldn't have ended on its own
|
|
* (`endedEarly`), or the same map/mode was replayed right after with a score.
|
|
* Replay evidence only arrives after the fact, so a live scan may already have
|
|
* sent the abandoned game; the counter-read check catches it in the moment.
|
|
*/
|
|
export function ingestSkipReasons<E extends DetectedEvent>(
|
|
built: readonly BuiltMatch<E>[],
|
|
): Map<BuiltMatch<E>, IngestSkipReason> {
|
|
const reasons = new Map<BuiltMatch<E>, IngestSkipReason>();
|
|
for (const [index, candidate] of built.entries()) {
|
|
const { match } = candidate;
|
|
if (match.lobby !== null && match.lobby !== TOURNAMENT_LOBBY) {
|
|
reasons.set(candidate, "lobby");
|
|
} else if (endedEarly(match) || wasReplayed(built, index)) {
|
|
reasons.set(candidate, "disconnect");
|
|
}
|
|
}
|
|
return reasons;
|
|
}
|
|
|
|
/**
|
|
* Objective-counter, player-status and strip-weapon reads on a match whose
|
|
* detected mode is not Splat Zones — the SZ parser (the only one so far)
|
|
* misreading another mode's overlay. The builder already leaves such a match's
|
|
* `objective`/`playerStatus` null; callers should delete these from their stores.
|
|
*/
|
|
export function invalidObjectiveEvents<E extends DetectedEvent>(
|
|
built: readonly BuiltMatch<E>[],
|
|
): E[] {
|
|
return built
|
|
.filter((b) => b.match.mode !== null && b.match.mode !== "SZ")
|
|
.flatMap((b) =>
|
|
b.sources.filter(
|
|
(event) =>
|
|
event.type === OBJECTIVE_EVENT_TYPE ||
|
|
event.type === PLAYER_STATUS_EVENT_TYPE ||
|
|
event.type === STRIP_WEAPONS_EVENT_TYPE,
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A disconnect ended the match before it was decided: a results screen with no
|
|
* score, and the last counter read still needed more game than the footage
|
|
* gave it — a game ends no sooner than the clock running out or the lower
|
|
* counter falling to zero at its 1/s cap (penalty worked off first).
|
|
*/
|
|
function endedEarly(match: ScannerMatch): boolean {
|
|
// no results screen at all: an unfinished scan, not an unfinished game
|
|
if (match.winner === null) return false;
|
|
if (match.matchScores !== null) return false;
|
|
const lastSample = match.objective?.samples.at(-1);
|
|
if (!lastSample || match.endsAt === null) return false;
|
|
|
|
const soonestEnd = secondsUntilSoonestEnd(lastSample);
|
|
if (soonestEnd === null) return false;
|
|
|
|
const secondsLeftInFootage = match.endsAt - lastSample.t;
|
|
return soonestEnd - secondsLeftInFootage > EARLY_END_MARGIN_SECONDS;
|
|
}
|
|
|
|
function secondsUntilSoonestEnd(
|
|
sample: ScannerMatchObjectiveSample,
|
|
): number | null {
|
|
const knockouts = sample.score.map((score, team) =>
|
|
score === null ? null : score + (sample.penalty[team] ?? 0),
|
|
);
|
|
const seconds = [sample.time, ...knockouts].filter(
|
|
(value): value is number => value !== null,
|
|
);
|
|
return seconds.length > 0 ? Math.min(...seconds) : null;
|
|
}
|
|
|
|
/**
|
|
* Whether the scoreless match at `index` was played again right after: the
|
|
* following matches on the same mode and stage are the same game restarted, so
|
|
* one of them reaching a score means the earlier attempts were disconnects.
|
|
* The run stops at the first other map.
|
|
*/
|
|
function wasReplayed<E extends DetectedEvent>(
|
|
built: readonly BuiltMatch<E>[],
|
|
index: number,
|
|
): boolean {
|
|
const { match } = built[index]!;
|
|
if (match.matchScores !== null) return false;
|
|
if (match.mode === null || match.stage === null) return false;
|
|
|
|
for (const later of built.slice(index + 1)) {
|
|
if (later.match.mode !== match.mode || later.match.stage !== match.stage) {
|
|
return false;
|
|
}
|
|
if (later.match.matchScores !== null) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** A match being accumulated as the timeline is walked. */
|
|
interface OpenMatch<E extends DetectedEvent> {
|
|
mapStart: E | null;
|
|
minimaps: E[];
|
|
deaths: E[];
|
|
/** objective-counter reads; become the match's `objective` samples */
|
|
objectives: E[];
|
|
/** icon-strip reads; become the match's `playerStatus` samples */
|
|
playerStatuses: E[];
|
|
/** sampled per-slot weapon evidence for the slot→row assignment */
|
|
stripWeapons: E[];
|
|
/** kill-feed stack reads; become the match's `kills` */
|
|
kills: E[];
|
|
scoreboard: E | null;
|
|
/**
|
|
* per-stage read counts (a MapStart's stage seeds it); the plurality winner
|
|
* delimits same-vs-next map so one misread frame can't poison the match
|
|
*/
|
|
stageVotes: Map<StageId, number>;
|
|
lastMinimapT: number | null;
|
|
}
|
|
|
|
function startMatch<E extends DetectedEvent>(): OpenMatch<E> {
|
|
return {
|
|
mapStart: null,
|
|
minimaps: [],
|
|
deaths: [],
|
|
objectives: [],
|
|
playerStatuses: [],
|
|
stripWeapons: [],
|
|
kills: [],
|
|
scoreboard: null,
|
|
stageVotes: new Map(),
|
|
lastMinimapT: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* For each minimap event, the next minimap's non-null stage read — the
|
|
* refutation signal for the stage-change split.
|
|
*/
|
|
function buildNextStageMap<E extends DetectedEvent>(
|
|
sorted: readonly E[],
|
|
): Map<E, StageId | null> {
|
|
const nextStage = new Map<E, StageId | null>();
|
|
let carry: StageId | null = null;
|
|
for (let i = sorted.length - 1; i >= 0; i--) {
|
|
const event = sorted[i]!;
|
|
if (event.type !== MINIMAP_EVENT_TYPE) continue;
|
|
nextStage.set(event, carry);
|
|
carry = (event.data as MinimapData).stage ?? carry;
|
|
}
|
|
return nextStage;
|
|
}
|
|
|
|
function vote(votes: Map<StageId, number>, stage: StageId | null): void {
|
|
if (stage !== null) votes.set(stage, (votes.get(stage) ?? 0) + 1);
|
|
}
|
|
|
|
/** Plurality stage of the reads so far; insertion order breaks ties. */
|
|
function leadingStage(votes: Map<StageId, number>): StageId | null {
|
|
let winner: StageId | null = null;
|
|
let best = 0;
|
|
for (const [stage, count] of votes) {
|
|
if (count > best) {
|
|
winner = stage;
|
|
best = count;
|
|
}
|
|
}
|
|
return winner;
|
|
}
|
|
|
|
function toBuiltMatch<E extends DetectedEvent>(
|
|
open: OpenMatch<E>,
|
|
): BuiltMatch<E> {
|
|
const sources = [
|
|
...(open.mapStart ? [open.mapStart] : []),
|
|
...open.minimaps,
|
|
...open.deaths,
|
|
...open.objectives,
|
|
...open.playerStatuses,
|
|
...open.stripWeapons,
|
|
...open.kills,
|
|
...(open.scoreboard ? [open.scoreboard] : []),
|
|
].sort((a, b) => a.t - b.t);
|
|
|
|
const board = open.scoreboard?.data as ScoreboardData | undefined;
|
|
const start = open.mapStart?.data as MapStartData | undefined;
|
|
// the replay-browser and battle log screens both 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.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,
|
|
data: event.data as ObjectiveData,
|
|
}));
|
|
const playerStatuses = open.playerStatuses.map((event) => ({
|
|
t: event.t,
|
|
data: event.data as PlayerStatusData,
|
|
}));
|
|
const stripWeapons = open.stripWeapons.map((event) => ({
|
|
t: event.t,
|
|
data: event.data as StripWeaponsData,
|
|
}));
|
|
const minimapReads = open.minimaps.map((event) => ({
|
|
t: event.t,
|
|
data: event.data as MinimapData,
|
|
}));
|
|
const killReads = open.kills.map((event) => ({
|
|
t: event.t,
|
|
data: event.data as KillData,
|
|
}));
|
|
const minimaps = minimapReads.map((read) => read.data);
|
|
|
|
const mode = board?.mode ?? start?.mode ?? null;
|
|
// only the SZ counter is parsed: reads on a known other-mode match are
|
|
// lookalike-overlay misreads (statuses ride along with counter reads).
|
|
// Minimap card states and the kill feed are mode-agnostic and feed their
|
|
// samples regardless
|
|
const counterModeValid = mode === null || mode === "SZ";
|
|
const progress = buildProgress(
|
|
counterModeValid ? objectives : [],
|
|
counterModeValid ? playerStatuses : [],
|
|
counterModeValid ? stripWeapons : [],
|
|
minimapReads,
|
|
killReads,
|
|
board,
|
|
minimapTeamColors(minimaps),
|
|
);
|
|
|
|
const pov: ScannerMatch["pov"] =
|
|
board && board.povIndex !== null
|
|
? {
|
|
team: board.povIndex < PLAYERS_PER_TEAM ? 0 : 1,
|
|
index: board.povIndex % PLAYERS_PER_TEAM,
|
|
}
|
|
: null;
|
|
|
|
const match: ScannerMatch = {
|
|
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),
|
|
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,
|
|
// 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
|
|
// them all — casts never see one
|
|
cast:
|
|
pov === null &&
|
|
(open.minimaps.some((event) => (event.data as MinimapData).spectator) ||
|
|
playerStatuses.some((read) => read.data.cast)),
|
|
objective: progress.objective,
|
|
playerStatus: progress.playerStatus,
|
|
kills: progress.kills,
|
|
teams: board
|
|
? teamsFromScoreboard(board, deaths, minimaps, progress.minimapEnemySide)
|
|
: teamsFromMinimaps(minimaps, deaths),
|
|
winner: board ? 0 : null,
|
|
pov,
|
|
};
|
|
|
|
return { match, sources };
|
|
}
|
|
|
|
function floorOrNull(t: number | undefined): number | null {
|
|
return t === undefined ? null : Math.max(0, Math.floor(t));
|
|
}
|
|
|
|
/**
|
|
* The counter reads as `objective` samples and the icon-strip reads as
|
|
* `playerStatus` samples, both in `teams` order. POV footage keeps the POV
|
|
* side on the left plate, but casted footage reorders the plates to follow
|
|
* the specced player — so each counter read is first oriented by its sides'
|
|
* ink hues (clustered against the first read that saw both); a status read
|
|
* carries no ink and inherits the orientation of the nearest counter read in
|
|
* time (same frames). Broadcast replay wipes re-run an earlier moment with the
|
|
* HUD intact, so both series are anchored by projected clock zero (`t + time`)
|
|
* against one shared dominant projection and reads off it are dropped;
|
|
* timerless reads follow their preceding anchored neighbor. A displayed count
|
|
* never increases, so each side keeps only its longest non-increasing score
|
|
* run (blips voided, not charted). Then into `teams` order: scoreboard-closed
|
|
* is winner-first (POV seat when read; else in SZ the side whose count went
|
|
* furthest down), minimap-grouped anchors on the minimap's own-vs-enemy ink
|
|
* colors, no signal keeps the first read's arrangement. Slots keep their
|
|
* on-screen left-to-right order through a side swap (whether the game mirrors
|
|
* slot order across sides is unattested).
|
|
*
|
|
* Minimap reads contribute status samples too (card cross-out and camo
|
|
* states), interleaved on the same replay-wipe anchor. Their sides are
|
|
* own/enemy — camera-stable — so they skip cluster orientation and map to
|
|
* `teams` through the match's minimap ink anchor.
|
|
*
|
|
* Within a side the strip's slot order is the lobby seating while a results
|
|
* scoreboard re-sorts rows per game (attested in the sendou-triton VoD), so on
|
|
* a scoreboard-closed match each side's slots are reordered into row order via
|
|
* slot-row-assignment.ts: weapon votes from StripWeapons evidence plus the
|
|
* minimap's card columns (mirror the strip seating; attested for the enemy
|
|
* column, own column assumed symmetric). The POV diamond follows neither
|
|
* order, so its flags map by card name and stay as drawn when too few names
|
|
* resolve. A minimap-grouped match's samples stay as drawn by construction.
|
|
*
|
|
* Kill-feed stack reads share the replay-wipe anchor (they carry the same
|
|
* clock) and reduce to one kill per row entering a stack (deriveKills); the
|
|
* feed belongs to the POV (on a cast: specced) player, so they need no side
|
|
* orientation.
|
|
*/
|
|
function buildProgress(
|
|
objectives: readonly { t: number; data: ObjectiveData }[],
|
|
playerStatuses: readonly { t: number; data: PlayerStatusData }[],
|
|
stripWeapons: readonly { t: number; data: StripWeaponsData }[],
|
|
minimapReads: readonly { t: number; data: MinimapData }[],
|
|
killReads: readonly { t: number; data: KillData }[],
|
|
board: ScoreboardData | undefined,
|
|
minimapColors: [InkRgb | null, InkRgb | null] | null,
|
|
): {
|
|
objective: ScannerMatchObjective | null;
|
|
playerStatus: ScannerMatchPlayerStatus | null;
|
|
kills: ScannerMatchKill[] | null;
|
|
/** the `teams` side the minimap's enemy column is; null with no scoreboard */
|
|
minimapEnemySide: 0 | 1 | null;
|
|
} {
|
|
const dominant = dominantAnchorOf([
|
|
...objectives,
|
|
...playerStatuses,
|
|
...killReads,
|
|
]);
|
|
const live = withoutReplayReads(objectives, dominant);
|
|
const liveKills = withoutReplayReads(killReads, dominant);
|
|
const statusReads = [
|
|
...playerStatuses.map(
|
|
(read): StatusRead => ({
|
|
t: read.t,
|
|
fromMinimap: false,
|
|
data: read.data,
|
|
}),
|
|
),
|
|
...minimapStatusReads(minimapReads),
|
|
].sort((a, b) => a.t - b.t);
|
|
const liveStatuses = withoutReplayReads(statusReads, dominant);
|
|
|
|
const clusterHues = seedClusterHues(live);
|
|
const swapFlags = readSwapFlags(live, clusterHues);
|
|
const oriented = withMonotonicScores(orientObjectives(live, swapFlags));
|
|
|
|
const swap = board
|
|
? board.povIndex !== null
|
|
? board.povIndex >= PLAYERS_PER_TEAM
|
|
: bestCount(oriented, 1) < bestCount(oriented, 0)
|
|
: minimapAnchorSwap(clusterHues, minimapColors);
|
|
const minimapSwapped = swap !== minimapAnchorSwap(clusterHues, minimapColors);
|
|
|
|
const perms = board
|
|
? slotRowPermutations(
|
|
board,
|
|
stripWeapons,
|
|
minimapReads,
|
|
live,
|
|
swapFlags,
|
|
swap,
|
|
minimapSwapped,
|
|
)
|
|
: null;
|
|
|
|
const objective =
|
|
oriented.length === 0
|
|
? null
|
|
: {
|
|
mode: "SZ" as const,
|
|
samples: oriented.map((read): ScannerMatchObjectiveSample => {
|
|
const [a, b] = swap ? ([1, 0] as const) : ([0, 1] as const);
|
|
return {
|
|
t: Math.max(0, Math.floor(read.t)),
|
|
time: read.time,
|
|
score: [read.score[a], read.score[b]],
|
|
penalty: [read.penalty[a], read.penalty[b]],
|
|
control: [read.control[a], read.control[b]],
|
|
};
|
|
}),
|
|
};
|
|
|
|
const playerStatus =
|
|
liveStatuses.length === 0
|
|
? null
|
|
: {
|
|
samples: withShortSpecialGapsBridged(
|
|
withImpossibleDeadRunsFlipped(
|
|
liveStatuses.map((read): ScannerMatchPlayerStatusSample => {
|
|
const swapped = read.fromMinimap
|
|
? minimapSwapped
|
|
: nearestSwapFlag(live, swapFlags, read.t) !== swap;
|
|
const [a, b] = swapped ? ([1, 0] as const) : ([0, 1] as const);
|
|
const arrange = (
|
|
flags: readonly [PlayerStatusFlags, PlayerStatusFlags],
|
|
): [PlayerStatusFlags, PlayerStatusFlags] =>
|
|
[a, b].map((source, side) =>
|
|
applyPermutation(
|
|
flags[source]!,
|
|
readPermutation(perms, read, source, side as 0 | 1),
|
|
),
|
|
) as [PlayerStatusFlags, PlayerStatusFlags];
|
|
return {
|
|
t: Math.max(0, Math.floor(read.t)),
|
|
time: read.data.time,
|
|
special: arrange(read.data.special),
|
|
dead: arrange(read.data.dead),
|
|
};
|
|
}),
|
|
),
|
|
),
|
|
};
|
|
|
|
return {
|
|
objective,
|
|
playerStatus,
|
|
kills: liveKills.length === 0 ? null : deriveKills(liveKills),
|
|
minimapEnemySide: board ? (minimapSwapped ? 0 : 1) : null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* One kill per feed row entering the feed. Rows expire oldest-first and a
|
|
* single read can miss an inner row (a blurred pill ends the bottom-up scan
|
|
* early), so each read is matched newest-first as a subsequence of the rows
|
|
* still remembered (first seen within KILL_ROW_LIFETIME_SECONDS): a row
|
|
* matching a remembered one is carried, anything else is a new kill.
|
|
* Remembered rows a read fails to show stay remembered until they age out,
|
|
* so the recovered read after a truncated one re-counts nothing.
|
|
*/
|
|
function deriveKills(
|
|
reads: readonly { t: number; data: KillData }[],
|
|
): ScannerMatchKill[] {
|
|
const kills: ScannerMatchKill[] = [];
|
|
// rows believed on screen, oldest first, by the read that first saw them
|
|
let known: { name: string | null; t: number }[] = [];
|
|
for (const read of reads) {
|
|
known = known.filter((row) => read.t - row.t <= KILL_ROW_LIFETIME_SECONDS);
|
|
const names = read.data.names.toReversed();
|
|
|
|
// newest-first greedy subsequence match: a row matches the newest
|
|
// remembered row not yet claimed, skipping remembered rows this read
|
|
// failed to show
|
|
const matched = new Map<number, number>();
|
|
let j = known.length - 1;
|
|
for (let i = names.length - 1; i >= 0; i--) {
|
|
let k = j;
|
|
while (k >= 0 && !sameRowName(names[i]!, known[k]!.name)) k--;
|
|
if (k >= 0) {
|
|
matched.set(k, i);
|
|
j = k - 1;
|
|
}
|
|
}
|
|
|
|
// rebuild the remembered stack in order: unmatched remembered rows stay
|
|
// (hidden or expiring), unmatched read rows are new kills
|
|
const t = Math.max(0, Math.floor(read.t));
|
|
const next: typeof known = [];
|
|
let placed = 0;
|
|
const placeNewUpTo = (end: number): void => {
|
|
for (; placed < end; placed++) {
|
|
const name = names[placed]!;
|
|
kills.push({ t, time: read.data.time, name });
|
|
next.push({ name, t: read.t });
|
|
}
|
|
};
|
|
for (const [k, row] of known.entries()) {
|
|
const i = matched.get(k);
|
|
if (i === undefined) {
|
|
next.push(row);
|
|
continue;
|
|
}
|
|
placeNewUpTo(i);
|
|
next.push(row);
|
|
placed = i + 1;
|
|
}
|
|
placeNewUpTo(names.length);
|
|
known = next;
|
|
}
|
|
// earliest first: the stack walk already emits in feed order, the sort
|
|
// pins it as the contract
|
|
return kills.toSorted((a, b) => a.t - b.t);
|
|
}
|
|
|
|
function sameRowName(a: string | null, b: string | null): boolean {
|
|
if (a === null || b === null) return true;
|
|
const ka = matchKey(a);
|
|
const kb = matchKey(b);
|
|
const similarity =
|
|
1 - editDistance(ka, kb) / Math.max(ka.length, kb.length, 1);
|
|
return similarity >= KILL_SAME_ROW_MIN_SIMILARITY;
|
|
}
|
|
|
|
/** The slot→row permutations of a scoreboard-closed match, per source. */
|
|
interface SlotRowPerms {
|
|
/** per teams side, for strip-seated slots (the strip and card columns) */
|
|
strip: [SlotRowPermutation, SlotRowPermutation];
|
|
/** for the POV diamond's teammate flags; null = keep as drawn */
|
|
diamond: SlotRowPermutation | null;
|
|
}
|
|
|
|
/**
|
|
* One minimap card's parsed weapon next to raw strip NCC scores (~0.3-0.6 per
|
|
* candidate per read): the card parser is gated on a clean read, so one card
|
|
* outweighs a single strip sample without drowning a match's worth of them.
|
|
*/
|
|
const MINIMAP_CARD_VOTE = 1;
|
|
|
|
/**
|
|
* Accumulates the match's weapon votes (strip evidence oriented read-by-read,
|
|
* minimap cards through the minimap anchor) and solves each side's slot→row
|
|
* assignment against the scoreboard's weapons, plus the diamond's name-based one.
|
|
*/
|
|
function slotRowPermutations(
|
|
board: ScoreboardData,
|
|
stripWeapons: readonly { t: number; data: StripWeaponsData }[],
|
|
minimapReads: readonly { t: number; data: MinimapData }[],
|
|
live: readonly { t: number; data: ObjectiveData }[],
|
|
swapFlags: readonly boolean[],
|
|
swap: boolean,
|
|
minimapSwapped: boolean,
|
|
): SlotRowPerms {
|
|
const votes: Map<MainWeaponId, number>[][] = [0, 1].map(() =>
|
|
[0, 1, 2, 3].map(() => new Map<MainWeaponId, number>()),
|
|
);
|
|
const addVote = (
|
|
side: 0 | 1,
|
|
slot: number,
|
|
weaponId: MainWeaponId,
|
|
score: number,
|
|
): void => {
|
|
const slotVotes = votes[side]![slot]!;
|
|
slotVotes.set(weaponId, (slotVotes.get(weaponId) ?? 0) + score);
|
|
};
|
|
|
|
for (const read of stripWeapons) {
|
|
const swapped = nearestSwapFlag(live, swapFlags, read.t) !== swap;
|
|
for (const side of [0, 1] as const) {
|
|
const source = swapped ? ((1 - side) as 0 | 1) : side;
|
|
for (const [slot, candidates] of read.data.slots[source].entries()) {
|
|
for (const candidate of candidates ?? []) {
|
|
addVote(side, slot, candidate.weaponId, candidate.score);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// enemy cards mirror the strip seating (attested); the spectator
|
|
// screen's own column is assumed symmetric. The POV diamond is not
|
|
// strip-seated and votes for nothing.
|
|
const enemySide = minimapSwapped ? 0 : 1;
|
|
for (const read of minimapReads) {
|
|
for (const [slot, enemy] of read.data.enemies.entries()) {
|
|
if (enemy.weaponId !== null) {
|
|
addVote(enemySide, slot, enemy.weaponId, MINIMAP_CARD_VOTE);
|
|
}
|
|
}
|
|
if (!read.data.spectator) continue;
|
|
for (const [slot, mate] of read.data.teammates.entries()) {
|
|
if (mate.weaponId !== null) {
|
|
addVote(
|
|
(1 - enemySide) as 0 | 1,
|
|
slot,
|
|
mate.weaponId,
|
|
MINIMAP_CARD_VOTE,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const rowWeapons = (side: 0 | 1) =>
|
|
board.players
|
|
.slice(side * PLAYERS_PER_TEAM, (side + 1) * PLAYERS_PER_TEAM)
|
|
.map((player) => player.weaponId);
|
|
const strip = [0, 1].map((side) =>
|
|
weaponSlotRowPermutation(votes[side]!, rowWeapons(side as 0 | 1)),
|
|
) as [SlotRowPermutation, SlotRowPermutation];
|
|
|
|
const friendlySide = minimapSwapped ? 1 : 0;
|
|
const cardNames: (string | null)[] = [null, null, null, null];
|
|
for (const read of minimapReads) {
|
|
if (read.data.spectator) continue;
|
|
for (const [slot, mate] of read.data.teammates.entries()) {
|
|
cardNames[slot] ??= mate.name?.trim() || null;
|
|
}
|
|
}
|
|
const diamond = cardNames.some((name) => name !== null)
|
|
? nameSlotRowPermutation(
|
|
cardNames,
|
|
board.players
|
|
.slice(
|
|
friendlySide * PLAYERS_PER_TEAM,
|
|
(friendlySide + 1) * PLAYERS_PER_TEAM,
|
|
)
|
|
.map((player) => player.name.trim() || null),
|
|
)
|
|
: null;
|
|
|
|
return { strip, diamond };
|
|
}
|
|
|
|
/**
|
|
* The permutation a status read's `sourceSide` flags take to teams side `side`:
|
|
* strip-seated sources use the weapon-vote assignment, the POV diamond its name
|
|
* assignment; a minimap-grouped match (no perms) keeps everything as drawn.
|
|
*/
|
|
function readPermutation(
|
|
perms: SlotRowPerms | null,
|
|
read: StatusRead,
|
|
sourceSide: 0 | 1,
|
|
side: 0 | 1,
|
|
): SlotRowPermutation {
|
|
if (!perms) return IDENTITY_PERMUTATION;
|
|
if (read.fromMinimap && sourceSide === 0 && !read.spectator) {
|
|
return perms.diamond ?? IDENTITY_PERMUTATION;
|
|
}
|
|
return perms.strip[side];
|
|
}
|
|
|
|
/**
|
|
* Debounces per-slot dead flags: an interior run whose flanking opposite-state
|
|
* reads sit closer than the state could have held (DEAD/ALIVE_RUN_MIN_SECONDS)
|
|
* is a misread blip (background ink through a translucent splatted icon, a box
|
|
* over a mid-animation icon) and takes the flanking state. Edge runs stay —
|
|
* nothing attests what came before or after the match window.
|
|
*/
|
|
function withImpossibleDeadRunsFlipped(
|
|
samples: ScannerMatchPlayerStatusSample[],
|
|
): ScannerMatchPlayerStatusSample[] {
|
|
let smoothed = samples;
|
|
for (const side of [0, 1] as const) {
|
|
for (let slot = 0; slot < PLAYERS_PER_TEAM; slot++) {
|
|
// flipping one blip can expose the next (alternating flicker), so
|
|
// each slot's series is re-swept until it settles
|
|
let changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
const series = smoothed.map((sample) => sample.dead[side][slot]);
|
|
let runStart = 0;
|
|
for (let i = 1; i <= series.length; i++) {
|
|
if (i < series.length && series[i] === series[runStart]) continue;
|
|
const interior = runStart > 0 && i < series.length;
|
|
const impossiblyShort =
|
|
interior &&
|
|
smoothed[i]!.t - smoothed[runStart - 1]!.t <=
|
|
(series[runStart] ? DEAD_RUN_MIN_SECONDS : ALIVE_RUN_MIN_SECONDS);
|
|
if (impossiblyShort) {
|
|
if (smoothed === samples) {
|
|
smoothed = samples.map((sample) => ({
|
|
...sample,
|
|
dead: [[...sample.dead[0]], [...sample.dead[1]]] as [
|
|
PlayerStatusFlags,
|
|
PlayerStatusFlags,
|
|
],
|
|
}));
|
|
}
|
|
for (let j = runStart; j < i; j++) {
|
|
smoothed[j]!.dead[side][slot] = !series[runStart];
|
|
}
|
|
changed = true;
|
|
break;
|
|
}
|
|
runStart = i;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return smoothed;
|
|
}
|
|
|
|
/**
|
|
* Bridges per-slot special-ready gaps: an interior not-ready run whose flanking
|
|
* ready reads sit closer than SPECIAL_REGAIN_MIN_SECONDS, with no death inside
|
|
* to explain the loss, is a misread gap (the ready wash's dim pulse trough) and
|
|
* reads ready throughout. Short READY runs stay (a fresh special really can be
|
|
* spent within a read or two), as do edge runs.
|
|
*/
|
|
function withShortSpecialGapsBridged(
|
|
samples: ScannerMatchPlayerStatusSample[],
|
|
): ScannerMatchPlayerStatusSample[] {
|
|
let bridged = samples;
|
|
for (const side of [0, 1] as const) {
|
|
for (let slot = 0; slot < PLAYERS_PER_TEAM; slot++) {
|
|
const series = samples.map((sample) => sample.special[side][slot]);
|
|
let runStart = 0;
|
|
for (let i = 1; i <= series.length; i++) {
|
|
if (i < series.length && series[i] === series[runStart]) continue;
|
|
const interiorGap =
|
|
!series[runStart] && runStart > 0 && i < series.length;
|
|
const impossiblyShort =
|
|
interiorGap &&
|
|
samples[i]!.t - samples[runStart - 1]!.t < SPECIAL_REGAIN_MIN_SECONDS;
|
|
const diedInside =
|
|
interiorGap &&
|
|
samples.slice(runStart, i).some((sample) => sample.dead[side][slot]);
|
|
if (impossiblyShort && !diedInside) {
|
|
if (bridged === samples) {
|
|
bridged = samples.map((sample) => ({
|
|
...sample,
|
|
special: [[...sample.special[0]], [...sample.special[1]]] as [
|
|
PlayerStatusFlags,
|
|
PlayerStatusFlags,
|
|
],
|
|
}));
|
|
}
|
|
for (let j = runStart; j < i; j++) {
|
|
bridged[j]!.special[side][slot] = true;
|
|
}
|
|
}
|
|
runStart = i;
|
|
}
|
|
}
|
|
}
|
|
return bridged;
|
|
}
|
|
|
|
/** A status read from either source, sides as read (pre-orientation). */
|
|
interface StatusRead {
|
|
t: number;
|
|
/** minimap sides are own/enemy — camera-stable, unlike the HUD plates */
|
|
fromMinimap: boolean;
|
|
/** minimap reads only: the 8-card spectator screen, whose own column is card-seated like the enemy one — the POV diamond is not (see readPermutation) */
|
|
spectator?: boolean;
|
|
data: {
|
|
time: number | null;
|
|
special: [PlayerStatusFlags, PlayerStatusFlags];
|
|
dead: [PlayerStatusFlags, PlayerStatusFlags];
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The minimap reads' card states as status reads: own side first, slots in card
|
|
* order (the order `teamsFromMinimaps` seats players), absent cards padded
|
|
* false. A read that saw no cards identifies nobody and contributes nothing.
|
|
*/
|
|
function minimapStatusReads(
|
|
minimapReads: readonly { t: number; data: MinimapData }[],
|
|
): StatusRead[] {
|
|
return minimapReads.flatMap((read): StatusRead[] => {
|
|
const { teammates, enemies } = read.data;
|
|
if (teammates.length === 0 && enemies.length === 0) return [];
|
|
return [
|
|
{
|
|
t: read.t,
|
|
fromMinimap: true,
|
|
spectator: read.data.spectator,
|
|
data: {
|
|
time: null,
|
|
special: [
|
|
sideFlags(teammates, "specialReady"),
|
|
sideFlags(enemies, "specialReady"),
|
|
],
|
|
dead: [sideFlags(teammates, "dead"), sideFlags(enemies, "dead")],
|
|
},
|
|
},
|
|
];
|
|
});
|
|
}
|
|
|
|
function sideFlags(
|
|
players: readonly { dead: boolean; specialReady: boolean }[],
|
|
key: "dead" | "specialReady",
|
|
): PlayerStatusFlags {
|
|
return [0, 1, 2, 3].map(
|
|
(slot) => players[slot]?.[key] ?? false,
|
|
) as PlayerStatusFlags;
|
|
}
|
|
|
|
/**
|
|
* The cluster-orientation flag of the counter read nearest in time — status
|
|
* reads come off the same frames, so the nearest one saw the same camera
|
|
* arrangement. False when no counter read carried a flag (POV never swaps).
|
|
*/
|
|
function nearestSwapFlag(
|
|
objectives: readonly { t: number }[],
|
|
swapFlags: readonly boolean[],
|
|
t: number,
|
|
): boolean {
|
|
let best = -1;
|
|
for (const [i, read] of objectives.entries()) {
|
|
if (
|
|
best === -1 ||
|
|
Math.abs(read.t - t) < Math.abs(objectives[best]!.t - t)
|
|
) {
|
|
best = i;
|
|
}
|
|
}
|
|
return best === -1 ? false : (swapFlags[best] ?? false);
|
|
}
|
|
|
|
/** A counter read with its sides in cluster (first-read) order. */
|
|
interface OrientedObjectiveRead {
|
|
t: number;
|
|
time: number | null;
|
|
score: [number | null, number | null];
|
|
penalty: [number | null, number | null];
|
|
control: [boolean, boolean];
|
|
}
|
|
|
|
/**
|
|
* The two team-ink cluster hues, seeded from the first read that saw both sides
|
|
* far enough apart; null when none qualifies (orientation stays as read).
|
|
*/
|
|
function seedClusterHues(
|
|
objectives: readonly { data: ObjectiveData }[],
|
|
): [number, number] | null {
|
|
for (const { data } of objectives) {
|
|
const [left, right] = data.teamColor;
|
|
if (left === null || right === null) continue;
|
|
const hues: [number, number] = [hueOf(left), hueOf(right)];
|
|
if (hueDistance(hues[0], hues[1]) >= MIN_TEAM_HUE_SEPARATION) return hues;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Per-read side orientation: a read whose ink hues sit closer to the clusters
|
|
* crosswise is swapped (the cast switched the specced side). Reads with no
|
|
* readable color inherit the previous orientation — plates only rearrange with
|
|
* a camera change, which leaves the colors readable once they are back.
|
|
*/
|
|
function readSwapFlags(
|
|
objectives: readonly { t: number; data: ObjectiveData }[],
|
|
clusterHues: [number, number] | null,
|
|
): boolean[] {
|
|
let previousSwapped = false;
|
|
return objectives.map(({ data }) => {
|
|
const swapped = clusterHues
|
|
? readSwapped(data, clusterHues, previousSwapped)
|
|
: false;
|
|
previousSwapped = swapped;
|
|
return swapped;
|
|
});
|
|
}
|
|
|
|
/** The counter reads with their sides in cluster (first-read) order. */
|
|
function orientObjectives(
|
|
objectives: readonly { t: number; data: ObjectiveData }[],
|
|
swapFlags: readonly boolean[],
|
|
): OrientedObjectiveRead[] {
|
|
return objectives.map(({ t, data }, i): OrientedObjectiveRead => {
|
|
const [a, b] = swapFlags[i] ? ([1, 0] as const) : ([0, 1] as const);
|
|
return {
|
|
t,
|
|
time: data.time,
|
|
score: [data.score[a], data.score[b]],
|
|
penalty: [data.penalty[a], data.penalty[b]],
|
|
control: [data.control[a], data.control[b]],
|
|
};
|
|
});
|
|
}
|
|
|
|
function readSwapped(
|
|
data: ObjectiveData,
|
|
clusterHues: [number, number],
|
|
previousSwapped: boolean,
|
|
): boolean {
|
|
const [left, right] = data.teamColor;
|
|
if (left === null && right === null) return previousSwapped;
|
|
const identityCost =
|
|
(left ? hueDistance(hueOf(left), clusterHues[0]) : 0) +
|
|
(right ? hueDistance(hueOf(right), clusterHues[1]) : 0);
|
|
const swappedCost =
|
|
(left ? hueDistance(hueOf(left), clusterHues[1]) : 0) +
|
|
(right ? hueDistance(hueOf(right), clusterHues[0]) : 0);
|
|
if (identityCost === swappedCost) return previousSwapped;
|
|
return swappedCost < identityCost;
|
|
}
|
|
|
|
/**
|
|
* Whether the cluster order is bravo-first, judged against the minimap's ink
|
|
* colors (own then enemy) — the `teams` anchor for cast matches.
|
|
*/
|
|
function minimapAnchorSwap(
|
|
clusterHues: [number, number] | null,
|
|
minimapColors: [InkRgb | null, InkRgb | null] | null,
|
|
): boolean {
|
|
if (!clusterHues || !minimapColors) return false;
|
|
const [own, enemy] = minimapColors;
|
|
if (own === null && enemy === null) return false;
|
|
const identityCost =
|
|
(own ? hueDistance(hueOf(own), clusterHues[0]) : 0) +
|
|
(enemy ? hueDistance(hueOf(enemy), clusterHues[1]) : 0);
|
|
const swappedCost =
|
|
(own ? hueDistance(hueOf(own), clusterHues[1]) : 0) +
|
|
(enemy ? hueDistance(hueOf(enemy), clusterHues[0]) : 0);
|
|
return swappedCost < identityCost;
|
|
}
|
|
|
|
/** Componentwise mean of the minimap reads' per-side ink colors; null when unread. */
|
|
function minimapTeamColors(
|
|
minimaps: readonly MinimapData[],
|
|
): [InkRgb | null, InkRgb | null] | null {
|
|
if (minimaps.length === 0) return null;
|
|
const sides = [0, 1].map((side): InkRgb | null => {
|
|
const colors = minimaps
|
|
.map((minimap) => minimap.teamColors[side as 0 | 1])
|
|
.filter((color): color is InkRgb => color !== null);
|
|
if (colors.length === 0) return null;
|
|
return {
|
|
r: Math.round(colors.reduce((sum, c) => sum + c.r, 0) / colors.length),
|
|
g: Math.round(colors.reduce((sum, c) => sum + c.g, 0) / colors.length),
|
|
b: Math.round(colors.reduce((sum, c) => sum + c.b, 0) / colors.length),
|
|
};
|
|
}) as [InkRgb | null, InkRgb | null];
|
|
return sides[0] === null && sides[1] === null ? null : sides;
|
|
}
|
|
|
|
/**
|
|
* The dominant clock-zero projection across every timed read; null when none.
|
|
* Counter and status reads project the same clock, so one anchor voids both.
|
|
*/
|
|
function dominantAnchorOf(
|
|
reads: readonly { t: number; data: { time: number | null } }[],
|
|
): number | null {
|
|
const anchors = reads.flatMap((read) =>
|
|
read.data.time !== null ? [read.t + read.data.time] : [],
|
|
);
|
|
return anchors.length === 0 ? null : dominantAnchor(anchors);
|
|
}
|
|
|
|
/**
|
|
* Drops reads taken off broadcast replay wipes: `t + time` projects the moment
|
|
* the match timer hits zero, constant across a live game but far off when the
|
|
* broadcast re-runs an earlier moment. Only reads near the dominant projection
|
|
* (the live series outnumbers ~30s replay clips) are kept; a timerless read
|
|
* shares the fate of its preceding anchored neighbor (the following one for a
|
|
* timerless head), so an unreadable timer never voids live reads.
|
|
*/
|
|
function withoutReplayReads<
|
|
T extends { t: number; data: { time: number | null } },
|
|
>(reads: readonly T[], dominant: number | null): T[] {
|
|
if (dominant === null) return [...reads];
|
|
const anchored = reads.flatMap((read, i) =>
|
|
read.data.time !== null ? [{ i, anchor: read.t + read.data.time }] : [],
|
|
);
|
|
if (anchored.length === 0) return [...reads];
|
|
|
|
const keptAnchored = new Map(
|
|
anchored.map(({ i, anchor }) => [
|
|
i,
|
|
Math.abs(anchor - dominant) <= REPLAY_ANCHOR_TOLERANCE_SECONDS,
|
|
]),
|
|
);
|
|
|
|
let previousKept = keptAnchored.get(anchored[0]!.i)!;
|
|
return reads.filter((_, i) => {
|
|
previousKept = keptAnchored.get(i) ?? previousKept;
|
|
return previousKept;
|
|
});
|
|
}
|
|
|
|
/** The clock-zero projection supported by the most reads within tolerance. */
|
|
function dominantAnchor(anchors: readonly number[]): number {
|
|
const sorted = anchors.toSorted((a, b) => a - b);
|
|
let best = sorted[0]!;
|
|
let bestRunLength = 0;
|
|
let lo = 0;
|
|
for (let hi = 0; hi < sorted.length; hi++) {
|
|
while (sorted[hi]! - sorted[lo]! > REPLAY_ANCHOR_TOLERANCE_SECONDS) lo++;
|
|
if (hi - lo + 1 > bestRunLength) {
|
|
bestRunLength = hi - lo + 1;
|
|
best = sorted[Math.floor((lo + hi) / 2)]!;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/**
|
|
* Voids score reads that contradict SZ's countdown: per side, only the longest
|
|
* non-increasing subsequence of readable scores is kept and every read off it
|
|
* gets that side's score nulled (penalty/control stand). A misread (a truncated
|
|
* "50" charted as 0, a stray 100) is always the minority, so it is what drops.
|
|
*/
|
|
function withMonotonicScores(
|
|
oriented: readonly OrientedObjectiveRead[],
|
|
): OrientedObjectiveRead[] {
|
|
const smoothed = oriented.map((read) => ({
|
|
...read,
|
|
score: [...read.score] as [number | null, number | null],
|
|
}));
|
|
for (const side of [0, 1] as const) {
|
|
const readIndices = smoothed.flatMap((read, i) =>
|
|
read.score[side] !== null ? [i] : [],
|
|
);
|
|
const kept = longestNonIncreasingRun(
|
|
readIndices.map((i) => smoothed[i]!.score[side]!),
|
|
);
|
|
for (const [k, i] of readIndices.entries()) {
|
|
if (!kept.has(k)) smoothed[i]!.score[side] = null;
|
|
}
|
|
}
|
|
return smoothed;
|
|
}
|
|
|
|
/** Indices of one longest non-increasing subsequence of `values`. */
|
|
function longestNonIncreasingRun(values: readonly number[]): Set<number> {
|
|
const lengths = new Array<number>(values.length).fill(1);
|
|
const prev = new Array<number>(values.length).fill(-1);
|
|
let bestEnd = values.length > 0 ? 0 : -1;
|
|
for (let i = 0; i < values.length; i++) {
|
|
for (let j = 0; j < i; j++) {
|
|
if (values[j]! >= values[i]! && lengths[j]! + 1 > lengths[i]!) {
|
|
lengths[i] = lengths[j]! + 1;
|
|
prev[i] = j;
|
|
}
|
|
}
|
|
if (lengths[i]! > lengths[bestEnd]!) bestEnd = i;
|
|
}
|
|
const kept = new Set<number>();
|
|
for (let i = bestEnd; i !== -1; i = prev[i]!) kept.add(i);
|
|
return kept;
|
|
}
|
|
|
|
/** The lowest count a side ever showed; Infinity when never read. */
|
|
function bestCount(
|
|
oriented: readonly OrientedObjectiveRead[],
|
|
side: 0 | 1,
|
|
): number {
|
|
return Math.min(
|
|
...oriented.map((read) => read.score[side] ?? Number.POSITIVE_INFINITY),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* When the match was played: a replay/battle log screen's on-screen recording
|
|
* timestamp (anchored to when the screen was seen, not a later send), else the
|
|
* 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 {
|
|
if (!scoreboard) return null;
|
|
const detectedAt = (scoreboard as { detectedAt?: number }).detectedAt ?? null;
|
|
if (timestamped?.timestamp) {
|
|
const recorded = parseReplayTimestamp(timestamped.timestamp, {
|
|
now: detectedAt ?? undefined,
|
|
});
|
|
if (recorded !== null) return recorded;
|
|
}
|
|
return detectedAt;
|
|
}
|
|
|
|
function teamsFromScoreboard(
|
|
board: ScoreboardData,
|
|
deaths: readonly DeathData[],
|
|
minimaps: readonly MinimapData[],
|
|
minimapEnemySide: 0 | 1 | null,
|
|
): [ScannerMatchTeam, ScannerMatchTeam] {
|
|
const abilities = harvestAbilities(board.players, deaths);
|
|
const cardMains =
|
|
minimapEnemySide !== null
|
|
? minimapMainsByRow(board, minimaps, minimapEnemySide)
|
|
: new Map<number, GearMains>();
|
|
const players = board.players.map((player, i): ScannerMatchPlayer => {
|
|
const build = mergeBuild(abilities.get(i), cardMains.get(i));
|
|
return {
|
|
name: player.name.trim() || null,
|
|
weaponId: player.weaponId,
|
|
paint: player.paint,
|
|
ka: player.ka,
|
|
d: player.d,
|
|
s: player.s,
|
|
...(build ? { abilities: build } : null),
|
|
};
|
|
});
|
|
return [
|
|
{ players: players.slice(0, PLAYERS_PER_TEAM) },
|
|
{ players: players.slice(PLAYERS_PER_TEAM) },
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Players merged across a match's minimap frames, alpha then bravo: weapons and
|
|
* names are fixed for a match, so a slot missed in one frame is filled from
|
|
* another (first read wins).
|
|
*/
|
|
function teamsFromMinimaps(
|
|
frames: readonly MinimapData[],
|
|
deaths: readonly DeathData[],
|
|
): [ScannerMatchTeam, ScannerMatchTeam] {
|
|
const alpha = mergeSlots(frames.map((frame) => frame.teammates));
|
|
const bravo = mergeSlots(frames.map((frame) => frame.enemies));
|
|
|
|
const players = [...alpha, ...bravo];
|
|
const abilities = harvestAbilities(players, deaths);
|
|
const withAbilities = players.map((player, i) => {
|
|
const build = abilities.get(i);
|
|
return build ? { ...player, abilities: build } : player;
|
|
});
|
|
|
|
return [
|
|
{ players: withAbilities.slice(0, alpha.length) },
|
|
{ players: withAbilities.slice(alpha.length) },
|
|
];
|
|
}
|
|
|
|
/** For each slot index, the first frame's non-null read of each field. */
|
|
function mergeSlots(
|
|
frames: Array<Array<MinimapCardRead>>,
|
|
): ScannerMatchPlayer[] {
|
|
const width = Math.max(0, ...frames.map((frame) => frame.length));
|
|
const out: ScannerMatchPlayer[] = [];
|
|
for (let i = 0; i < width; i++) {
|
|
const reads = frames
|
|
.map((frame) => frame[i])
|
|
.filter((read) => read !== undefined);
|
|
const mains = mergeMains(reads.map((read) => read.abilities));
|
|
out.push({
|
|
name:
|
|
reads
|
|
.map((read) => read.name?.trim() || null)
|
|
.find((n) => n !== null) ?? null,
|
|
weaponId:
|
|
reads.map((read) => read.weaponId).find((id) => id !== null) ?? null,
|
|
paint: null,
|
|
ka: null,
|
|
d: null,
|
|
s: null,
|
|
...(mains ? { abilities: mainsAsRows(mains) } : null),
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** the gear slots a build has, in card/death-screen order */
|
|
const GEAR_SLOTS = [0, 1, 2];
|
|
|
|
interface MinimapCardRead {
|
|
name: string | null;
|
|
weaponId: MainWeaponId | null;
|
|
abilities: GearMains;
|
|
}
|
|
|
|
/**
|
|
* Gear mains per scoreboard row, harvested from the minimap cards. A card's
|
|
* drawn position is no seat (frames leave absent cards out of their columns),
|
|
* so cards identify their row by name and weapon within their own column's
|
|
* side (own/enemy is camera-stable, unlike the HUD plates).
|
|
*/
|
|
function minimapMainsByRow(
|
|
board: ScoreboardData,
|
|
minimaps: readonly MinimapData[],
|
|
enemySide: 0 | 1,
|
|
): Map<number, GearMains> {
|
|
const cards: [MinimapCardRead[], MinimapCardRead[]] = [[], []];
|
|
for (const frame of minimaps) {
|
|
cards[enemySide].push(...frame.enemies);
|
|
cards[enemySide === 0 ? 1 : 0].push(...frame.teammates);
|
|
}
|
|
const mains = new Map<number, GearMains>();
|
|
for (const side of [0, 1] as const) {
|
|
const rows = board.players.slice(
|
|
side * PLAYERS_PER_TEAM,
|
|
(side + 1) * PLAYERS_PER_TEAM,
|
|
);
|
|
for (const [row, build] of harvestCardMains(rows, cards[side])) {
|
|
mains.set(side * PLAYERS_PER_TEAM + row, build);
|
|
}
|
|
}
|
|
return mains;
|
|
}
|
|
|
|
/**
|
|
* The gear mains a set of card reads agree on: badges come and go with
|
|
* cross-outs and camo, so each slot takes its first identified read. Null when
|
|
* no read identified any of the three.
|
|
*/
|
|
function mergeMains(reads: readonly GearMains[]): GearMains | null {
|
|
const mains = GEAR_SLOTS.map((slot) => {
|
|
const read = reads
|
|
.map((abilities) => abilities[slot] ?? null)
|
|
.filter((ability) => ability !== null);
|
|
return read.find((ability) => ability !== "UNKNOWN") ?? read[0] ?? null;
|
|
});
|
|
return mains.some((ability) => ability !== null) ? mains : null;
|
|
}
|
|
|
|
/** Minimap cards show no sub slots, so each gear row holds its main alone. */
|
|
function mainsAsRows(mains: GearMains): AbilityWithUnknown[][] {
|
|
return mains.map((main) => [main ?? "UNKNOWN"]);
|
|
}
|
|
|
|
/**
|
|
* One player's gear rows: death screens read whole rows (main and subs),
|
|
* minimap cards only mains — so a death row stands and the cards fill in
|
|
* the mains it left unread.
|
|
*/
|
|
function mergeBuild(
|
|
death: AbilityWithUnknown[][] | undefined,
|
|
mains: GearMains | undefined,
|
|
): AbilityWithUnknown[][] | undefined {
|
|
if (!mains) return death;
|
|
if (!death) return mainsAsRows(mains);
|
|
return GEAR_SLOTS.map((slot) => {
|
|
const row = death[slot] ?? [];
|
|
if (row.length > 0 && row[0] !== "UNKNOWN") return row;
|
|
return [mains[slot] ?? row[0] ?? "UNKNOWN", ...row.slice(1)];
|
|
});
|
|
}
|