Handle endgame better

This commit is contained in:
Kalle
2026-08-08 14:17:16 +03:00
parent 35740d35f5
commit c0d778e6cd
3 changed files with 117 additions and 8 deletions

View File

@@ -84,7 +84,11 @@ sequenceDiagram
+ casted 8-player spectator variant), `objective` (ranked counter overlay:
counts, penalties, holder, match timer — a mode-discriminated union with
only the SZ member so far). Objective reads land on `ScannerMatch` as
progress samples anchored to the game clock; a displayed count only ever
progress samples anchored to the game clock; broadcast replay wipes re-run
an earlier moment with the counter intact, so the builder keeps only the
dominant cluster of clock-zero projections (`t + time`) and drops replay
reads outright (timerless reads follow their preceding anchored
neighbor). A displayed count only ever
ticks down, so the builder keeps each side's longest non-increasing score
run and voids reads off it (surviving OCR blips chart as gaps, not dips). Each read also carries a
per-side team ink color (`core/ink-color.ts` — the plate fill in

View File

@@ -79,6 +79,15 @@ const EARLY_END_MARGIN_SECONDS = 10;
*/
const MIN_TEAM_HUE_SEPARATION = 30;
/**
* A counter read whose projected clock zero (`t + time`) sits further than
* this from the match's dominant projection was taken off a broadcast
* replay of another moment, not the live game. Live projections jitter by
* a couple of seconds (wall clock and match timer both round to whole
* seconds); attested replay wipes land a minute or more away.
*/
const REPLAY_ANCHOR_TOLERANCE_SECONDS = 10;
export interface BuiltMatch<E extends DetectedEvent> {
match: ScannerMatch;
/**
@@ -414,7 +423,11 @@ function floorOrNull(t: number | undefined): number | null {
* the left plate is the POV/alpha side for the whole game, but casted
* footage reorders the plates to follow the specced player — so each read
* is first oriented by its sides' team ink hues (clustered against the
* first read that saw both), making the series side-stable. A displayed
* first read that saw both), making the series side-stable. Broadcast
* replay wipes re-run an earlier moment with the counter overlay intact,
* so reads are first anchored by their projected clock zero (`t + time`),
* and only the dominant anchor cluster — the live game — survives;
* timerless reads follow their preceding anchored neighbor. A displayed
* count never increases (it shows the team's best remaining), so each
* side's series then keeps only its longest non-increasing run of scores —
* surviving OCR blips are voided rather than charted. The whole
@@ -431,10 +444,9 @@ function buildObjective(
): ScannerMatchObjective | null {
if (objectives.length === 0) return null;
const clusterHues = seedClusterHues(objectives);
const oriented = withMonotonicScores(
orientByTeamColor(objectives, clusterHues),
);
const live = withoutReplayReads(objectives);
const clusterHues = seedClusterHues(live);
const oriented = withMonotonicScores(orientByTeamColor(live, clusterHues));
const swap = board
? board.povIndex !== null
@@ -569,6 +581,55 @@ function minimapTeamColors(
return sides[0] === null && sides[1] === null ? null : sides;
}
/**
* Drops reads taken off broadcast replay wipes: `t + time` projects the
* wall-clock moment the match timer reaches zero, which stays constant
* across a live game but lands far away when the broadcast re-runs an
* earlier moment, clock and all. Only reads near the dominant projection
* (the live series always 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 — or as yet
* unattested overtime — timer display never voids live reads.
*/
function withoutReplayReads<T extends { t: number; data: ObjectiveData }>(
objectives: readonly T[],
): T[] {
const anchored = objectives.flatMap((read, i) =>
read.data.time !== null ? [{ i, anchor: read.t + read.data.time }] : [],
);
if (anchored.length === 0) return [...objectives];
const dominant = dominantAnchor(anchored.map((read) => read.anchor));
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 objectives.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 bestCount = 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 > bestCount) {
bestCount = 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 the readable scores is kept and

View File

@@ -51,10 +51,12 @@ function death(
return { type: "Death", t, confidence: 0.9, data };
}
// default timer stays consistent with t (clock zero projected at 300s of
// footage) so reads register as one live game to the replay filter
function objective(
t: number,
{
time = 215 as number | null,
time = (300 - Math.round(t)) as number | null,
score = [95, 53] as [number | null, number | null],
penalty = [null, null] as [number | null, number | null],
control = [true, false] as [boolean, boolean],
@@ -260,7 +262,7 @@ test("a losing-side pov swaps objective samples into teams order", () => {
]);
assert.deepEqual(built[0]!.match.objective!.samples[0], {
t: 120,
time: 215,
time: 180,
score: [53, 95],
penalty: [null, 4],
control: [false, true],
@@ -398,6 +400,48 @@ test("a transient score dip is voided against the surrounding countdown", () =>
assert.deepEqual(samples[1]!.penalty, [4, null]);
});
test("post-game replay wipes are dropped by their clock projection", () => {
const built = buildScannerMatches([
mapStart(0),
objective(60, { score: [80, 6], penalty: [null, 56] }),
objective(61, { score: [78, 6], penalty: [null, 56] }),
objective(62, { score: [77, 6], penalty: [null, 56] }),
// broadcast re-runs the opening moments, clock jumped back to 4:51
objective(90, { time: 291, score: [100, 100] }),
objective(91, { time: 290, score: [99, 100] }),
// then the closing moments again
objective(100, { time: 62, score: [6, 77], penalty: [56, null] }),
objective(101, { time: 61, score: [6, 75], penalty: [56, null] }),
scoreboard(300),
]);
const samples = built[0]!.match.objective!.samples;
assert.deepEqual(
samples.map((sample) => sample.t),
[60, 61, 62],
);
assert.deepEqual(samples.at(-1)!.penalty, [null, 56]);
});
test("timerless reads share their live neighbor's replay-filter fate", () => {
const built = buildScannerMatches([
mapStart(0),
// timerless head inherits from the first anchored read
objective(59, { time: null, score: [82, 6] }),
objective(60, { score: [80, 6] }),
objective(61, { score: [79, 6] }),
objective(62, { time: null, score: [78, 6] }),
// replay wipe, including a timerless read inside it
objective(90, { time: 291, score: [100, 100] }),
objective(91, { time: null, score: [99, 100] }),
objective(92, { time: 289, score: [97, 100] }),
scoreboard(300),
]);
assert.deepEqual(
built[0]!.match.objective!.samples.map((sample) => sample.t),
[59, 60, 61, 62],
);
});
test("a stray full-count blip is voided against the surrounding countdown", () => {
const built = buildScannerMatches([
mapStart(0),