mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-10 19:35:46 -05:00
Handle endgame better
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user