sendou.ink/app/components/objective-timeline-utils.ts
2026-08-07 20:47:19 +03:00

110 lines
3.6 KiB
TypeScript

const PENALTY_BRIDGE_SECONDS = 6;
/** The count a knockout wins at: the counter runs out and the team takes all of it. */
const FULL_COUNT = 100;
/** One penalty read: when it was made and the pill value seen (null = no pill or unreadable). */
export interface PenaltyRead {
/** whole seconds into the source (video, stream or game) the read was made at */
t: number;
penalty: number | null;
}
/**
* The penalty pill is misread for a frame or two at a time: it flickers
* between a value and null, and occasionally drops a digit ("36" read as
* "6"). Median-filters isolated outlier values, drops one-off reads with no
* nearby confirmation and carries the previous value across short null gaps
* so the band renders as one steady shape instead of a picket fence.
*
* @param reads one team's penalty reads, sorted by `t` ascending
* @returns the smoothed penalty per read, index-aligned with the input
*/
export function smoothPenalties(
reads: readonly PenaltyRead[],
): (number | null)[] {
const medianFiltered = medianFilterValues(reads.map((read) => read.penalty));
const kept = reads.map((read, i) => {
const value = medianFiltered[i]!;
if (value === null) return null;
const hasNearbyRead = reads.some(
(other, j) =>
j !== i &&
other.penalty !== null &&
Math.abs(other.t - read.t) <= PENALTY_BRIDGE_SECONDS,
);
return hasNearbyRead ? value : null;
});
const result = [...kept];
let prev = -1;
for (let i = 0; i < result.length; i++) {
if (result[i] !== null) {
prev = i;
continue;
}
if (prev === -1) continue;
const next = result.findIndex((value, j) => j > i && value !== null);
if (next === -1) continue;
if (reads[next]!.t - reads[prev]!.t <= PENALTY_BRIDGE_SECONDS) {
result[i] = result[prev];
}
}
return result;
}
/** One counter read: when it was made and the count displayed per team. */
export interface ObjectiveScoreRead {
/** whole seconds into the source (video, stream or game) the read was made at */
t: number;
/** displayed count per team; null = unreadable */
score: readonly [number | null, number | null];
}
/**
* Match scores implied by each team's last readable counter read. The counter
* counts down from 100 while match scores run the other way (100 = knockout),
* so a read is inverted into the count the team took. Stands in where the
* results screen reports no score of its own — a knockout's loser — but the
* last read is only as late as the last frame the counter was seen in, so it
* can trail the count the team ended on.
*
* @param reads counter reads, in any order
* @returns per-team match score (0-100); null where nothing was read
*/
export function matchScoresFromObjective(
reads: readonly ObjectiveScoreRead[],
): [number | null, number | null] {
const sorted = reads.toSorted((a, b) => a.t - b.t);
const lastCountTaken = (side: 0 | 1) => {
for (let i = sorted.length - 1; i >= 0; i--) {
const count = sorted[i]!.score[side];
// a count outside the counter's range is a misread, not a state
if (count !== null && count >= 0 && count <= FULL_COUNT) {
return FULL_COUNT - count;
}
}
return null;
};
return [lastCountTaken(0), lastCountTaken(1)];
}
function medianFilterValues(
values: readonly (number | null)[],
): (number | null)[] {
const nonNullIndexes = values.flatMap((value, i) =>
value !== null ? [i] : [],
);
const result = [...values];
for (let k = 1; k < nonNullIndexes.length - 1; k++) {
const window = [
values[nonNullIndexes[k - 1]!]!,
values[nonNullIndexes[k]!]!,
values[nonNullIndexes[k + 1]!]!,
].sort((a, b) => a - b);
result[nonNullIndexes[k]!] = window[1]!;
}
return result;
}