diff --git a/app/components/GameTimeline.tsx b/app/components/GameTimeline.tsx index c1777a662..c5153769a 100644 --- a/app/components/GameTimeline.tsx +++ b/app/components/GameTimeline.tsx @@ -1,6 +1,6 @@ /** - * A game's two scanned-timeline charts (player status bands above the objective-counter chart) - * on one shared time axis. Hovering scrubs both: a cursor line spans the charts and a readout + * A game's scanned-timeline charts (player status bands above the objective-counter chart, plus a + * TC/RM objective's position along its track) on one shared time axis. Hovering scrubs both: a cursor line spans the charts and a readout * shows the moment's state and any kills under the cursor, replacing the chart's own tooltip. */ import clsx from "clsx"; @@ -11,6 +11,7 @@ import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { abilityImageUrl } from "~/utils/urls"; import styles from "./GameTimeline.module.css"; import { Image, WeaponImage } from "./Image"; +import { ObjectivePositionTimeline } from "./ObjectivePositionTimeline"; import { ObjectiveTimeline, type ObjectiveTimelineEvent, @@ -19,6 +20,7 @@ import { formatElapsed, smoothPenalties, TIMELINE_PLOT_GUTTER_PX, + withUnpushedTrackCounts, } from "./objective-timeline-utils"; import { PLAYER_STATUS_TAIL_SECONDS, @@ -62,7 +64,9 @@ export function GameTimeline({ const [scrub, setScrub] = useState(null); const plotRef = useRef(null); - const objective = (objectiveEvents ?? []).toSorted((a, b) => a.t - b.t); + const objective = withUnpushedTrackCounts( + (objectiveEvents ?? []).toSorted((a, b) => a.t - b.t), + ); const samples = (playerStatusSamples ?? []).toSorted((a, b) => a.t - b.t); const domain = timelineDomain(objective, samples); if (!domain) return null; @@ -124,7 +128,9 @@ const TimelineCharts = memo(function TimelineCharts({ teams, pov, }: GameTimelineProps) { - const objective = (objectiveEvents ?? []).toSorted((a, b) => a.t - b.t); + const objective = withUnpushedTrackCounts( + (objectiveEvents ?? []).toSorted((a, b) => a.t - b.t), + ); const samples = (playerStatusSamples ?? []).toSorted((a, b) => a.t - b.t); const domain = timelineDomain(objective, samples); if (!domain) return null; @@ -147,6 +153,9 @@ const TimelineCharts = memo(function TimelineCharts({ showTooltip={false} /> ) : null} + {objective.some((event) => event.data.position !== undefined) ? ( + + ) : null} ); }); @@ -228,11 +237,19 @@ function ScrubReadout({ })} ) : null} - {objectiveNow?.control[side] ? ( + {objectiveNow?.control === side ? ( {t("common:objectiveTimeline.inControl")} ) : null} + {objectiveNow?.position != null && + pushingSide(objectiveNow.position) === side ? ( + + {t("common:objectiveTimeline.pushed", { + value: Math.abs(objectiveNow.position), + })} + + ) : null} {statusNow ? ( <> @@ -343,7 +360,9 @@ interface ObjectiveStateAtTime { /** last readable count per team at the scrubbed moment */ scores: [number | null, number | null]; penalties: [number | null, number | null]; - control: [boolean, boolean]; + control: 0 | 1 | null; + /** TC/RM: the last read objective position (see ObjectiveTimelineSample); null when none */ + position: number | null; } /** State implied by the last objective read at or before `time`, scores carried across unreadable reads. */ @@ -373,15 +392,26 @@ function objectiveStateAt( )[index] ?? null, ) as [number | null, number | null]; const latest = sorted[index]!; + let position: number | null = null; + for (let i = 0; i <= index; i++) { + position = sorted[i]!.data.position ?? position; + } return { clock: latest.data.time, scores, penalties, - control: [latest.data.control[0], latest.data.control[1]], + control: latest.data.control, + position, }; } +/** The side a track position is progress for: past the middle toward bravo's end is alpha's. */ +function pushingSide(position: number): 0 | 1 | null { + if (position === 0) return null; + return position > 0 ? 0 : 1; +} + interface PlayerStatusAtTime { /** slot indexes inside a splatted band at the scrubbed moment, per side */ dead: [number[], number[]]; diff --git a/app/components/ObjectivePositionTimeline.module.css b/app/components/ObjectivePositionTimeline.module.css new file mode 100644 index 000000000..4be556131 --- /dev/null +++ b/app/components/ObjectivePositionTimeline.module.css @@ -0,0 +1,6 @@ +.container { + height: 140px; + background-color: var(--color-bg-high); + border-radius: var(--radius-box); + padding: var(--s-2-5) var(--s-3); +} diff --git a/app/components/ObjectivePositionTimeline.tsx b/app/components/ObjectivePositionTimeline.tsx new file mode 100644 index 000000000..a19f4a3fb --- /dev/null +++ b/app/components/ObjectivePositionTimeline.tsx @@ -0,0 +1,111 @@ +/** + * Tug-of-war chart of a Tower Control / Rainmaker objective along its track: the middle line is + * the start, up is toward the end alpha pushes to and fills in alpha's color, down toward bravo's + * goal in bravo's. Shares the objective chart's x-axis and y-label gutter so both line up. + */ + +import { + Chart as ChartJS, + Filler, + LinearScale, + LineElement, + PointElement, +} from "chart.js"; +import { Line } from "react-chartjs-2"; +import { useThemeColors } from "~/hooks/useThemeColors"; +import styles from "./ObjectivePositionTimeline.module.css"; +import type { ObjectiveTimelineEvent } from "./ObjectiveTimeline"; +import { TIMELINE_PLOT_GUTTER_PX, withAlpha } from "./objective-timeline-utils"; + +ChartJS.register(LinearScale, PointElement, LineElement, Filler); + +const TRACK_END = 100; + +export function ObjectivePositionTimeline({ + events, + domain, +}: { + /** sorted by `t`; events without a position read are skipped */ + events: readonly ObjectiveTimelineEvent[]; + domain: [number, number]; +}) { + const colors = useThemeColors({ + alpha: "--color-chart-alpha", + bravo: "--color-chart-bravo", + border: "--color-border", + borderHigh: "--color-border-high", + text: "--color-text-high", + }); + const points = events.flatMap((event) => + event.data.position != null ? [{ x: event.t, y: event.data.position }] : [], + ); + if (points.length === 0) return null; + + return ( +
+ + ctx.tick?.value === 0 ? colors.borderHigh : colors.border, + }, + border: { color: colors.borderHigh }, + afterBuildTicks: (axis) => { + axis.ticks = [ + { value: -TRACK_END }, + { value: 0 }, + { value: TRACK_END }, + ]; + }, + afterFit: (axis) => { + axis.width = TIMELINE_PLOT_GUTTER_PX; + }, + ticks: { + color: colors.text, + autoSkip: false, + callback: (value) => Math.abs(Number(value)), + }, + }, + }, + plugins: { + legend: { display: false }, + tooltip: { enabled: false }, + }, + }} + /> +
+ ); +} diff --git a/app/components/ObjectiveTimeline.tsx b/app/components/ObjectiveTimeline.tsx index 935ed90be..17099c58b 100644 --- a/app/components/ObjectiveTimeline.tsx +++ b/app/components/ObjectiveTimeline.tsx @@ -23,6 +23,7 @@ import { formatElapsed, smoothPenalties, TIMELINE_PLOT_GUTTER_PX, + withAlpha, } from "./objective-timeline-utils"; ChartJS.register( @@ -48,8 +49,13 @@ export interface ObjectiveTimelineSample { score: [number | null, number | null]; /** penalty pill value per team; null = no pill (or unreadable) */ penalty: [number | null, number | null]; - /** which team held the objective at the read */ - control: [boolean, boolean]; + /** which team held the objective at the read; null = neither */ + control: 0 | 1 | null; + /** + * TC/RM: the objective along its track, -100..100, positive toward the end + * alpha pushes to; absent on SZ, null = unread + */ + position?: number | null; } export interface ObjectiveTimelineEvent { @@ -107,7 +113,7 @@ export function ObjectiveTimeline({ data: sorted.map((event, i) => ({ x: event.t, y: - event.data.control[side] || sorted[i - 1]?.data.control[side] + event.data.control === side || sorted[i - 1]?.data.control === side ? CONTROL_LANE_Y : null, })), @@ -234,7 +240,7 @@ export function ObjectiveTimeline({ value: penalty[side], }) : null, - control[side] + control === side ? t("common:objectiveTimeline.inControl") : null, ] @@ -268,11 +274,6 @@ function gridColor( return value === 0 ? colors.borderHigh : colors.border; } -/** `oklch(64% 0.16 10)` → `oklch(64% 0.16 10 / 0.55)` */ -function withAlpha(oklchColor: string, alpha: number) { - return oklchColor.replace(/\)$/, ` / ${alpha})`); -} - /** the match timer's M:SS (215 → "3:35") */ function formatClock(seconds: number): string { const minutes = Math.floor(seconds / 60); diff --git a/app/components/match-page/ingested-scoreboard.ts b/app/components/match-page/ingested-scoreboard.ts index b18531549..90238e151 100644 --- a/app/components/match-page/ingested-scoreboard.ts +++ b/app/components/match-page/ingested-scoreboard.ts @@ -112,7 +112,16 @@ function toTimelineObjective( time: sample.time, score: alphaFirst(sample.score), penalty: alphaFirst(sample.penalty), - control: alphaFirst(sample.control), + control: + alphaIsWinner || sample.control === null + ? sample.control + : sample.control === 0 + ? 1 + : 0, + position: + !alphaIsWinner && sample.position != null + ? 0 - sample.position + : sample.position, }, })); } diff --git a/app/components/objective-timeline-utils.test.ts b/app/components/objective-timeline-utils.test.ts index cbeb1e7c7..827ed00b9 100644 --- a/app/components/objective-timeline-utils.test.ts +++ b/app/components/objective-timeline-utils.test.ts @@ -4,6 +4,7 @@ import { type ObjectiveScoreRead, type PenaltyRead, smoothPenalties, + withUnpushedTrackCounts, } from "./objective-timeline-utils"; function reads(...pairs: Array<[t: number, penalty: number | null]>) { @@ -103,3 +104,37 @@ describe("matchScoresFromObjective", () => { expect(matchScoresFromObjective([])).toEqual([null, null]); }); }); + +describe("withUnpushedTrackCounts", () => { + const trackEvent = (alpha: number | null, bravo: number | null) => ({ + data: { + score: [alpha, bravo] as [number | null, number | null], + position: 0, + }, + }); + + test("shows a full count until a side's first plate read", () => { + const scores = withUnpushedTrackCounts([ + trackEvent(null, null), + trackEvent(90, null), + trackEvent(null, 70), + trackEvent(null, null), + ]).map((event) => event.data.score); + + expect(scores).toEqual([ + [100, 100], + [90, 100], + [null, 70], + [null, null], + ]); + }); + + test("leaves splat zones counts alone", () => { + const events = [ + { data: { score: [null, null] as [number | null, number | null] } }, + { data: { score: [90, 80] as [number | null, number | null] } }, + ]; + + expect(withUnpushedTrackCounts(events)).toEqual(events); + }); +}); diff --git a/app/components/objective-timeline-utils.ts b/app/components/objective-timeline-utils.ts index 6a682a94f..765886a4b 100644 --- a/app/components/objective-timeline-utils.ts +++ b/app/components/objective-timeline-utils.ts @@ -86,6 +86,43 @@ export function matchScoresFromObjective( return [lastCountTaken(0), lastCountTaken(1)]; } +interface TrackCountEvent { + data: { + score: [number | null, number | null]; + position?: number | null; + }; +} + +/** + * TC/RM draw no count plate for a team that never pushed past the middle, so + * a side's reads before its first count showed its full count, not an + * unreadable one. SZ events (no `position`) pass through untouched. + * + * @param sorted events sorted by `t` ascending; result is index-aligned + */ +export function withUnpushedTrackCounts( + sorted: readonly T[], +): T[] { + if (!sorted.some((event) => event.data.position !== undefined)) { + return [...sorted]; + } + const firstRead = ([0, 1] as const).map((side) => + sorted.findIndex((event) => event.data.score[side] !== null), + ); + return sorted.map((event, i) => { + const score = event.data.score.map((value, side) => { + const first = firstRead[side]!; + return value === null && (first === -1 || i < first) ? FULL_COUNT : value; + }) as [number | null, number | null]; + return { ...event, data: { ...event.data, score } }; + }); +} + +/** `oklch(64% 0.16 10)` → `oklch(64% 0.16 10 / 0.55)` */ +export function withAlpha(oklchColor: string, alpha: number) { + return oklchColor.replace(/\)$/, ` / ${alpha})`); +} + /** Seconds formatted as m:ss, with an hours part only when needed. */ export function formatElapsed(seconds: number): string { const hours = Math.floor(seconds / 3600); diff --git a/app/features/match-page-test/routes/match-page-test.tsx b/app/features/match-page-test/routes/match-page-test.tsx index ce9f619b5..fc67db012 100644 --- a/app/features/match-page-test/routes/match-page-test.tsx +++ b/app/features/match-page-test/routes/match-page-test.tsx @@ -27,7 +27,7 @@ import type { PlayerStatusTimelineSample } from "~/components/PlayerStatusTimeli import { logger } from "~/utils/logger"; import type { SendouRouteHandle } from "~/utils/remix.server"; -/** Counter reads of a made-up zones game, for previewing the timeline chart. */ +/** Counter reads of a made-up Rainmaker game, for previewing the timeline charts. */ const MOCK_OBJECTIVE_EVENTS = mockObjectiveEvents(); /** Icon-strip reads of the same made-up game, for previewing the status bands. */ @@ -864,41 +864,37 @@ export default function MatchPageTestRoute() { } /** - * Zones game second by second: the controlling side burns its penalty before its count moves, - * and losing the zone after counting hands it a penalty to burn next time. + * Rainmaker game second by second: the carrier's team drags the objective toward the other end, + * a dropped Rainmaker sits neutral where it fell, and each team's count only falls past its best + * push so far. */ function mockObjectiveEvents(): ObjectiveTimelineEvent[] { - const PHASES: Array<{ seconds: number; control: [boolean, boolean] }> = [ - { seconds: 12, control: [false, false] }, - { seconds: 30, control: [true, false] }, - { seconds: 14, control: [false, false] }, - { seconds: 44, control: [false, true] }, - { seconds: 10, control: [false, false] }, - { seconds: 80, control: [true, false] }, + const PHASES: Array<{ + seconds: number; + holder: 0 | 1 | null; + speed: number; + }> = [ + { seconds: 12, holder: null, speed: 0 }, + { seconds: 20, holder: 0, speed: 2 }, + { seconds: 14, holder: null, speed: 0 }, + { seconds: 40, holder: 1, speed: -2 }, + { seconds: 10, holder: null, speed: 0 }, + { seconds: 45, holder: 0, speed: 2 }, + { seconds: 20, holder: null, speed: 0 }, + { seconds: 29, holder: 1, speed: -2 }, ]; - const PENALTY_ON_LOSING_ZONE = 12; const SAMPLE_EVERY_SECONDS = 2; - const score: [number, number] = [100, 100]; - const penalty: [number, number] = [0, 0]; + const best: [number, number] = [0, 0]; const events: ObjectiveTimelineEvent[] = []; - let previousControl: [boolean, boolean] = [false, false]; + let position = 0; let t = 0; for (const phase of PHASES) { - for (const side of [0, 1] as const) { - if (previousControl[side] && !phase.control[side]) { - penalty[side] += PENALTY_ON_LOSING_ZONE; - } - } - previousControl = phase.control; - for (let second = 0; second < phase.seconds; second++) { - for (const side of [0, 1] as const) { - if (!phase.control[side]) continue; - if (penalty[side] > 0) penalty[side] -= 1; - else score[side] = Math.max(0, score[side] - 1); - } + position = Math.max(-100, Math.min(100, position + phase.speed)); + best[0] = Math.max(best[0], position); + best[1] = Math.max(best[1], -position); t += 1; if (t % SAMPLE_EVERY_SECONDS !== 0) continue; @@ -906,9 +902,13 @@ function mockObjectiveEvents(): ObjectiveTimelineEvent[] { t, data: { time: 300 - t, - score: [score[0], score[1]], - penalty: [penalty[0] || null, penalty[1] || null], - control: [phase.control[0], phase.control[1]], + score: [ + best[0] > 0 ? 100 - best[0] : null, + best[1] > 0 ? 100 - best[1] : null, + ], + penalty: [null, null], + control: phase.holder, + position, }, }); } diff --git a/app/features/scanner-ingest/core/Matches.test.ts b/app/features/scanner-ingest/core/Matches.test.ts index 341f852af..a1653acc6 100644 --- a/app/features/scanner-ingest/core/Matches.test.ts +++ b/app/features/scanner-ingest/core/Matches.test.ts @@ -21,7 +21,7 @@ describe("canonicalMatch", () => { time: 215, score: [95, 53], penalty: [4, null], - control: [true, false], + control: 0, }, ], }, @@ -197,6 +197,34 @@ describe("mergeMatches", () => { expect(merged.teams[0].players[0]!.name).toBe("w1"); }); + test("flips a side-swapped track position back with its sides", () => { + const existing = scannerMatch({ objective: null }); + const incoming = sideSwapped( + scannerMatch({ + objective: { + mode: "TC", + samples: [ + { + t: 120, + time: 215, + score: [100, 60], + penalty: [null, null], + control: 1, + position: -30, + }, + ], + }, + }), + ); + + const { merged } = Matches.mergeMatches(existing, incoming); + expect(merged.objective!.samples[0]).toMatchObject({ + score: [100, 60], + control: 1, + position: -30, + }); + }); + test("aligns a side-swapped incoming match before merging", () => { const existing = scannerMatch({ winner: null, matchScores: null }); const incoming = sideSwapped( diff --git a/app/features/scanner-ingest/core/Matches.ts b/app/features/scanner-ingest/core/Matches.ts index 299a2afe7..6be5cecc5 100644 --- a/app/features/scanner-ingest/core/Matches.ts +++ b/app/features/scanner-ingest/core/Matches.ts @@ -161,7 +161,8 @@ function canonicalObjective( time: sample.time, score: [sample.score[0], sample.score[1]], penalty: [sample.penalty[0], sample.penalty[1]], - control: [sample.control[0], sample.control[1]], + control: sample.control, + ...(sample.position !== undefined ? { position: sample.position } : null), })), }; } @@ -294,7 +295,11 @@ function swapSides(match: ScannerMatch): ScannerMatch { ...sample, score: [sample.score[1], sample.score[0]], penalty: [sample.penalty[1], sample.penalty[0]], - control: [sample.control[1], sample.control[0]], + control: + sample.control === null ? null : sample.control === 0 ? 1 : 0, + ...(sample.position != null + ? { position: 0 - sample.position } + : null), })), }, playerStatus: diff --git a/app/features/scanner-ingest/core/Scoreboards.test.ts b/app/features/scanner-ingest/core/Scoreboards.test.ts index ccde711e8..515ebdd6e 100644 --- a/app/features/scanner-ingest/core/Scoreboards.test.ts +++ b/app/features/scanner-ingest/core/Scoreboards.test.ts @@ -117,14 +117,30 @@ function testObjective(): ScannerMatchObjective { time: 300, score: [100, 100], penalty: [null, null], - control: [false, false], + control: null, }, { t: 630, time: 270, score: [80, 100], penalty: [null, 12], - control: [true, false], + control: 0, + }, + ], + }; +} + +function testTrackObjective(): ScannerMatchObjective { + return { + mode: "RM", + samples: [ + { + t: 600, + time: 300, + score: [75, null], + penalty: [null, null], + control: 0, + position: 25, }, ], }; @@ -163,7 +179,11 @@ function swapSides(match: ScannerMatch): ScannerMatch { ...sample, score: [sample.score[1], sample.score[0]], penalty: [sample.penalty[1], sample.penalty[0]], - control: [sample.control[1], sample.control[0]], + control: + sample.control === null ? null : sample.control === 0 ? 1 : 0, + ...(sample.position != null + ? { position: 0 - sample.position } + : null), })), }, playerStatus: @@ -541,14 +561,14 @@ describe("deriveScoreboardData", () => { time: 300, score: [100, 100], penalty: [null, null], - control: [false, false], + control: null, }, { t: 30, time: 270, score: [80, 100], penalty: [null, 12], - control: [true, false], + control: 0, }, ], }); @@ -568,6 +588,21 @@ describe("deriveScoreboardData", () => { expect(swapped!.objective).toEqual(straight!.objective); }); + test("derives track positions winner-first", () => { + const straight = derive([ + { data: testMatch({ objective: testTrackObjective() }), povUserId: null }, + ]); + const swapped = derive([ + { + data: swapSides(testMatch({ objective: testTrackObjective() })), + povUserId: null, + }, + ]); + + expect(straight!.objective!.samples[0]!.position).toBe(25); + expect(swapped!.objective).toEqual(straight!.objective); + }); + test("rebases status samples onto the same origin as the counter's", () => { const data = derive([ { diff --git a/app/features/scanner-ingest/core/Scoreboards.ts b/app/features/scanner-ingest/core/Scoreboards.ts index 61e3a8522..45e2d726d 100644 --- a/app/features/scanner-ingest/core/Scoreboards.ts +++ b/app/features/scanner-ingest/core/Scoreboards.ts @@ -201,7 +201,7 @@ export interface IngestedScoreboardData { scores: [number | null, number | null]; /** in scoreboard order: rows 0-3 winning team, rows 4-7 losing team */ players: IngestedScoreboardPlayer[]; - /** objective-counter progress, per-team values [winner, loser], `t` in seconds since the game's first read (the source video is not stored). Absent when no counter was read. */ + /** objective-counter progress winner-first (per-team values [winner, loser], `control` 0 = winner), `t` in seconds since the game's first read (the source video is not stored). Absent when no counter was read. */ objective?: ScannerMatchObjective; /** per-player special/death samples, teams winner-first, `t` on the same origin as `objective`. Absent when the icon strip was never read. */ playerStatus?: ScannerMatchPlayerStatus; @@ -373,7 +373,21 @@ function winnerFirstObjective( time: sample.time, score: winnerFirst(sample.score), penalty: winnerFirst(sample.penalty), - control: winnerFirst(sample.control), + control: + winner === 0 || sample.control === null + ? sample.control + : sample.control === 0 + ? 1 + : 0, + // a track position is measured toward the end the first side pushes to + ...(sample.position !== undefined + ? { + position: + winner === 1 && sample.position !== null + ? 0 - sample.position + : sample.position, + } + : null), })), }; } diff --git a/app/features/scanner-ingest/core/tests/fixtures.ts b/app/features/scanner-ingest/core/tests/fixtures.ts index cb1095922..789d6acf5 100644 --- a/app/features/scanner-ingest/core/tests/fixtures.ts +++ b/app/features/scanner-ingest/core/tests/fixtures.ts @@ -72,5 +72,21 @@ export function sideSwapped(match: ScannerMatch): ScannerMatch { match.matchScores === null ? null : [match.matchScores[1], match.matchScores[0]], + objective: + match.objective === null + ? null + : { + ...match.objective, + samples: match.objective.samples.map((sample) => ({ + ...sample, + score: [sample.score[1], sample.score[0]], + penalty: [sample.penalty[1], sample.penalty[0]], + control: + sample.control === null ? null : sample.control === 0 ? 1 : 0, + ...(sample.position != null + ? { position: 0 - sample.position } + : null), + })), + }, }; } diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index afbc8d013..d4bce62da 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -241,8 +241,9 @@ sequenceDiagram `scoreboard-battle-log/detector.ts` reads it), `scoreboard-own` (personal results), `death` (respawn overlay), `map-start` (match intro), `minimap` (in-match overlay + 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), `kill` (the "Splatted !" feed + counts, penalties, holder, match timer — a mode-discriminated union: SZ + plates, and the Tower Control / Rainmaker track, see below; Clam Blitz is + not parsed), `kill` (the "Splatted !" feed bottom-center). The feed is the POV player's — on the SWS26 broadcast the specced player's, so a cast's kills follow camera swaps. One `Kill` event per frame carries the whole visible stack newest-first, up to four rows, @@ -326,13 +327,15 @@ sequenceDiagram control, the digit ink otherwise): casted footage keeps the specced player's team on the left plate, so the builder orients samples by ink hue and anchors them to `teams` order via the minimap sub-tile colors - (casts never show a results screen). Reads grouping into a match - whose detected mode is not SZ are lookalike misreads: the builder nulls - that match's `objective` and callers discard the events - (`invalidObjectiveEvents`; Live also stops collecting once a MapStart - reveals a non-SZ mode). PlayerStatus reads follow the objective pipeline - wholesale: same replay-wipe anchor, cast orientation inherited from the - nearest counter read, nulled together on non-SZ matches, and rendered as + (casts never show a results screen). Reads of the overlay the match's + mode doesn't draw (SZ plates on TC/RM, a track on SZ) are lookalike + misreads the builder leaves out, and on Turf War / Clam Blitz every read + is: the builder nulls that match's `objective` and callers discard the + events (`invalidObjectiveEvents`; Live also stops collecting once a + MapStart reveals such a mode). A match with no mode read builds from its + majority overlay and discards nothing. PlayerStatus reads follow the + objective pipeline wholesale: same replay-wipe anchor, cast orientation + inherited from the nearest counter read, nulled together on TW/CB matches, and rendered as per-player splat/special bands (`~/components/PlayerStatusTimeline.tsx`, shared with the match page) above the objective chart. Minimap reads feed the same samples: every card/row carries `dead` (respawn @@ -575,3 +578,30 @@ finished labeling work can be handed over as a reviewable link, e.g. `/scanner?view=fixtures&q=gauge-overlay,ready-trough`. Fixtures are committed as plain blobs (no LFS for now); keep additions deliberate — fixture IO is isolated in `node/fixtures.ts` if a retreat to LFS/an external corpus is needed. + +## Tower Control / Rainmaker track + +Both modes draw one overlay (`core/detectors/objective/track.ts`, geometry in +`rois.ts`): a dotted track under the icon strip, ends x514..1405 at y155, +with the objective's icon riding it and a "Remaining" plate per team hanging +under the furthest point that team pushed to — so a team's plate always sits +on the half it pushes into, and the left team pushes right. The gate tells the +track from the SZ plates by the dots, which sit at a fixed pitch and phase in +every lobby (a comb projected at that phase). The icon is scored +procedurally (held: team-ink disc around a white glyph; neutral: white ring +around an olive disc), its x mapped linearly to `position` -100..100; the +holder is the icon's ink against each team's ink off its own track end (end +marker plus first dots, which the backdrop can't drown like the strip); the +counts are every confident digit run in the plate band, each assigned by the +plate ink around it (no label is read: localized). The checkpoint markers +tell the modes apart (TC black-framed squares, RM pedestals); when neither +reads, the read's `mode` is null and the match's mode decides. `control` is +the holder's side (`0 | 1 | null`) on every overlay — in SZ too, where a +plate fills only while its team holds every zone. Samples add `position`, +positive toward the end `teams[0]` pushes to: every side swap (cast +orientation, winner-first ingest, display order) negates it and flips +`control`. A team with no plate yet has +never pushed past the middle: the charts show its full count until its +first plate read (`withUnpushedTrackCounts`). Fixtures: `objective/tower-*`, +`objective/rainmaker-*` (tower_control / rainmaker VoDs plus TC/RM death +frames symlinked). diff --git a/app/features/scanner/components/FixturesPage.tsx b/app/features/scanner/components/FixturesPage.tsx index a152746ca..b5565fad9 100644 --- a/app/features/scanner/components/FixturesPage.tsx +++ b/app/features/scanner/components/FixturesPage.tsx @@ -368,13 +368,18 @@ function ObjectiveExpected(props: { data: ExpectedData; }) { const { data } = props; + if (data.position !== undefined) return ; return (
control{" "} - {data.control?.[0] ? "left" : data.control?.[1] ? "right" : "none"} + {data.control === 0 + ? "left" + : data.control === 1 + ? "right" + : "none"} @@ -403,3 +408,57 @@ function ObjectiveExpected(props: {
); } + +/** TC/RM: the track with its icon and the plate band under it, whose plates slide with the pushes. */ +function TrackObjectiveExpected(props: { + frame: HTMLCanvasElement | null; + data: ExpectedData; +}) { + const { data } = props; + return ( +
+
+ + mode {data.mode ?? "?"} + + + timer {formatTimer(data.time ?? null)} + + + control{" "} + + {data.control === 0 + ? "left" + : data.control === 1 + ? "right" + : "none"} + + + + position {data.position ?? "—"} + + + counts{" "} + + {data.score?.[0] ?? "—"} / {data.score?.[1] ?? "—"} + + +
+ {props.frame ? ( + + ) : null} +
+ ); +} diff --git a/app/features/scanner/components/ObjectiveCard.tsx b/app/features/scanner/components/ObjectiveCard.tsx index a77c630ee..c4f1606b3 100644 --- a/app/features/scanner/components/ObjectiveCard.tsx +++ b/app/features/scanner/components/ObjectiveCard.tsx @@ -23,10 +23,12 @@ export function ObjectiveCard(props: { const side = (index: 0 | 1) => { const score = data.score[index] ?? "?"; const penalty = - data.penalty[index] !== null ? ` (+${data.penalty[index]})` : ""; + data.mode === "SZ" && data.penalty[index] !== null + ? ` (+${data.penalty[index]})` + : ""; return `${score}${penalty}`; }; - const holder = data.control.findIndex(Boolean); + const holder = data.control; const formatDetectedAt = useEventTimeFormatter(); return ( @@ -42,9 +44,12 @@ export function ObjectiveCard(props: { {side(0)} – {side(1)} - {holder >= 0 + {holder !== null ? ` · ${holder === 0 ? "alpha" : "bravo"} in control` : null} + {data.mode !== "SZ" && data.position !== null + ? ` · at ${data.position > 0 ? "+" : ""}${data.position}` + : null} {detectedAt ? {formatDetectedAt(detectedAt)} : null} {data.score[0] ?? "?"}–{data.score[1] ?? "?"} - - {data.penalty[0] ?? "—"} / {data.penalty[1] ?? "—"} - + {data.mode === "SZ" ? ( + + {data.penalty[0] ?? "—"} / {data.penalty[1] ?? "—"} + + ) : ( + {data.position ?? "—"} + )} - {data.control[0] + {data.control === 0 ? "left" - : data.control[1] + : data.control === 1 ? "right" : "none"} diff --git a/app/features/scanner/components/events-feed.ts b/app/features/scanner/components/events-feed.ts index 310d1c4a7..5ffc69950 100644 --- a/app/features/scanner/components/events-feed.ts +++ b/app/features/scanner/components/events-feed.ts @@ -246,8 +246,9 @@ async function rawSessions(events: StoredEvent[]): Promise { (a, b) => a.t - b.t || (a.id ?? 0) - (b.id ?? 0), ); let built = buildScannerMatches(sorted); - // objective reads grouped into a known non-SZ match slipped past the live - // block (e.g. the mode read arrived after them) — delete them + // objective reads grouped into a match whose mode rules their overlay out + // slipped past the live block (e.g. the mode read arrived after them) — + // delete them const invalid = invalidObjectiveEvents(built); if (invalid.length > 0) { await deleteEvents( diff --git a/app/features/scanner/components/fixture-export.ts b/app/features/scanner/components/fixture-export.ts index a97302514..86c972245 100644 --- a/app/features/scanner/components/fixture-export.ts +++ b/app/features/scanner/components/fixture-export.ts @@ -143,13 +143,22 @@ function buildExpectedJson( return `${JSON.stringify( { event: eventType, - data: { - mode: objective.mode, - time: objective.time, - score: objective.score, - penalty: objective.penalty, - control: objective.control, - }, + data: + objective.mode === "SZ" + ? { + mode: objective.mode, + time: objective.time, + score: objective.score, + penalty: objective.penalty, + control: objective.control, + } + : { + mode: objective.mode, + time: objective.time, + score: objective.score, + control: objective.control, + position: objective.position, + }, }, null, 2, diff --git a/app/features/scanner/components/game-timeline-view.ts b/app/features/scanner/components/game-timeline-view.ts index 84634c1fc..1bc3a22e1 100644 --- a/app/features/scanner/components/game-timeline-view.ts +++ b/app/features/scanner/components/game-timeline-view.ts @@ -51,7 +51,12 @@ export function gameTimelineProps( time: sample.time, score: ordered(sample.score), penalty: ordered(sample.penalty), - control: ordered(sample.control), + control: + sample.control === null ? null : sample.control === first ? 0 : 1, + position: + first === 1 && sample.position != null + ? 0 - sample.position + : sample.position, }, })), playerStatusSamples: (match.playerStatus?.samples ?? []).map((sample) => ({ diff --git a/app/features/scanner/components/live-session.ts b/app/features/scanner/components/live-session.ts index 19f21f8ea..0ac4d132e 100644 --- a/app/features/scanner/components/live-session.ts +++ b/app/features/scanner/components/live-session.ts @@ -135,8 +135,9 @@ let releaseCaptureLock: (() => void) | null = null; let unsubscribeFeed: (() => void) | null = null; let timeline = new TimelineBuilder(); const storedIds = new WeakMap(); -// 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 +// the open match is known to be a mode with no parsed counter overlay (Turf +// War, Clam Blitz), so counter reads are lookalike misreads and are not +// collected at all let objectiveBlocked = false; /** windows already cut, `${match first source id}:${window t}` */ /** @@ -401,7 +402,8 @@ function onResult( if (action.action === "merged" || action.action === "dropped") continue; if (event.type === MAP_START_EVENT_TYPE) { const mode = (event.data as MapStartData).mode; - objectiveBlocked = mode !== null && mode !== "SZ"; + objectiveBlocked = + mode !== null && mode !== "SZ" && mode !== "TC" && mode !== "RM"; } else if (SCOREBOARD_EVENT_TYPES.includes(event.type)) { objectiveBlocked = false; } diff --git a/app/features/scanner/components/vod-scan.ts b/app/features/scanner/components/vod-scan.ts index b32f0dc99..ce5b5c775 100644 --- a/app/features/scanner/components/vod-scan.ts +++ b/app/features/scanner/components/vod-scan.ts @@ -518,7 +518,7 @@ export async function startVodScan( } } -/** Objective reads that grouped into a known non-SZ match (misreads) are dropped. */ +/** Objective reads that grouped into a match whose mode rules their overlay out (misreads) are dropped. */ function withoutInvalidObjectives(events: ScanEvent[]): ScanEvent[] { const invalid = new Set(invalidObjectiveEvents(buildScannerMatches(events))); return invalid.size > 0 ? events.filter((e) => !invalid.has(e)) : events; diff --git a/app/features/scanner/core/csv/events.ts b/app/features/scanner/core/csv/events.ts index 14fdded6b..16e54ce89 100644 --- a/app/features/scanner/core/csv/events.ts +++ b/app/features/scanner/core/csv/events.ts @@ -238,8 +238,10 @@ function eventCells(event: CsvEvent, originT: number): CsvCell[] { case OBJECTIVE_EVENT_TYPE: { const d = event.data as ObjectiveData; const sideText = (side: 0 | 1) => - `${d.score[side] ?? "?"}${d.penalty[side] !== null ? ` (+${d.penalty[side]})` : ""}${d.control[side] ? " ctrl" : ""}`; + `${d.score[side] ?? "?"}${d.mode === "SZ" && d.penalty[side] !== null ? ` (+${d.penalty[side]})` : ""}${d.control === side ? " ctrl" : ""}`; const clock = d.time === null ? "" : `${formatClock(d.time)} · `; + const position = + d.mode !== "SZ" && d.position !== null ? ` @ ${d.position}` : ""; return [ ...base, "", @@ -251,7 +253,7 @@ function eventCells(event: CsvEvent, originT: number): CsvCell[] { "", "", "", - `${clock}${sideText(0)} vs ${sideText(1)}`, + `${clock}${sideText(0)} vs ${sideText(1)}${position}`, "", "", ]; diff --git a/app/features/scanner/core/detectors/objective/index.ts b/app/features/scanner/core/detectors/objective/index.ts index 69d7389d2..7304f9ccb 100644 --- a/app/features/scanner/core/detectors/objective/index.ts +++ b/app/features/scanner/core/detectors/objective/index.ts @@ -1,13 +1,16 @@ /** - * ObjectiveDetector: parses the ranked counter overlay top-center — count plates, - * penalty pills, control (the controlling plate keeps its team-color fill, the - * other is near-black with digits in team ink) and the M:SS timer. Digits read - * as the trailing digit run (banner.ts) under several channel extractions (ink - * on black needs the brightest channel, ink on a team fill the darkest); best - * read wins. No readable count on either side = lookalike, emits nothing. - * `ObjectiveData` is discriminated on `mode`; only SZ exists until TC/RM/CB - * fixtures. Each read also emits a PlayerStatus event (player-status.ts) off - * the same frame, paired downstream by the shared timer value. + * ObjectiveDetector: parses the ranked counter overlay top-center. Splat Zones + * draws count plates, penalty pills, control (the controlling plate keeps its + * team-color fill, the other is near-black with digits in team ink) and the + * M:SS timer. Digits read as the trailing digit run (banner.ts) under several + * channel extractions (ink on black needs the brightest channel, ink on a team + * fill the darkest); best read wins. No readable count on either side = + * lookalike, emits nothing. Tower Control and Rainmaker share a different + * overlay, a track with the objective riding it (track.ts), which the gate + * tells apart by the track's dot comb (`variant`). `ObjectiveData` is + * discriminated on `mode`; Clam Blitz is not parsed. Each read also emits a + * PlayerStatus event (player-status.ts) off the same frame, paired downstream + * by the shared timer value. */ import { type Mat, minMaxLoc } from "../../cv"; import { type GlyphSet, recognizeTextSteps, scaleGlyphSet } from "../../glyphs"; @@ -36,6 +39,7 @@ import { CONTROL_PLATE_MIN_SATURATION, GATE_PLATE_MAX_STD, GATE_SCORE_MIN_MAX_BRIGHTNESS, + GATE_TRACK_MIN_COMB, PENALTY_BIN_THRESHOLD, PENALTY_PROBE_MAX_MEAN, PENALTY_PROBE_MAX_STD, @@ -50,11 +54,13 @@ import { SCORE_TEXT_HEIGHTS, STATUS_LAYOUT_STICKY_MAX_GAP_S, STRIP_WEAPON_SAMPLE_INTERVAL, + TRACK_PLATE_TEXT_HEIGHTS, } from "./rois"; import { parseStripWeaponsSteps, type StripWeaponsData } from "./strip-weapons"; import { readMatchTimerSteps, timerBoxChecks, timerGlyphSets } from "./timer"; +import { readTrackSteps, trackComb } from "./track"; -export type ObjectiveData = SplatZonesObjectiveData; +export type ObjectiveData = SplatZonesObjectiveData | TrackObjectiveData; export interface SplatZonesObjectiveData { mode: "SZ"; @@ -64,8 +70,11 @@ export interface SplatZonesObjectiveData { score: [number | null, number | null]; /** penalty pill value per team; null = no pill (or unreadable) */ penalty: [number | null, number | null]; - /** which team currently holds the zone (team-color plate fill) */ - control: [boolean, boolean]; + /** + * which team is in control (its plate fills team color: it holds every + * zone); null = neither + */ + control: 0 | 1 | null; /** * mean team-ink RGB per side off the plate (fill in control, digit ink * otherwise); null on too little ink. The stable team identity on casted @@ -74,28 +83,62 @@ export interface SplatZonesObjectiveData { teamColor: [InkRgb | null, InkRgb | null]; } +/** Tower Control / Rainmaker: one overlay, told apart only by its checkpoint markers. */ +export interface TrackObjectiveData { + /** off the checkpoint markers; null when none read (the match's mode decides) */ + mode: "TC" | "RM" | null; + /** match timer seconds; null = unreadable */ + time: number | null; + /** + * displayed "Remaining" count per team, [alpha, bravo]; null = no plate + * read (a team that never pushed past the middle shows none: 100) + */ + score: [number | null, number | null]; + /** which team holds the objective (the icon's ink); null = neutral */ + control: 0 | 1 | null; + /** + * the objective along the track, -100 (left end) .. 100 (right end): the + * left team pushes right, so positive is alpha's progress; null = no icon + */ + position: number | null; + /** each team's ink off its own end of the track, for cast orientation */ + teamColor: [InkRgb | null, InkRgb | null]; +} + export const OBJECTIVE_EVENT_TYPE = "Objective"; +/** Gate variants: which overlay the parse reads. */ +const ZONES_VARIANT = "zones"; +const TRACK_VARIANT = "track"; + /** How often the counter is worth checking (it changes at most 1/s). */ const CHECK_INTERVAL_SECONDS = 1; /** * Timeline content guard: reads merge only with the same state so every tick/ - * penalty/control change is its own event. `time` (ticks every second) and - * `teamColor` (pixel means jitter) are deliberately not compared. + * penalty/control/position change is its own event. `time` (ticks every + * second), `teamColor` (pixel means jitter) and a track read's marker `mode` + * (markers come and go as checkpoints fall) are deliberately not compared. */ export function sameObjectiveData(a: unknown, b: unknown): boolean { const da = a as ObjectiveData; const db = b as ObjectiveData; - return ( - da.mode === db.mode && - da.score[0] === db.score[0] && - da.score[1] === db.score[1] && - da.penalty[0] === db.penalty[0] && - da.penalty[1] === db.penalty[1] && - da.control[0] === db.control[0] && - da.control[1] === db.control[1] - ); + if ( + da.score[0] !== db.score[0] || + da.score[1] !== db.score[1] || + da.control !== db.control + ) { + return false; + } + if (da.mode === "SZ" || db.mode === "SZ") { + return ( + da.mode === "SZ" && + db.mode === "SZ" && + da.penalty[0] === db.penalty[0] && + da.penalty[1] === db.penalty[1] + ); + } + return da.position === db.position; } interface SideRead { @@ -128,6 +171,14 @@ export function createObjectiveDetector( ) : null; const timerSets = timerGlyphSets(resources); + const trackSets: GlyphSet[] = resources.paintDigits + ? TRACK_PLATE_TEXT_HEIGHTS.map((h) => + scaleGlyphSet( + resources.paintDigits!, + h / resources.paintDigits!.height, + ), + ) + : []; /** Mean and standard deviation of a grayscale ROI. */ function meanStd(gray: Mat, roi: Roi): { mean: number; std: number } { @@ -153,17 +204,26 @@ export function createObjectiveDetector( return maxVal >= GATE_SCORE_MIN_MAX_BRIGHTNESS; } + /** The timer box, then the track's dot comb (TC/RM) or the SZ plates. */ function gate(frame: Mat): GateResult { const gray = frameGray(frame); + const timerChecks = timerBoxChecks(gray); + if (timerChecks.every(Boolean) && trackComb(frame) >= GATE_TRACK_MIN_COMB) { + return { pass: true, score: 1, variant: TRACK_VARIANT }; + } const checks = [ - ...timerBoxChecks(gray), + ...timerChecks, plateProbeOk(gray, PLATE_PROBE_ROIS[0]), plateProbeOk(gray, PLATE_PROBE_ROIS[1]), scoreInkOk(frame, SCORE_ROIS[0]), scoreInkOk(frame, SCORE_ROIS[1]), ]; const passed = checks.filter(Boolean).length; - return { pass: passed === checks.length, score: passed / checks.length }; + return { + pass: passed === checks.length, + score: passed / checks.length, + variant: ZONES_VARIANT, + }; } /** Best trailing-digit read across channel extractions, thresholds and glyph sizes; every combination reads in one lockstep. */ @@ -285,11 +345,60 @@ export function createObjectiveDetector( function* parseSteps( frame: Mat, t: number, - _gate: GateResult | undefined, + gateResult: GateResult | undefined, speculative: boolean, ): MatchSteps< DetectedEvent[] > { + const variant = (gateResult ?? gate(frame)).variant; + const counter = + variant === TRACK_VARIANT + ? yield* parseTrackSteps(frame, speculative) + : yield* parseZonesSteps(frame, speculative); + if (!counter) return []; + const timeValue = counter.event.data.time; + + const playerStatus = parsePlayerStatus( + frame, + t, + timeValue, + lastStatus && t - lastStatus.t <= STATUS_LAYOUT_STICKY_MAX_GAP_S + ? lastStatus.layout + : undefined, + ); + lastStatus = { layout: playerStatus.data.layout, t }; + + // sampled slot-identity evidence for the strip → scoreboard-row assignment; + // identities are fixed so every read would re-measure at full sweep cost + let stripWeapons: DetectedEvent | null = null; + readsSinceWeaponSample++; + if ( + resources.stripWeapons && + readsSinceWeaponSample >= STRIP_WEAPON_SAMPLE_INTERVAL + ) { + readsSinceWeaponSample = 0; + stripWeapons = yield* parseStripWeaponsSteps( + frame, + t, + playerStatus.data, + resources.stripWeapons, + ); + } + + return [ + // the strip statuses ride along with every counter read; the shared + // timer value pairs the two events downstream + { ...counter.event, t }, + playerStatus, + ...(stripWeapons ? [stripWeapons] : []), + ]; + } + + /** The SZ counter read; null = no readable count on either side (a lookalike). */ + function* parseZonesSteps( + frame: Mat, + speculative: boolean, + ): MatchSteps<{ event: DetectedEvent } | null> { const gray = frameGray(frame); const [scoreL, scoreR, penaltyL, penaltyR, timer] = yield* all([ @@ -321,46 +430,16 @@ export function createObjectiveDetector( }; }) as [SideRead, SideRead]; - // no readable count on either side = the gate hit a lookalike - if (sides.every((side) => side.score.value === null)) return []; - - const playerStatus = parsePlayerStatus( - frame, - t, - timer.value, - lastStatus && t - lastStatus.t <= STATUS_LAYOUT_STICKY_MAX_GAP_S - ? lastStatus.layout - : undefined, - ); - lastStatus = { layout: playerStatus.data.layout, t }; - - // sampled slot-identity evidence for the strip → scoreboard-row assignment; - // identities are fixed so every read would re-measure at full sweep cost - let stripWeapons: DetectedEvent | null = null; - readsSinceWeaponSample++; - if ( - resources.stripWeapons && - readsSinceWeaponSample >= STRIP_WEAPON_SAMPLE_INTERVAL - ) { - readsSinceWeaponSample = 0; - stripWeapons = yield* parseStripWeaponsSteps( - frame, - t, - playerStatus.data, - resources.stripWeapons, - ); - } + if (sides.every((side) => side.score.value === null)) return null; const confidences = sides.flatMap((side) => [ ...(side.score.value !== null ? [side.score.confidence] : []), ...(side.penalty?.value != null ? [side.penalty.confidence] : []), ]); - return [ - // the strip statuses ride along with every counter read; the shared - // timer value pairs the two events downstream - { + return { + event: { type: OBJECTIVE_EVENT_TYPE, - t, + t: 0, confidence: confidences.reduce((a, b) => a + b, 0) / confidences.length, data: { mode: "SZ", @@ -370,7 +449,13 @@ export function createObjectiveDetector( sides[0].penalty?.value ?? null, sides[1].penalty?.value ?? null, ], - control: [sides[0].control, sides[1].control], + // both plates filled is impossible in-game: a misread, so neither + control: + sides[0].control === sides[1].control + ? null + : sides[0].control + ? 0 + : 1, teamColor: [sides[0].teamColor, sides[1].teamColor], }, debug: { @@ -381,9 +466,47 @@ export function createObjectiveDetector( plateFills: sides.map((side) => side.fill), }, }, - playerStatus, - ...(stripWeapons ? [stripWeapons] : []), + }; + } + + /** The TC/RM track read; null = neither the icon nor a plate read (a lookalike). */ + function* parseTrackSteps( + frame: Mat, + speculative: boolean, + ): MatchSteps<{ event: DetectedEvent } | null> { + const gray = frameGray(frame); + const [track, timer] = yield* all([ + readTrackSteps(frame, trackSets, speculative), + readMatchTimerSteps(gray, timerSets, speculative), + ]); + const plates = track.score.filter((read) => read.value !== null); + if (track.position === null && plates.length === 0) return null; + + const confidences = [ + ...(track.iconScore !== null ? [track.iconScore] : []), + ...plates.map((read) => read.confidence), ]; + return { + event: { + type: OBJECTIVE_EVENT_TYPE, + t: 0, + confidence: confidences.reduce((a, b) => a + b, 0) / confidences.length, + data: { + mode: track.mode, + time: timer.value, + score: [track.score[0].value, track.score[1].value], + control: track.holder, + position: track.position, + teamColor: track.teamColor, + }, + debug: { + timerReading: timer.reading, + scoreReadings: track.score.map((read) => read.reading), + scoreConfidences: track.score.map((read) => read.confidence), + ...track.debug, + }, + }, + }; } return { diff --git a/app/features/scanner/core/detectors/objective/rois.ts b/app/features/scanner/core/detectors/objective/rois.ts index e2fb451c8..837122968 100644 --- a/app/features/scanner/core/detectors/objective/rois.ts +++ b/app/features/scanner/core/detectors/objective/rois.ts @@ -460,3 +460,134 @@ export const STATUS_DPAD_PROBES_EVEN: readonly Roi[] = [ export const STATUS_WHITE_MIN_VALUE = 215; export const STATUS_WHITE_MAX_SPREAD = 40; export const STATUS_CAST_MIN_DPAD_WHITE = 0.25; + +// Tower Control / Rainmaker track overlay (track.ts): a dotted track under the +// icon strip, end to end x514..1405 at y155 in both modes, with the objective's +// icon riding it and each team's "Remaining" plate hanging under the point its +// push reached (so a side's plate always sits on the half it pushes into: +// the left team pushes right). Calibrated on the tower_control / rainmaker VoDs +// (720p upscaled) and the TC/RM death fixtures (German, lime/magenta, +// yellow/purple lobbies). + +export const TRACK_Y = 155; +export const TRACK_CENTER_X = 959.5; +export const TRACK_HALF_LENGTH = 445.5; + +/** + * Track dots sit at a fixed pitch and phase in every attested lobby and mode + * (DFT peak 14.10-14.15 px, dot centers x528.7 + k·14.13), whatever markers + * cover some of them. + */ +export const TRACK_DOT_PITCH = 14.13; +export const TRACK_FIRST_DOT_X = 528.7; +export const TRACK_COMB_SPAN: readonly [number, number] = [520, 1400]; +/** Rows above/below the line a dot stands out from: past its ~9px diameter. */ +export const TRACK_COMB_OFFSET_Y = 9; + +/** + * Comb projection at the dot phase: gameplay with the track reads >=17 (a + * dark backdrop leaves little between dots and ground), every other frame + * <=7 (SZ HUD, lobby, results, the intro). + */ +export const GATE_TRACK_MIN_COMB = 11; + +/** + * Held icon: team-ink disc (outer radius ~18) with a white squid glyph filling + * r7-11. Neutral (the anchor badge, dropped Rainmaker / idle tower): white + * ring r18-21 around an olive disc r13-16. + */ +export const TRACK_ICON_SPAN: readonly [number, number] = [505, 1415]; +export const TRACK_ICON_RING_RADII = [15.5, 16.5, 17.5] as const; +export const TRACK_ICON_CORE_RADII = [8, 9.5, 11] as const; +export const TRACK_NEUTRAL_RING_RADII = [18.5, 19.5, 20.5] as const; +export const TRACK_NEUTRAL_DISC_RADII = [13.5, 14.5, 15.5] as const; +export const TRACK_ICON_Y_JITTER = 1; + +/** + * Icon pixel classes: saturated ink / white glyph / the neutral badge's olive + * (~130,133,30 in every lobby). Pale sky (~188,220,252) must be neither ink nor + * white, or a sky backdrop scores as the neutral badge's white ring. + */ +export const TRACK_INK_MIN_SPREAD = 80; +export const TRACK_INK_MIN_VALUE = 90; +export const TRACK_WHITE_MIN_VALUE = 170; +export const TRACK_WHITE_MAX_SPREAD = 55; +/** Olive is duller than team ink: compressed footage reads it at spread ~60. */ +export const TRACK_OLIVE_HUE_RANGE: readonly [number, number] = [40, 75]; +export const TRACK_OLIVE_MIN_SPREAD = 45; +export const TRACK_OLIVE_MAX_VALUE = 190; + +/** + * Checkpoint markers tell the modes apart: TC squares (31 or 40px, ~3px black + * frame, pale ink quadrants), RM pedestals (white base ~47px wide at y169, white cap y133-141, ink + * body above). Squares are scored off the ends (both modes draw rings there); + * pedestals up to them, as RM's goal pedestals replace the end rings. + */ +export const TRACK_SQUARE_SPAN: readonly [number, number] = [545, 1375]; +export const TRACK_PEDESTAL_SPAN: readonly [number, number] = [505, 1415]; +export const TRACK_SQUARE_SIZES = [31, 40] as const; +export const TRACK_PEDESTAL = { + baseY: 169, + baseHalfWidth: 18, + bodyY: 160, + capY: 137, + capHalfWidth: 10, +}; +export const TRACK_MARKER_MIN_SCORE = 0.75; +export const TRACK_MARKER_ICON_CLEARANCE = 30; +export const TRACK_DARK_MAX_VALUE = 60; + +/** Shape score (ring fraction × core fraction) an icon must reach. */ +export const TRACK_ICON_MIN_SCORE = 0.5; + +/** A held icon's ink hue must sit this close to its team's strip hue. */ +export const TRACK_ICON_MAX_TEAM_HUE_DIST = 40; + +/** + * "Remaining" plates: localized label over ~35px white digits with a dark + * outline; digits sit y220-255 wherever the plate slides. The band spans every + * plate position (centers x514..1405, ~90px wide). + */ +export const TRACK_PLATE_DIGIT_ROI: Roi = { x: 460, y: 214, w: 1000, h: 48 }; +export const TRACK_PLATE_TEXT_HEIGHTS = [33, 36] as const; +export const TRACK_PLATE_BIN_THRESHOLD = 190; +export const TRACK_PLATE_DIGIT_MIN_CONF = 0.75; +/** Plate ink sampled beside a digit run: this far out and this tall. */ +export const TRACK_PLATE_INK_PAD_X = 10; +/** + * A plate sits on the half its team pushes into; the tip can reach just past + * the center when a side's record is still ~100. + */ +export const TRACK_PLATE_CENTER_SLACK = 30; + +/** + * Each team's ink off its own end of the track: the end marker's core (TC + * ring center, RM pedestal body) and the first four dots are always drawn in + * the ink of the team defending that end. Kept tight: the backdrop around them + * is often inked too. The icon only covers them when pushed there by the other + * team, so they are skipped while it is within reach. + */ +export const TRACK_END_INK_ROIS: readonly [readonly Roi[], readonly Roi[]] = + (() => { + const dot = (k: number): Roi => ({ + x: Math.round(TRACK_FIRST_DOT_X + k * TRACK_DOT_PITCH) - 2, + y: TRACK_Y - 2, + w: 4, + h: 4, + }); + const lastDot = Math.floor((1400 - TRACK_FIRST_DOT_X) / TRACK_DOT_PITCH); + return [ + [{ x: 508, y: 149, w: 12, h: 12 }, ...[0, 1, 2, 3].map(dot)], + [ + { x: 1399, y: 149, w: 12, h: 12 }, + ...[0, 1, 2, 3].map((k) => dot(lastDot - k)), + ], + ]; + })(); +export const TRACK_END_INK_ICON_CLEARANCE = 25; + +/** Fallback: the icon strip (team-ink squid plates) per side; splats and backdrop can drown it. */ +export const TRACK_STRIP_INK_ROIS: readonly [Roi, Roi] = [ + { x: 520, y: 40, w: 370, h: 55 }, + { x: 1030, y: 40, w: 370, h: 55 }, +]; diff --git a/app/features/scanner/core/detectors/objective/track.ts b/app/features/scanner/core/detectors/objective/track.ts new file mode 100644 index 000000000..d2ed00c64 --- /dev/null +++ b/app/features/scanner/core/detectors/objective/track.ts @@ -0,0 +1,611 @@ +/** + * Tower Control / Rainmaker counter overlay: a dotted track under the icon + * strip with the objective's icon riding it, and a "Remaining" plate per team + * hanging under the furthest point that team pushed to. Both modes draw the + * same overlay (only the checkpoint markers differ), so the read is shared. + * + * - Gate: the track's dots sit at a fixed pitch and phase in every lobby, so + * a comb projected at that phase (dot rows standing out from the rows just + * above and below) proves the track whatever the backdrop and markers. + * - Icon: scored procedurally along the line, no templates. A held icon is a + * team-ink disc around a white glyph; the neutral one (idle tower, dropped + * Rainmaker) a white ring around an olive disc. Its x maps linearly onto + * -100 (left end) .. 100 (right end); the left team pushes right. + * - Holder: the held icon's ink hue against each side's ink, read off that + * side's own end of the track (its marker and first dots). + * - Scores: every confident digit run in the plate band, each assigned to the + * team whose ink surrounds it — a plate sits on the half its team pushes + * into, which bounds the assignment. No label is read (localized). + */ +import type { Mat } from "../../cv"; +import { type GlyphSet, recognizeTextSteps } from "../../glyphs"; +import { copyRoi, minChannel, type Roi } from "../../image"; +import { + dominantInkColor, + hueDistance, + hueOf, + type InkRgb, +} from "../../ink-color"; +import { all, type MatchSteps } from "../../match-steps"; +import type { BannerScoreRead } from "../scoreboard/banner"; +import { + TRACK_CENTER_X, + TRACK_COMB_OFFSET_Y, + TRACK_COMB_SPAN, + TRACK_DARK_MAX_VALUE, + TRACK_DOT_PITCH, + TRACK_END_INK_ICON_CLEARANCE, + TRACK_END_INK_ROIS, + TRACK_FIRST_DOT_X, + TRACK_HALF_LENGTH, + TRACK_ICON_CORE_RADII, + TRACK_ICON_MAX_TEAM_HUE_DIST, + TRACK_ICON_MIN_SCORE, + TRACK_ICON_RING_RADII, + TRACK_ICON_SPAN, + TRACK_ICON_Y_JITTER, + TRACK_INK_MIN_SPREAD, + TRACK_INK_MIN_VALUE, + TRACK_MARKER_ICON_CLEARANCE, + TRACK_MARKER_MIN_SCORE, + TRACK_NEUTRAL_DISC_RADII, + TRACK_NEUTRAL_RING_RADII, + TRACK_OLIVE_HUE_RANGE, + TRACK_OLIVE_MAX_VALUE, + TRACK_OLIVE_MIN_SPREAD, + TRACK_PEDESTAL, + TRACK_PEDESTAL_SPAN, + TRACK_PLATE_BIN_THRESHOLD, + TRACK_PLATE_CENTER_SLACK, + TRACK_PLATE_DIGIT_MIN_CONF, + TRACK_PLATE_DIGIT_ROI, + TRACK_PLATE_INK_PAD_X, + TRACK_SQUARE_SIZES, + TRACK_SQUARE_SPAN, + TRACK_STRIP_INK_ROIS, + TRACK_WHITE_MAX_SPREAD, + TRACK_WHITE_MIN_VALUE, + TRACK_Y, +} from "./rois"; + +const RING_SAMPLES = 32; +/** Digits of one number nearly touch; a gap past this fraction of a digit width ends the run. */ +const DIGIT_GAP_MAX_RATIO = 0.55; +/** Digits span the set's full height; the label's lowercase letters top out lower. */ +const DIGIT_MIN_HEIGHT_RATIO = 0.82; +const MAX_COUNT = 100; + +export interface TrackRead { + /** icon position, -100 (left end) .. 100 (right end); null = no icon found */ + position: number | null; + /** which side holds the objective; null = neutral or unattributable */ + holder: 0 | 1 | null; + score: [BannerScoreRead, BannerScoreRead]; + teamColor: [InkRgb | null, InkRgb | null]; + /** TC by its checkpoint squares, RM by its pedestals; null when neither shows */ + mode: "TC" | "RM" | null; + /** the icon's shape score; null = no icon */ + iconScore: number | null; + debug: Record; +} + +/** Comb projection of the track's dot row at its fixed phase; see GATE_TRACK_MIN_COMB. */ +export function trackComb(frame: Mat): number { + const [x0, x1] = TRACK_COMB_SPAN; + const band = copyRoi(frame, { + x: x0, + y: TRACK_Y - TRACK_COMB_OFFSET_Y - 1, + w: x1 - x0 + 1, + h: 2 * TRACK_COMB_OFFSET_Y + 3, + }); + const { data, cols } = band; + const channels = band.channels(); + const at = (x: number, y: number) => (y * cols + x) * channels; + const distance = (a: number, b: number) => + Math.abs(data[a]! - data[b]!) + + Math.abs(data[a + 1]! - data[b + 1]!) + + Math.abs(data[a + 2]! - data[b + 2]!); + + const contrast: number[] = []; + for (let x = 0; x < cols; x++) { + let sum = 0; + for (let dy = -1; dy <= 1; dy++) { + const y = TRACK_COMB_OFFSET_Y + 1 + dy; + const center = at(x, y); + sum += Math.min( + distance(center, at(x, y - TRACK_COMB_OFFSET_Y)), + distance(center, at(x, y + TRACK_COMB_OFFSET_Y)), + ); + } + contrast.push(sum / 3); + } + band.delete(); + + const mean = contrast.reduce((a, b) => a + b, 0) / contrast.length; + let projection = 0; + for (const [i, value] of contrast.entries()) { + const phase = + (2 * Math.PI * (x0 + i - TRACK_FIRST_DOT_X)) / TRACK_DOT_PITCH; + projection += (value - mean) * Math.cos(phase); + } + return projection / contrast.length; +} + +/** The track overlay's icon, holder, plates and team inks off one frame. */ +export function* readTrackSteps( + frame: Mat, + digitSets: readonly GlyphSet[], + speculative: boolean, +): MatchSteps { + const band = trackBandClasses(frame); + const icon = locateIcon(band); + const markers = markerMode(band, icon); + const teamColor = ([0, 1] as const).map((side) => { + const rois = TRACK_END_INK_ROIS[side]; + const iconClear = + icon === null || + rois.every( + (roi) => + Math.abs(icon.x - (roi.x + roi.w / 2)) > TRACK_END_INK_ICON_CLEARANCE, + ); + return ( + (iconClear ? dominantInkColor(frame, rois) : null) ?? + dominantInkColor(frame, [TRACK_STRIP_INK_ROIS[side]]) + ); + }) as [InkRgb | null, InkRgb | null]; + const teamHues = teamColor.map((color) => (color ? hueOf(color) : null)) as [ + number | null, + number | null, + ]; + + const holder = + icon?.kind === "held" && icon.hue !== null + ? nearestSide(icon.hue, teamHues) + : null; + + const plateReads = yield* readPlates(frame, digitSets, teamHues, speculative); + + return { + position: + icon === null + ? null + : Math.round( + Math.max( + -100, + Math.min( + 100, + ((icon.x - TRACK_CENTER_X) / TRACK_HALF_LENGTH) * 100, + ), + ), + ), + holder, + score: plateReads.score, + teamColor, + mode: markers.mode, + iconScore: icon?.score ?? null, + debug: { + icon, + markers, + teamHues, + plates: plateReads.runs, + }, + }; +} + +interface IconHit { + kind: "held" | "neutral"; + x: number; + y: number; + score: number; + /** circular-mean hue of the held icon's ink ring; null for neutral */ + hue: number | null; +} + +interface PixelClasses { + cols: number; + rows: number; + ink: Uint8Array; + white: Uint8Array; + olive: Uint8Array; + dark: Uint8Array; + hue: Float32Array; + /** band origin in frame coordinates */ + x0: number; + y0: number; +} + +/** Pixel classes of the band the icon and the checkpoint markers can occupy. */ +function trackBandClasses(frame: Mat): PixelClasses { + const maxRadius = Math.max(...TRACK_NEUTRAL_RING_RADII) + 1; + const margin = Math.ceil(maxRadius) + TRACK_ICON_Y_JITTER; + const [spanX0, spanX1] = TRACK_ICON_SPAN; + return classifyPixels(frame, { + x: spanX0 - margin, + y: TRACK_Y - margin, + w: spanX1 - spanX0 + 2 * margin + 1, + h: 2 * margin + 1, + }); +} + +/** Best held or neutral icon along the track line; null under TRACK_ICON_MIN_SCORE. */ +function locateIcon(classes: PixelClasses): IconHit | null { + const [spanX0, spanX1] = TRACK_ICON_SPAN; + + const scoreAt = (kind: IconHit["kind"], x: number, y: number) => + kind === "held" + ? ringFraction(classes, x, y, TRACK_ICON_RING_RADII, classes.ink) * + ringFraction(classes, x, y, TRACK_ICON_CORE_RADII, classes.white) + : ringFraction(classes, x, y, TRACK_NEUTRAL_RING_RADII, classes.white) * + ringFraction(classes, x, y, TRACK_NEUTRAL_DISC_RADII, classes.olive); + + let best: IconHit | null = null; + for (const kind of ["held", "neutral"] as const) { + for (let x = spanX0; x <= spanX1; x++) { + const score = scoreAt(kind, x, TRACK_Y); + if (!best || score > best.score) { + best = { kind, x, y: TRACK_Y, score, hue: null }; + } + } + } + if (!best) return null; + for (let dy = -TRACK_ICON_Y_JITTER; dy <= TRACK_ICON_Y_JITTER; dy++) { + for (let dx = -2; dx <= 2; dx++) { + const score = scoreAt(best.kind, best.x + dx, TRACK_Y + dy); + if (score > best.score) { + best = { ...best, x: best.x + dx, y: TRACK_Y + dy, score }; + } + } + } + if (best.score < TRACK_ICON_MIN_SCORE) return null; + return best.kind === "held" + ? { ...best, hue: ringHue(classes, best.x, best.y) } + : best; +} + +function classifyPixels(frame: Mat, roi: Roi): PixelClasses { + const crop = copyRoi(frame, roi); + const { data, cols, rows } = crop; + const channels = crop.channels(); + const ink = new Uint8Array(cols * rows); + const white = new Uint8Array(cols * rows); + const olive = new Uint8Array(cols * rows); + const dark = new Uint8Array(cols * rows); + const hue = new Float32Array(cols * rows); + for (let i = 0; i < cols * rows; i++) { + const color = { + r: data[i * channels]!, + g: data[i * channels + 1]!, + b: data[i * channels + 2]!, + }; + const max = Math.max(color.r, color.g, color.b); + const min = Math.min(color.r, color.g, color.b); + const spread = max - min; + ink[i] = + spread >= TRACK_INK_MIN_SPREAD && max >= TRACK_INK_MIN_VALUE ? 1 : 0; + white[i] = + min >= TRACK_WHITE_MIN_VALUE && spread <= TRACK_WHITE_MAX_SPREAD ? 1 : 0; + dark[i] = max <= TRACK_DARK_MAX_VALUE ? 1 : 0; + hue[i] = ink[i] ? hueOf(color) : 0; + if (spread >= TRACK_OLIVE_MIN_SPREAD && max <= TRACK_OLIVE_MAX_VALUE) { + const oliveHue = hueOf(color); + olive[i] = + oliveHue >= TRACK_OLIVE_HUE_RANGE[0] && + oliveHue <= TRACK_OLIVE_HUE_RANGE[1] + ? 1 + : 0; + } + } + crop.delete(); + return { cols, rows, ink, white, olive, dark, hue, x0: roi.x, y0: roi.y }; +} + +/** + * TC or RM off the checkpoint markers: TC draws black-framed squares (a + * crosshair over pale ink quadrants), RM team-ink pedestals between a white + * cap and base. Markers the icon covers are skipped. Over the TC/RM VoDs: + * pedestals score >=0.8 on RM and <=0.2 on TC; squares >=0.5 (mostly >=0.8, + * lower as passed checkpoints vanish) on TC and <=0.7 on RM. + */ +function markerMode( + classes: PixelClasses, + icon: IconHit | null, +): { mode: "TC" | "RM" | null; square: number; pedestal: number } { + const fraction = ( + mask: Uint8Array, + points: readonly (readonly [number, number])[], + ) => { + let hits = 0; + for (const [x, y] of points) { + const px = Math.round(x) - classes.x0; + const py = Math.round(y) - classes.y0; + if (px < 0 || py < 0 || px >= classes.cols || py >= classes.rows) { + continue; + } + hits += mask[py * classes.cols + px]!; + } + return hits / points.length; + }; + const line = (fromX: number, fromY: number, toX: number, toY: number) => + Array.from({ length: 12 }, (_, i) => { + const f = i / 11; + return [fromX + (toX - fromX) * f, fromY + (toY - fromY) * f] as const; + }); + + let square = 0; + let pedestal = 0; + const within = (x: number, [lo, hi]: readonly [number, number]) => + x >= lo && x <= hi; + for ( + let x = Math.min(TRACK_SQUARE_SPAN[0], TRACK_PEDESTAL_SPAN[0]); + x <= Math.max(TRACK_SQUARE_SPAN[1], TRACK_PEDESTAL_SPAN[1]); + x++ + ) { + if (icon && Math.abs(icon.x - x) < TRACK_MARKER_ICON_CLEARANCE) continue; + for (const size of within(x, TRACK_SQUARE_SPAN) ? TRACK_SQUARE_SIZES : []) { + const h = size / 2 - 1.5; + const outline = [ + ...line(x - h, TRACK_Y - h, x + h, TRACK_Y - h), + ...line(x - h, TRACK_Y + h, x + h, TRACK_Y + h), + ...line(x - h, TRACK_Y - h, x - h, TRACK_Y + h), + ...line(x + h, TRACK_Y - h, x + h, TRACK_Y + h), + ]; + const q = size / 4; + const quadrants = [ + [x - q, TRACK_Y - q], + [x + q, TRACK_Y - q], + [x - q, TRACK_Y + q], + [x + q, TRACK_Y + q], + ] as const; + square = Math.max( + square, + fraction(classes.dark, outline) * fraction(classes.ink, quadrants), + ); + } + if (!within(x, TRACK_PEDESTAL_SPAN)) continue; + const { baseY, baseHalfWidth, bodyY, capY, capHalfWidth } = TRACK_PEDESTAL; + const base = line(x - baseHalfWidth, baseY, x + baseHalfWidth, baseY); + const body = line( + x - baseHalfWidth / 2, + bodyY, + x + baseHalfWidth / 2, + bodyY, + ); + const cap = line(x - capHalfWidth, capY, x + capHalfWidth, capY); + pedestal = Math.max( + pedestal, + fraction(classes.white, base) * + fraction(classes.ink, body) * + fraction(classes.white, cap), + ); + } + // a pedestal is the sharper proof (TC backdrops never scored one), so it wins a tie + const mode = + pedestal >= TRACK_MARKER_MIN_SCORE + ? "RM" + : square >= TRACK_MARKER_MIN_SCORE + ? "TC" + : null; + return { mode, square, pedestal }; +} + +const ringOffsets = new Map(); + +function offsetsFor(radius: number): [number, number][] { + let offsets = ringOffsets.get(radius); + if (!offsets) { + offsets = Array.from({ length: RING_SAMPLES }, (_, i) => { + const angle = (2 * Math.PI * i) / RING_SAMPLES; + return [ + Math.round(radius * Math.cos(angle)), + Math.round(radius * Math.sin(angle)), + ] as [number, number]; + }); + ringOffsets.set(radius, offsets); + } + return offsets; +} + +/** Fraction of the rings' samples around (x, y) (frame coordinates) set in `mask`. */ +function ringFraction( + classes: PixelClasses, + x: number, + y: number, + radii: readonly number[], + mask: Uint8Array, +): number { + let hits = 0; + let total = 0; + for (const radius of radii) { + for (const [dx, dy] of offsetsFor(radius)) { + const px = x + dx - classes.x0; + const py = y + dy - classes.y0; + total++; + if (px < 0 || py < 0 || px >= classes.cols || py >= classes.rows) { + continue; + } + hits += mask[py * classes.cols + px]!; + } + } + return hits / total; +} + +/** Circular mean hue of the ink samples on a held icon's ring. */ +function ringHue(classes: PixelClasses, x: number, y: number): number | null { + const indices = TRACK_ICON_RING_RADII.flatMap((radius) => + offsetsFor(radius).map( + ([dx, dy]) => + (y + dy - classes.y0) * classes.cols + (x + dx - classes.x0), + ), + ); + return meanInkHue(classes, indices); +} + +/** Circular mean hue of the ink pixels among `indices`; null when none is ink. */ +function meanInkHue( + classes: PixelClasses, + indices: Iterable, +): number | null { + let sin = 0; + let cos = 0; + let count = 0; + for (const index of indices) { + if (!classes.ink[index]) continue; + const angle = (classes.hue[index]! * Math.PI) / 180; + sin += Math.sin(angle); + cos += Math.cos(angle); + count++; + } + if (count === 0) return null; + return ((Math.atan2(sin, cos) * 180) / Math.PI + 360) % 360; +} + +/** The side whose ink hue sits closest to `hue`, if close enough and closer than the other. */ +function nearestSide( + hue: number, + teamHues: readonly [number | null, number | null], +): 0 | 1 | null { + const distances = teamHues.map((teamHue) => + teamHue === null ? Number.POSITIVE_INFINITY : hueDistance(hue, teamHue), + ); + const side: 0 | 1 = distances[0]! <= distances[1]! ? 0 : 1; + return distances[side]! <= TRACK_ICON_MAX_TEAM_HUE_DIST ? side : null; +} + +interface PlateRun { + value: number; + confidence: number; + digits: number; + /** run center, frame x */ + x: number; + hue: number | null; + side: 0 | 1 | null; + reading: string; +} + +/** Every confident digit run in the plate band, each assigned to a side. */ +function* readPlates( + frame: Mat, + digitSets: readonly GlyphSet[], + teamHues: readonly [number | null, number | null], + speculative: boolean, +): MatchSteps<{ score: [BannerScoreRead, BannerScoreRead]; runs: PlateRun[] }> { + const empty = (reading: string): BannerScoreRead => ({ + value: null, + confidence: 0, + digits: 0, + reading, + }); + const score: [BannerScoreRead, BannerScoreRead] = [empty(""), empty("")]; + if (digitSets.length === 0) return { score, runs: [] }; + + const band = minChannel(frame, TRACK_PLATE_DIGIT_ROI); + const raws = yield* all( + digitSets.map((set) => + recognizeTextSteps( + band, + set, + { + binThreshold: TRACK_PLATE_BIN_THRESHOLD, + spaceGap: Number.POSITIVE_INFINITY, + minCharScore: 0.3, + }, + speculative, + ), + ), + ); + band.delete(); + + const runs: PlateRun[] = []; + for (const [i, set] of digitSets.entries()) { + const raw = raws[i]!; + const maxGap = Math.max( + 4, + Math.round(set.medianWidth * DIGIT_GAP_MAX_RATIO), + ); + const digits = raw.chars.filter( + (c) => + /^\d$/.test(c.char) && + c.score >= TRACK_PLATE_DIGIT_MIN_CONF && + c.y1 - c.y0 >= set.height * DIGIT_MIN_HEIGHT_RATIO, + ); + let group: typeof digits = []; + const flush = () => { + if (group.length > 0 && group.length <= 3) { + const value = Number.parseInt(group.map((c) => c.char).join(""), 10); + if (value <= MAX_COUNT) { + const x0 = TRACK_PLATE_DIGIT_ROI.x + group[0]!.x0; + const x1 = TRACK_PLATE_DIGIT_ROI.x + group.at(-1)!.x1; + const hue = plateHue(frame, x0, x1); + const x = (x0 + x1) / 2; + runs.push({ + value, + confidence: Math.min(...group.map((c) => c.score)), + digits: group.length, + x, + hue, + side: plateSide(x, hue, teamHues), + reading: raw.text, + }); + } + } + group = []; + }; + for (const c of digits) { + if (group.length > 0 && c.x0 - group.at(-1)!.x1 > maxGap) flush(); + group.push(c); + } + flush(); + } + + for (const run of runs) { + if (run.side === null) continue; + const current = score[run.side]; + const better = + current.value === null || + run.digits > current.digits || + (run.digits === current.digits && run.confidence > current.confidence); + if (better) { + score[run.side] = { + value: run.value, + confidence: run.confidence, + digits: run.digits, + reading: run.reading, + }; + } + } + return { score, runs }; +} + +/** Circular mean hue of the plate ink flanking a digit run (the plate body left and right of it). */ +function plateHue(frame: Mat, x0: number, x1: number): number | null { + const roi: Roi = { + x: Math.max(0, Math.round(x0) - TRACK_PLATE_INK_PAD_X), + y: TRACK_PLATE_DIGIT_ROI.y, + w: Math.round(x1 - x0) + 2 * TRACK_PLATE_INK_PAD_X, + h: TRACK_PLATE_DIGIT_ROI.h, + }; + const classes = classifyPixels(frame, roi); + return meanInkHue(classes, classes.ink.keys()); +} + +/** + * The side a plate belongs to: by ink when the teams' hues are known, but a + * side's plate never sits deep in the half the other side pushes into. + */ +function plateSide( + x: number, + hue: number | null, + teamHues: readonly [number | null, number | null], +): 0 | 1 | null { + const allowed = ([0, 1] as const).filter((candidate) => + candidate === 0 + ? x >= TRACK_CENTER_X - TRACK_PLATE_CENTER_SLACK + : x <= TRACK_CENTER_X + TRACK_PLATE_CENTER_SLACK, + ); + const inked = hue === null ? null : nearestSide(hue, teamHues); + if (inked !== null) return allowed.includes(inked) ? inked : null; + // no ink verdict to go on: the position decides when it allows one side only + const inkUnknown = + hue === null || teamHues.every((teamHue) => teamHue === null); + return inkUnknown && allowed.length === 1 ? allowed[0]! : null; +} diff --git a/app/features/scanner/core/ink-color.ts b/app/features/scanner/core/ink-color.ts index 45103be36..49de0636e 100644 --- a/app/features/scanner/core/ink-color.ts +++ b/app/features/scanner/core/ink-color.ts @@ -58,6 +58,66 @@ export function meanInkColor( }; } +/** Hue histogram bin width for dominantInkColor. */ +const HUE_BIN_DEGREES = 10; +/** Pixels within this many degrees of the modal hue join its mean color. */ +const DOMINANT_HUE_TOLERANCE = 20; +/** Value floor for dominantInkColor: dark saturated backdrop is not UI ink. */ +const DOMINANT_INK_MIN_VALUE = 105; + +/** + * Mean RGB of the ink pixels around the ROIs' modal hue: for regions where + * team ink dominates but other saturated art (weapon renders) is mixed in, + * which a plain mean would pull off the team's hue. Null on too little ink. + */ +export function dominantInkColor( + frame: Mat, + rois: readonly Roi[], +): InkRgb | null { + const pixels: { r: number; g: number; b: number; hue: number }[] = []; + for (const roi of rois) { + const crop = copyRoi(frame, roi); + const { data } = crop; + const channels = crop.channels(); + for (let i = 0; i < data.length; i += channels) { + const color = { r: data[i]!, g: data[i + 1]!, b: data[i + 2]! }; + const max = Math.max(color.r, color.g, color.b); + const spread = max - Math.min(color.r, color.g, color.b); + if (spread < INK_MIN_SATURATION || max < DOMINANT_INK_MIN_VALUE) continue; + pixels.push({ ...color, hue: hueOf(color) }); + } + crop.delete(); + } + if (pixels.length < MIN_INK_PIXELS) return null; + + const bins = new Array(360 / HUE_BIN_DEGREES).fill(0); + for (const { hue } of pixels) { + bins[Math.floor(hue / HUE_BIN_DEGREES) % bins.length]!++; + } + let modeBin = 0; + let modeCount = -1; + for (let bin = 0; bin < bins.length; bin++) { + const count = + bins[(bin + bins.length - 1) % bins.length]! + + bins[bin]! + + bins[(bin + 1) % bins.length]!; + if (count > modeCount) { + modeCount = count; + modeBin = bin; + } + } + const modeHue = (modeBin + 0.5) * HUE_BIN_DEGREES; + const near = pixels.filter( + (pixel) => hueDistance(pixel.hue, modeHue) <= DOMINANT_HUE_TOLERANCE, + ); + if (near.length < MIN_INK_PIXELS) return null; + return { + r: Math.round(near.reduce((sum, p) => sum + p.r, 0) / near.length), + g: Math.round(near.reduce((sum, p) => sum + p.g, 0) / near.length), + b: Math.round(near.reduce((sum, p) => sum + p.b, 0) / near.length), + }; +} + /** Hue angle of an ink color, degrees on the 0-360 color wheel. */ export function hueOf(color: InkRgb): number { const max = Math.max(color.r, color.g, color.b); diff --git a/app/features/scanner/core/match-builder.ts b/app/features/scanner/core/match-builder.ts index 260be64bc..950f78012 100644 --- a/app/features/scanner/core/match-builder.ts +++ b/app/features/scanner/core/match-builder.ts @@ -11,6 +11,7 @@ import type { AbilityWithUnknown, MainWeaponId, + ModeShort, StageId, } from "~/modules/in-game-lists/types"; import { @@ -340,31 +341,81 @@ export function ingestSkipReasons( } /** - * 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. + * Objective-counter reads on a match whose detected mode rules their overlay + * out — lookalike misreads the builder already left out of the match's + * `objective` — plus, on a mode with no parsed overlay (Turf War, Clam + * Blitz), the player-status and strip-weapon reads riding along with them. + * Callers should delete these from their stores. A match with no mode read + * yet loses nothing: its minority overlay is only left out of the build. */ export function invalidObjectiveEvents( built: readonly BuiltMatch[], ): 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, - ), - ); + return built.flatMap(({ match, sources }) => { + if (match.mode === null) return []; + const kind = matchCounterKind(match.mode, []); + return sources.filter((event) => { + if (event.type === OBJECTIVE_EVENT_TYPE) { + return counterKindOfRead(event.data as ObjectiveData) !== kind; + } + return ( + kind === null && + (event.type === PLAYER_STATUS_EVENT_TYPE || + event.type === STRIP_WEAPONS_EVENT_TYPE) + ); + }); + }); +} + +type CounterKind = "zones" | "track"; + +/** + * Which counter overlay the match's reads should come from: the one its mode + * draws, or with the mode unknown whichever kind most reads saw (the other is + * a lookalike). Null = a mode with no parsed overlay, or no reads. + */ +function matchCounterKind( + mode: ModeShort | null, + reads: readonly ObjectiveData[], +): CounterKind | null { + if (mode === "SZ") return "zones"; + if (mode === "TC" || mode === "RM") return "track"; + if (mode !== null) return null; + const trackReads = reads.filter( + (read) => counterKindOfRead(read) === "track", + ).length; + if (reads.length === 0) return null; + return trackReads * 2 > reads.length ? "track" : "zones"; +} + +function counterKindOfRead(data: ObjectiveData): CounterKind { + return data.mode === "SZ" ? "zones" : "track"; +} + +/** + * The objective's mode: the match's when known, else what most track reads' + * checkpoint markers showed; null when a track match's markers never read. + */ +function objectiveMode( + kind: CounterKind, + mode: ModeShort | null, + reads: readonly ObjectiveData[], +): ScannerMatchObjective["mode"] { + if (kind === "zones") return "SZ"; + if (mode === "TC" || mode === "RM") return mode; + const votes = { TC: 0, RM: 0 }; + for (const read of reads) { + if (read.mode === "TC" || read.mode === "RM") votes[read.mode]++; + } + if (votes.TC === votes.RM) return null; + return votes.TC > votes.RM ? "TC" : "RM"; } /** * 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). + * gave it — a game ends no sooner than the clock running out or (SZ) 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 @@ -373,19 +424,26 @@ function endedEarly(match: ScannerMatch): boolean { const lastSample = match.objective?.samples.at(-1); if (!lastSample || match.endsAt === null) return false; - const soonestEnd = secondsUntilSoonestEnd(lastSample); + const soonestEnd = secondsUntilSoonestEnd( + lastSample, + match.objective?.mode === "SZ", + ); if (soonestEnd === null) return false; const secondsLeftInFootage = match.endsAt - lastSample.t; return soonestEnd - secondsLeftInFootage > EARLY_END_MARGIN_SECONDS; } +/** Only SZ's count ticks at a known rate (1/s), so only it bounds a knockout. */ function secondsUntilSoonestEnd( sample: ScannerMatchObjectiveSample, + countsSeconds: boolean, ): number | null { - const knockouts = sample.score.map((score, team) => - score === null ? null : score + (sample.penalty[team] ?? 0), - ); + const knockouts = countsSeconds + ? 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, ); @@ -527,15 +585,29 @@ function toBuiltMatch( 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). + // reads of the overlay the mode doesn't draw are lookalike misreads, and on + // a mode with no parsed overlay the statuses riding along with them go too. // Minimap card states and the kill feed are mode-agnostic and feed their // samples regardless - const counterModeValid = mode === null || mode === "SZ"; + const counterKind = matchCounterKind( + mode, + objectives.map((read) => read.data), + ); + const counterReads = objectives.filter( + (read) => counterKindOfRead(read.data) === counterKind, + ); + const statusesValid = counterKind !== null || mode === null; const progress = buildProgress( - counterModeValid ? objectives : [], - counterModeValid ? playerStatuses : [], - counterModeValid ? stripWeapons : [], + counterReads, + counterKind === null + ? null + : objectiveMode( + counterKind, + mode, + counterReads.map((read) => read.data), + ), + statusesValid ? playerStatuses : [], + statusesValid ? stripWeapons : [], minimapReads, killReads, board, @@ -627,6 +699,7 @@ function floorOrNull(t: number | undefined): number | null { */ function buildProgress( objectives: readonly { t: number; data: ObjectiveData }[], + mode: ScannerMatchObjective["mode"], playerStatuses: readonly { t: number; data: PlayerStatusData }[], stripWeapons: readonly { t: number; data: StripWeaponsData }[], minimapReads: readonly { t: number; data: MinimapData }[], @@ -686,7 +759,7 @@ function buildProgress( oriented.length === 0 ? null : { - mode: "SZ" as const, + mode, samples: oriented.map((read): ScannerMatchObjectiveSample => { const [a, b] = swap ? ([1, 0] as const) : ([0, 1] as const); return { @@ -694,7 +767,14 @@ function buildProgress( time: read.time, score: [read.score[a], read.score[b]], penalty: [read.penalty[a], read.penalty[b]], - control: [read.control[a], read.control[b]], + control: swap ? flippedSide(read.control) : read.control, + ...(read.position !== undefined + ? { + position: swap + ? flippedPosition(read.position) + : read.position, + } + : null), }; }), }; @@ -1113,7 +1193,9 @@ interface OrientedObjectiveRead { time: number | null; score: [number | null, number | null]; penalty: [number | null, number | null]; - control: [boolean, boolean]; + control: 0 | 1 | null; + /** TC/RM only: the objective along the track, positive = the first side's progress */ + position?: number | null; } /** @@ -1159,16 +1241,31 @@ function orientObjectives( ): OrientedObjectiveRead[] { return objectives.map(({ t, data }, i): OrientedObjectiveRead => { const [a, b] = swapFlags[i] ? ([1, 0] as const) : ([0, 1] as const); - return { + const oriented: OrientedObjectiveRead = { 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]], + penalty: + data.mode === "SZ" ? [data.penalty[a], data.penalty[b]] : [null, null], + control: swapFlags[i] ? flippedSide(data.control) : data.control, + }; + if (data.mode === "SZ") return oriented; + return { + ...oriented, + position: swapFlags[i] ? flippedPosition(data.position) : data.position, }; }); } +function flippedSide(side: 0 | 1 | null): 0 | 1 | null { + return side === null ? null : side === 0 ? 1 : 0; +} + +/** A track position seen from the other side: each team pushes toward the other's end. */ +function flippedPosition(position: number | null): number | null { + return position === null ? null : 0 - position; +} + function readSwapped( data: ObjectiveData, clusterHues: [number, number], diff --git a/app/features/scanner/core/scanner-match.ts b/app/features/scanner/core/scanner-match.ts index ee3c56413..f66947e67 100644 --- a/app/features/scanner/core/scanner-match.ts +++ b/app/features/scanner/core/scanner-match.ts @@ -40,10 +40,15 @@ export interface ScannerMatchObjectiveSample { time: number | null; /** displayed count per team, in `teams` order; null = unreadable */ score: [number | null, number | null]; - /** penalty pill value per team; null = no pill (or unreadable) */ + /** penalty pill value per team; null = no pill (or unreadable); always null on TC/RM */ penalty: [number | null, number | null]; - /** which team held the objective at the read */ - control: [boolean, boolean]; + /** which team held the objective at the read, in `teams` order; null = neither */ + control: 0 | 1 | null; + /** + * TC/RM only: the tower / Rainmaker along its track, -100..100 — positive + * toward the end `teams[0]` pushes to (its progress); null = icon unread + */ + position?: number | null; } /** @@ -53,7 +58,11 @@ export interface ScannerMatchObjectiveSample { * observed (capture gap, covered HUD): render it as unknown, don't interpolate. */ export interface ScannerMatchObjective { - mode: "SZ"; + /** + * the counter's mode; null on a TC/RM track whose mode neither the match + * nor the checkpoint markers told + */ + mode: "SZ" | "TC" | "RM" | null; samples: ScannerMatchObjectiveSample[]; } diff --git a/app/features/scanner/node/fixtures.ts b/app/features/scanner/node/fixtures.ts index 907065a92..5f32bba9b 100644 --- a/app/features/scanner/node/fixtures.ts +++ b/app/features/scanner/node/fixtures.ts @@ -77,7 +77,8 @@ interface ExpectedScoreboard { | "none"; data?: { lobby?: ScannerLobby; - mode?: ModeShort; + /** Objective: null = a TC/RM track whose checkpoint markers could not tell */ + mode?: ModeShort | null; stage?: StageId; /** informational for the human corrector; tests compare `stage` */ stageLabel?: string; @@ -109,8 +110,10 @@ interface ExpectedScoreboard { score?: [number | null, number | null]; /** Objective only: penalty pill value per team; null = no pill */ penalty?: [number | null, number | null]; - /** Objective only: which team currently holds the objective */ - control?: [boolean, boolean]; + /** Objective only: which team currently holds the objective; null = neither */ + control?: 0 | 1 | null; + /** Objective TC/RM only: the icon along the track, -100 (left end) .. 100 (right end); null = no icon */ + position?: number | null; /** PlayerStatus only: special held per slot, [left team, right team] */ special?: [boolean[], boolean[]]; /** PlayerStatus only: splatted per slot, [left team, right team] */ diff --git a/app/features/scanner/scanner-schemas.ts b/app/features/scanner/scanner-schemas.ts index 978a52935..eb2d8ae00 100644 --- a/app/features/scanner/scanner-schemas.ts +++ b/app/features/scanner/scanner-schemas.ts @@ -57,7 +57,7 @@ const scannerMatchTeamSchema = v.object({ const teamIndexSchema = v.union([v.literal(0), v.literal(1)]); -/** counters change at most 1/s, so a match yields a few hundred samples */ +/** counters (and a TC/RM track's position) change at most 1/s, so a match yields a few hundred samples */ const MAX_OBJECTIVE_SAMPLES = 1000; const scannerMatchObjectiveSampleSchema = v.object({ @@ -65,11 +65,14 @@ const scannerMatchObjectiveSampleSchema = v.object({ time: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))), score: v.tuple([v.nullable(v.number()), v.nullable(v.number())]), penalty: v.tuple([v.nullable(v.number()), v.nullable(v.number())]), - control: v.tuple([v.boolean(), v.boolean()]), + control: v.nullable(teamIndexSchema), + position: v.optional( + v.nullable(v.pipe(v.number(), v.minValue(-100), v.maxValue(100))), + ), }); const scannerMatchObjectiveSchema = v.object({ - mode: v.literal("SZ"), + mode: v.nullable(v.picklist(["SZ", "TC", "RM"])), samples: v.pipe( v.array(scannerMatchObjectiveSampleSchema), v.maxLength(MAX_OBJECTIVE_SAMPLES), diff --git a/app/features/scanner/tests/fixtures/kill/pov-rainmaker-masque/expected.json b/app/features/scanner/tests/fixtures/kill/pov-rainmaker-masque/expected.json new file mode 100644 index 000000000..31cc66221 --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/pov-rainmaker-masque/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Kill", + "data": { + "time": 263, + "names": ["Masque"] + }, + "options": { + "notes": "Frame shared with objective/rainmaker-dropped-neutral (rainmaker VoD, Rainmaker overlay)." + } +} diff --git a/app/features/scanner/tests/fixtures/kill/pov-rainmaker-masque/frame.png b/app/features/scanner/tests/fixtures/kill/pov-rainmaker-masque/frame.png new file mode 120000 index 000000000..8feeb2fc5 --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/pov-rainmaker-masque/frame.png @@ -0,0 +1 @@ +../../objective/rainmaker-dropped-neutral/frame.png \ No newline at end of file diff --git a/app/features/scanner/tests/fixtures/kill/pov-tower-control-bandana-safari/expected.json b/app/features/scanner/tests/fixtures/kill/pov-tower-control-bandana-safari/expected.json new file mode 100644 index 000000000..3fd7289a9 --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/pov-tower-control-bandana-safari/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Kill", + "data": { + "time": 217, + "names": ["Safari", "Bandana"] + }, + "options": { + "notes": "Frame shared with objective/tower-control-bravo-deep-push-sky (tower_control VoD, Tower Control overlay)." + } +} diff --git a/app/features/scanner/tests/fixtures/kill/pov-tower-control-bandana-safari/frame.png b/app/features/scanner/tests/fixtures/kill/pov-tower-control-bandana-safari/frame.png new file mode 120000 index 000000000..79dc8caad --- /dev/null +++ b/app/features/scanner/tests/fixtures/kill/pov-tower-control-bandana-safari/frame.png @@ -0,0 +1 @@ +../../objective/tower-control-bravo-deep-push-sky/frame.png \ No newline at end of file diff --git a/app/features/scanner/tests/fixtures/objective/rainmaker-alpha-carry/expected.json b/app/features/scanner/tests/fixtures/objective/rainmaker-alpha-carry/expected.json new file mode 100644 index 000000000..a5f215a11 --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/rainmaker-alpha-carry/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Objective", + "data": { + "mode": "RM", + "time": 208, + "score": [44, 72], + "control": 0, + "position": 55 + } +} diff --git a/app/features/scanner/tests/fixtures/objective/rainmaker-alpha-carry/frame.png b/app/features/scanner/tests/fixtures/objective/rainmaker-alpha-carry/frame.png new file mode 100644 index 000000000..89ef9ffb0 Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/rainmaker-alpha-carry/frame.png differ diff --git a/app/features/scanner/tests/fixtures/objective/rainmaker-bravo-carry/expected.json b/app/features/scanner/tests/fixtures/objective/rainmaker-bravo-carry/expected.json new file mode 100644 index 000000000..742dfce95 --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/rainmaker-bravo-carry/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Objective", + "data": { + "mode": "RM", + "time": 123, + "score": [21, 72], + "control": 1, + "position": 28 + } +} diff --git a/app/features/scanner/tests/fixtures/objective/rainmaker-bravo-carry/frame.png b/app/features/scanner/tests/fixtures/objective/rainmaker-bravo-carry/frame.png new file mode 100644 index 000000000..584aa5d24 Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/rainmaker-bravo-carry/frame.png differ diff --git a/app/features/scanner/tests/fixtures/objective/rainmaker-dropped-neutral/expected.json b/app/features/scanner/tests/fixtures/objective/rainmaker-dropped-neutral/expected.json new file mode 100644 index 000000000..dfdc8d9a9 --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/rainmaker-dropped-neutral/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Objective", + "data": { + "mode": "RM", + "time": 263, + "score": [null, 72], + "control": null, + "position": -29 + } +} diff --git a/app/features/scanner/tests/fixtures/objective/rainmaker-dropped-neutral/frame.png b/app/features/scanner/tests/fixtures/objective/rainmaker-dropped-neutral/frame.png new file mode 100644 index 000000000..66986d0e5 Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/rainmaker-dropped-neutral/frame.png differ diff --git a/app/features/scanner/tests/fixtures/objective/rainmaker-german-dropped-neutral/expected.json b/app/features/scanner/tests/fixtures/objective/rainmaker-german-dropped-neutral/expected.json new file mode 100644 index 000000000..567a0c5c6 --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/rainmaker-german-dropped-neutral/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Objective", + "data": { + "mode": "RM", + "time": 197, + "score": [26, 60], + "control": null, + "position": -41 + } +} diff --git a/app/features/scanner/tests/fixtures/objective/rainmaker-german-dropped-neutral/frame.png b/app/features/scanner/tests/fixtures/objective/rainmaker-german-dropped-neutral/frame.png new file mode 120000 index 000000000..a60136bc3 --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/rainmaker-german-dropped-neutral/frame.png @@ -0,0 +1 @@ +../../death/german-booyah-bomb/frame.png \ No newline at end of file diff --git a/app/features/scanner/tests/fixtures/objective/rainmaker-neutral-near-goal/expected.json b/app/features/scanner/tests/fixtures/objective/rainmaker-neutral-near-goal/expected.json new file mode 100644 index 000000000..383385035 --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/rainmaker-neutral-near-goal/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Objective", + "data": { + "mode": "RM", + "time": 207, + "score": [1, 51], + "control": null, + "position": 89 + } +} diff --git a/app/features/scanner/tests/fixtures/objective/rainmaker-neutral-near-goal/frame.png b/app/features/scanner/tests/fixtures/objective/rainmaker-neutral-near-goal/frame.png new file mode 120000 index 000000000..f6f72630f --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/rainmaker-neutral-near-goal/frame.png @@ -0,0 +1 @@ +../../death/headphones/frame.png \ No newline at end of file diff --git a/app/features/scanner/tests/fixtures/objective/rainmaker-reset-pale-sky/expected.json b/app/features/scanner/tests/fixtures/objective/rainmaker-reset-pale-sky/expected.json new file mode 100644 index 000000000..feb80f357 --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/rainmaker-reset-pale-sky/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Objective", + "data": { + "mode": "RM", + "time": 233, + "score": [null, 72], + "control": null, + "position": 0 + } +} diff --git a/app/features/scanner/tests/fixtures/objective/rainmaker-reset-pale-sky/frame.png b/app/features/scanner/tests/fixtures/objective/rainmaker-reset-pale-sky/frame.png new file mode 100644 index 000000000..a06c34dc0 Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/rainmaker-reset-pale-sky/frame.png differ diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-bright-plate/expected.json b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-bright-plate/expected.json index 13f808c0b..2c7b1842d 100644 --- a/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-bright-plate/expected.json +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-bright-plate/expected.json @@ -5,7 +5,7 @@ "time": 277, "score": [97, 100], "penalty": [null, null], - "control": [true, false] + "control": 0 }, "options": { "notes": "Alpha (bright yellow-green ink) in control: their plate keeps the bright team-color fill (POV frame shows the We're in control! banner). Bravo still at the three-digit 100." diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-double-penalty/expected.json b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-double-penalty/expected.json index 34628f9e3..7c5fddd0c 100644 --- a/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-double-penalty/expected.json +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-double-penalty/expected.json @@ -5,7 +5,7 @@ "time": 157, "score": [2, 15], "penalty": [64, 65], - "control": [true, false] + "control": 0 }, "options": { "notes": "Penalty pills under both plates at once; alpha (bright green fill) in control." diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-penalty/expected.json b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-penalty/expected.json index 8c517bef6..feeed9192 100644 --- a/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-penalty/expected.json +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-penalty/expected.json @@ -5,7 +5,7 @@ "time": 209, "score": [2, 84], "penalty": [75, null], - "control": [false, true] + "control": 1 }, "options": { "notes": "Single-digit count with a large penalty on alpha; bravo (purple) in control with the team-color fill." diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-start/expected.json b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-start/expected.json index 28dba9670..748c6fdb9 100644 --- a/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-start/expected.json +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-barnacle-dime-start/expected.json @@ -5,7 +5,7 @@ "time": 299, "score": [100, 100], "penalty": [null, null], - "control": [false, false] + "control": null }, "options": { "notes": "Match start: zone neutral, both plates near-black with three-digit 100s (green/pink digits)." diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-left/expected.json b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-left/expected.json index 2840c881a..3d1b04816 100644 --- a/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-left/expected.json +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-left/expected.json @@ -5,7 +5,7 @@ "time": 262, "score": [50, 100], "penalty": [null, null], - "control": [true, false] + "control": 0 }, "options": { "notes": "Casted stream (Splat World Series 2026 playoffs) mid-fight: lime team in control, white '50' on the bright lime fill blurred by stream compression — the leading '5' scored under the main digit floor and the read truncated to 0 before the extension floor existed." diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-right/expected.json b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-right/expected.json index 0c719c6a7..fa883085f 100644 --- a/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-right/expected.json +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-lime-control-blur-right/expected.json @@ -5,7 +5,7 @@ "time": 249, "score": [100, 29], "penalty": [null, null], - "control": [false, true] + "control": 1 }, "options": { "notes": "Same cast game seconds later after a camera swap put the lime team's plate on the right: white '29' on the bright lime fill, leading '2' blur-eroded under the main digit floor (was truncated to 9). Gray-band noise on this frame matches digit templates at up to 0.66 — the anchor-digit rule must keep rejecting it." diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-cast-overhead-purple-right/expected.json b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-overhead-purple-right/expected.json index 5bfc07aac..66f42f16c 100644 --- a/app/features/scanner/tests/fixtures/objective/splat-zones-cast-overhead-purple-right/expected.json +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-overhead-purple-right/expected.json @@ -5,7 +5,7 @@ "time": 243, "score": [49, 98], "penalty": [39, null], - "control": [false, true] + "control": 1 }, "options": { "notes": "Casted stream (SWS26) overhead camera of the same game as the specced-purple-left fixture: default arrangement puts green left (black plate, green digits, +39 penalty pill) and purple right (in control, team-color fill)." diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-cast-penalty-pill-under-nameplate-badge/expected.json b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-penalty-pill-under-nameplate-badge/expected.json index 7eae1e99b..261344740 100644 --- a/app/features/scanner/tests/fixtures/objective/splat-zones-cast-penalty-pill-under-nameplate-badge/expected.json +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-penalty-pill-under-nameplate-badge/expected.json @@ -5,7 +5,7 @@ "time": 173, "score": [35, 30], "penalty": [17, 54], - "control": [false, false] + "control": null }, "options": { "notes": "Casted stream (Splat World Series 2026 playoffs, Day 1 Swiss, King of Ham vs FTWIN on Eeltail Alley) with penalty pills under both neutral plates. A spectated player's in-world nameplate (biscuit, X camera badge) sits right under the blue plate, and its white round badge overlaps the left rounded end of the '+54' pill — the pill-presence probe there reads bright, so the whole right penalty came back null while the digits themselves are untouched." diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-cast-specced-purple-left/expected.json b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-specced-purple-left/expected.json index 6669d007b..043f2209b 100644 --- a/app/features/scanner/tests/fixtures/objective/splat-zones-cast-specced-purple-left/expected.json +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-specced-purple-left/expected.json @@ -5,7 +5,7 @@ "time": 248, "score": [100, 49], "penalty": [null, null], - "control": [false, false] + "control": null }, "options": { "notes": "Casted stream (SWS26) from a purple-team player's POV: the specced team's plate sits left, so purple (magenta digits) is side 0 and green side 1 — opposite arrangement to the overhead frame from the same game. Zone neutral: both plates near-black." diff --git a/app/features/scanner/tests/fixtures/objective/splat-zones-mako-mart/expected.json b/app/features/scanner/tests/fixtures/objective/splat-zones-mako-mart/expected.json index 2edfe054c..15a01e463 100644 --- a/app/features/scanner/tests/fixtures/objective/splat-zones-mako-mart/expected.json +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-mako-mart/expected.json @@ -5,6 +5,6 @@ "time": 215, "score": [95, 53], "penalty": [4, null], - "control": [false, true] + "control": 1 } } diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-alpha-near-goal/expected.json b/app/features/scanner/tests/fixtures/objective/tower-control-alpha-near-goal/expected.json new file mode 100644 index 000000000..7cc6cff76 --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/tower-control-alpha-near-goal/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Objective", + "data": { + "mode": "TC", + "time": 161, + "score": [2, 27], + "control": 0, + "position": 97 + } +} diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-alpha-near-goal/frame.png b/app/features/scanner/tests/fixtures/objective/tower-control-alpha-near-goal/frame.png new file mode 100644 index 000000000..c310ebc75 Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/tower-control-alpha-near-goal/frame.png differ diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-alpha-push/expected.json b/app/features/scanner/tests/fixtures/objective/tower-control-alpha-push/expected.json new file mode 100644 index 000000000..f48920d9b --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/tower-control-alpha-push/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Objective", + "data": { + "mode": "TC", + "time": 173, + "score": [32, 27], + "control": 0, + "position": 65 + } +} diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-alpha-push/frame.png b/app/features/scanner/tests/fixtures/objective/tower-control-alpha-push/frame.png new file mode 100644 index 000000000..4b1d196f9 Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/tower-control-alpha-push/frame.png differ diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-alpha-ride-from-center/expected.json b/app/features/scanner/tests/fixtures/objective/tower-control-alpha-ride-from-center/expected.json new file mode 100644 index 000000000..4c36362d7 --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/tower-control-alpha-ride-from-center/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Objective", + "data": { + "mode": "TC", + "time": 127, + "score": [1, 27], + "control": 0, + "position": 0 + } +} diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-alpha-ride-from-center/frame.png b/app/features/scanner/tests/fixtures/objective/tower-control-alpha-ride-from-center/frame.png new file mode 100644 index 000000000..0585f36e2 Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/tower-control-alpha-ride-from-center/frame.png differ diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-bravo-deep-push-sky/expected.json b/app/features/scanner/tests/fixtures/objective/tower-control-bravo-deep-push-sky/expected.json new file mode 100644 index 000000000..4ad271d6a --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/tower-control-bravo-deep-push-sky/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Objective", + "data": { + "mode": "TC", + "time": 217, + "score": [92, 27], + "control": 1, + "position": -72 + } +} diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-bravo-deep-push-sky/frame.png b/app/features/scanner/tests/fixtures/objective/tower-control-bravo-deep-push-sky/frame.png new file mode 100644 index 000000000..e387122a2 Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/tower-control-bravo-deep-push-sky/frame.png differ diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-lime-bravo-carry/expected.json b/app/features/scanner/tests/fixtures/objective/tower-control-lime-bravo-carry/expected.json new file mode 100644 index 000000000..993a255bf --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/tower-control-lime-bravo-carry/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Objective", + "data": { + "mode": "TC", + "time": 172, + "score": [95, 34], + "control": 1, + "position": -7 + } +} diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-lime-bravo-carry/frame.jpg b/app/features/scanner/tests/fixtures/objective/tower-control-lime-bravo-carry/frame.jpg new file mode 120000 index 000000000..0d03b1c3e --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/tower-control-lime-bravo-carry/frame.jpg @@ -0,0 +1 @@ +../../death/splattershot-tezuka/frame.jpg \ No newline at end of file diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-lime-bravo-deep-push/expected.json b/app/features/scanner/tests/fixtures/objective/tower-control-lime-bravo-deep-push/expected.json new file mode 100644 index 000000000..cb69f3b0b --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/tower-control-lime-bravo-deep-push/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Objective", + "data": { + "mode": "TC", + "time": 155, + "score": [95, 29], + "control": 1, + "position": -74 + } +} diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-lime-bravo-deep-push/frame.jpg b/app/features/scanner/tests/fixtures/objective/tower-control-lime-bravo-deep-push/frame.jpg new file mode 120000 index 000000000..e3fa7a48d --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/tower-control-lime-bravo-deep-push/frame.jpg @@ -0,0 +1 @@ +../../death/rapid-blaster-deco-heavy-kez/frame.jpg \ No newline at end of file diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-neutral-start/expected.json b/app/features/scanner/tests/fixtures/objective/tower-control-neutral-start/expected.json new file mode 100644 index 000000000..4c5d5f638 --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/tower-control-neutral-start/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Objective", + "data": { + "mode": "TC", + "time": 295, + "score": [null, null], + "control": null, + "position": 0 + } +} diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-neutral-start/frame.png b/app/features/scanner/tests/fixtures/objective/tower-control-neutral-start/frame.png new file mode 100644 index 000000000..8ce29e67e Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/tower-control-neutral-start/frame.png differ diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-neutral-yellow-lobby/expected.json b/app/features/scanner/tests/fixtures/objective/tower-control-neutral-yellow-lobby/expected.json new file mode 100644 index 000000000..2d1b9d442 --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/tower-control-neutral-yellow-lobby/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Objective", + "data": { + "mode": "TC", + "time": 284, + "score": [null, null], + "control": null, + "position": 0 + } +} diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-neutral-yellow-lobby/frame.png b/app/features/scanner/tests/fixtures/objective/tower-control-neutral-yellow-lobby/frame.png new file mode 120000 index 000000000..b2088305f --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/tower-control-neutral-yellow-lobby/frame.png @@ -0,0 +1 @@ +../../death/wipeout-52-gal/frame.png \ No newline at end of file diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-plates-side-by-side/expected.json b/app/features/scanner/tests/fixtures/objective/tower-control-plates-side-by-side/expected.json new file mode 100644 index 000000000..b726c41e6 --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/tower-control-plates-side-by-side/expected.json @@ -0,0 +1,10 @@ +{ + "event": "Objective", + "data": { + "mode": "TC", + "time": 247, + "score": [92, 79], + "control": 1, + "position": -15 + } +} diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-plates-side-by-side/frame.png b/app/features/scanner/tests/fixtures/objective/tower-control-plates-side-by-side/frame.png new file mode 100644 index 000000000..afe4d7f65 Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/tower-control-plates-side-by-side/frame.png differ diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-purple-bravo-deep-push/expected.json b/app/features/scanner/tests/fixtures/objective/tower-control-purple-bravo-deep-push/expected.json new file mode 100644 index 000000000..21e075500 --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/tower-control-purple-bravo-deep-push/expected.json @@ -0,0 +1,14 @@ +{ + "event": "Objective", + "data": { + "mode": "TC", + "time": 226, + "score": [null, 24], + "control": 1, + "position": -77 + }, + "options": { + "skipFields": ["mode"], + "notes": "Blurry capture: the checkpoint squares read 0.50 on the square probe, so the read reports no mode and the match's mode decides." + } +} diff --git a/app/features/scanner/tests/fixtures/objective/tower-control-purple-bravo-deep-push/frame.png b/app/features/scanner/tests/fixtures/objective/tower-control-purple-bravo-deep-push/frame.png new file mode 120000 index 000000000..b189d6998 --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/tower-control-purple-bravo-deep-push/frame.png @@ -0,0 +1 @@ +../../death/z-f-splatterscope-vod/frame.png \ No newline at end of file diff --git a/app/features/scanner/tests/logic/game-timeline-view.test.ts b/app/features/scanner/tests/logic/game-timeline-view.test.ts index 907ba3acf..84533238b 100644 --- a/app/features/scanner/tests/logic/game-timeline-view.test.ts +++ b/app/features/scanner/tests/logic/game-timeline-view.test.ts @@ -31,7 +31,7 @@ const MATCH: ScannerMatch = { time: 290, score: [90, 100], penalty: [null, 5], - control: [true, false], + control: 0, }, ], }, @@ -65,6 +65,35 @@ const withKills = ( ): ScannerMatch => ({ ...MATCH, kills, teams }); describe("gameTimelineProps", () => { + test("flips a track position along with the sides", () => { + const props = gameTimelineProps( + { + ...MATCH, + objective: { + mode: "TC", + samples: [ + { + t: 110, + time: 290, + score: [60, null], + penalty: [null, null], + control: 0, + position: 40, + }, + ], + }, + }, + 100, + LABELS, + ); + + expect(props.objectiveEvents![0]!.data).toMatchObject({ + score: [null, 60], + control: 1, + position: -40, + }); + }); + test("puts the POV's team first", () => { const props = gameTimelineProps(MATCH, 100, LABELS); @@ -74,7 +103,7 @@ describe("gameTimelineProps", () => { time: 290, score: [100, 90], penalty: [5, null], - control: [false, true], + control: 1, }, }); expect(props.pov?.side).toBe(0); diff --git a/app/features/scanner/tests/logic/match-builder.test.ts b/app/features/scanner/tests/logic/match-builder.test.ts index ef5051ef7..c6e703e7a 100644 --- a/app/features/scanner/tests/logic/match-builder.test.ts +++ b/app/features/scanner/tests/logic/match-builder.test.ts @@ -12,7 +12,10 @@ import type { MinimapEnemy, MinimapTeammate, } from "../../core/detectors/minimap/index"; -import type { ObjectiveData } from "../../core/detectors/objective/index"; +import type { + ObjectiveData, + TrackObjectiveData, +} from "../../core/detectors/objective/index"; import type { PlayerStatusData } from "../../core/detectors/objective/player-status"; import type { StripWeaponsData } from "../../core/detectors/objective/strip-weapons"; import type { ScoreboardData } from "../../core/detectors/scoreboard/index"; @@ -62,7 +65,7 @@ function objective( 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], + control = 0 as ObjectiveData["control"], teamColor = [null, null] as ObjectiveData["teamColor"], } = {}, ): DetectedEvent { @@ -77,6 +80,28 @@ function objective( return { type: "Objective", t, confidence: 0.9, data }; } +function trackObjective( + t: number, + { + mode = "TC" as TrackObjectiveData["mode"], + time = (300 - Math.round(t)) as number | null, + score = [80, 100] as [number | null, number | null], + control = 0 as TrackObjectiveData["control"], + position = 30 as number | null, + teamColor = [null, null] as ObjectiveData["teamColor"], + } = {}, +): DetectedEvent { + const data: TrackObjectiveData = { + mode, + time, + score, + control, + position, + teamColor, + }; + return { type: "Objective", t, confidence: 0.9, data }; +} + function scoreboard( t: number, { @@ -260,14 +285,14 @@ test("objective reads become teams-order samples on the match", () => { time: 215, score: [95, 53], penalty: [4, null], - control: [true, false], + control: 0, }, { t: 180, time: 155, score: [80, 53], penalty: [null, null], - control: [true, false], + control: 0, }, ], }); @@ -283,10 +308,133 @@ test("a losing-side pov swaps objective samples into teams order", () => { time: 180, score: [53, 95], penalty: [null, 4], - control: [false, true], + control: 1, }); }); +test("track reads become samples with the objective's position", () => { + const built = buildScannerMatches([ + mapStart(0, { mode: "RM" }), + trackObjective(120, { control: null, position: 0, score: [null, null] }), + trackObjective(130, { control: 1, position: -40, score: [null, 60] }), + scoreboard(300, { mode: "RM" }), + ]); + assert.deepEqual(built[0]!.match.objective, { + mode: "RM", + samples: [ + { + t: 120, + time: 180, + score: [null, null], + penalty: [null, null], + control: null, + position: 0, + }, + { + t: 130, + time: 170, + score: [null, 60], + penalty: [null, null], + control: 1, + position: -40, + }, + ], + }); +}); + +test("a losing-side pov flips the track position with the sides", () => { + const built = buildScannerMatches([ + mapStart(0, { mode: "TC" }), + trackObjective(120, { control: 0, position: 30, score: [80, 100] }), + scoreboard(300, { mode: "TC", povIndex: 6 }), + ]); + assert.deepEqual(built[0]!.match.objective!.samples[0], { + t: 120, + time: 180, + score: [100, 80], + penalty: [null, null], + control: 1, + position: -30, + }); +}); + +test("casted track swaps flip the position with the sides", () => { + const built = buildScannerMatches([ + minimap(0, { teamColors: [GREEN_INK, PURPLE_INK] }), + trackObjective(60, { + control: 0, + position: 20, + teamColor: [GREEN_INK, PURPLE_INK], + }), + // the caster specs a purple player: purple's side moves left + trackObjective(61, { + control: 1, + position: -25, + teamColor: [PURPLE_INK, GREEN_INK], + }), + minimap(90, { teamColors: [GREEN_INK, PURPLE_INK] }), + ]); + assert.deepEqual( + built[0]!.match.objective!.samples.map((sample) => [ + sample.control, + sample.position, + ]), + [ + [0, 20], + [0, 25], + ], + ); +}); + +test("an unknown-mode track match takes its mode from the checkpoint markers", () => { + const built = buildScannerMatches([ + mapStart(0, { mode: null }), + trackObjective(60, { mode: "RM" }), + trackObjective(61, { mode: null, position: 31 }), + trackObjective(62, { mode: "RM", position: 32 }), + scoreboard(300, { mode: null }), + ]); + assert.equal(built[0]!.match.objective!.mode, "RM"); +}); + +test("a known TC/RM match drops SZ lookalike reads, and an SZ match track ones", () => { + const track = [ + mapStart(0, { mode: "TC" }), + objective(60), + trackObjective(61), + scoreboard(300, { mode: "TC" }), + ]; + const trackBuilt = buildScannerMatches(track); + assert.equal(trackBuilt[0]!.match.objective!.samples[0]!.t, 61); + assert.deepEqual(invalidObjectiveEvents(trackBuilt), [track[1]]); + + const zones = [ + mapStart(0), + objective(60), + trackObjective(61), + scoreboard(300), + ]; + const zonesBuilt = buildScannerMatches(zones); + assert.equal(zonesBuilt[0]!.match.objective!.samples[0]!.t, 60); + assert.deepEqual(invalidObjectiveEvents(zonesBuilt), [zones[2]]); +}); + +test("an unknown-mode match builds from its majority overlay and deletes nothing", () => { + const events = [ + mapStart(0, { mode: null }), + objective(60), + trackObjective(61), + trackObjective(62, { position: 35 }), + scoreboard(300, { mode: null }), + ]; + const built = buildScannerMatches(events); + assert.deepEqual( + built[0]!.match.objective!.samples.map((sample) => sample.t), + [61, 62], + ); + assert.deepEqual(invalidObjectiveEvents(built), []); +}); + test("a known non-SZ match drops its objective reads", () => { const events = [ mapStart(0, { mode: "CB" }), @@ -317,20 +465,20 @@ test("casted plate swaps are reoriented by team ink color", () => { minimap(0, { teamColors: [GREEN_INK, PURPLE_INK] }), objective(60, { score: [80, 90], - control: [true, false], + control: 0, teamColor: [GREEN_INK, PURPLE_INK], }), // the caster specs a purple player: purple's plate moves left objective(120, { score: [90, 75], penalty: [4, null], - control: [true, false], + control: 0, teamColor: [PURPLE_INK, GREEN_INK], }), // colors unreadable: the previous arrangement carries over objective(125, { score: [85, 75], - control: [true, false], + control: 0, teamColor: [null, null], }), minimap(180), @@ -355,11 +503,7 @@ test("casted plate swaps are reoriented by team ink color", () => { ); assert.deepEqual( samples.map((sample) => sample.control), - [ - [true, false], - [false, true], - [false, true], - ], + [0, 1, 1], ); }); @@ -369,14 +513,14 @@ test("minimap ink colors anchor a bravo-first cluster into teams order", () => { // every read had purple (bravo) on the left plate objective(60, { score: [90, 80], - control: [false, true], + control: 1, teamColor: [PURPLE_INK, GREEN_INK], }), minimap(120), ]); const samples = built[0]!.match.objective!.samples; assert.deepEqual(samples[0]!.score, [80, 90]); - assert.deepEqual(samples[0]!.control, [true, false]); + assert.equal(samples[0]!.control, 0); }); test("without a pov the side whose count got lower is the winner side", () => { diff --git a/app/features/scanner/tests/objective.test.ts b/app/features/scanner/tests/objective.test.ts index aad337823..05b337b04 100644 --- a/app/features/scanner/tests/objective.test.ts +++ b/app/features/scanner/tests/objective.test.ts @@ -1,10 +1,12 @@ /** - * Golden-file tests for the ObjectiveDetector over every fixture in objective/, - * plus cross-negative sweeps both ways: the objective gate must stay quiet on - * every other detector's positives (and the shared negatives), and vice versa. + * Golden-file tests for the ObjectiveDetector over every fixture in objective/ + * (SZ plates and the TC/RM track), plus cross-negative sweeps both ways: the + * objective gate must stay quiet on every other detector's positives (and the + * shared negatives), and vice versa. */ import assert from "node:assert/strict"; +import { realpathSync } from "node:fs"; import { loadOpenCV } from "../core/cv"; import { createDeathDetector } from "../core/detectors/death/index"; import { createMapStartDetector } from "../core/detectors/map-start/index"; @@ -31,6 +33,8 @@ await loadOpenCV(); const resources = await loadScoreboardResources(); const detector = createObjectiveDetector(resources); const fixtures = loadFixtures("objective"); +/** A track position is the icon's x in ~4.5px units: a pixel of jitter can round either way. */ +const POSITION_TOLERANCE = 1; test("objective fixtures exist", () => { assert.ok(fixtures.length > 0, "no fixtures found under objective/"); @@ -108,27 +112,42 @@ for (const fixture of fixtures) { }, () => { assert.equal( - event.data.penalty[side], + event.data.mode === "SZ" ? event.data.penalty[side] : null, expected.penalty![side], `penalty[${side}] mismatch (${debug()})`, ); }, ); - await t.test( - `control[${side}]`, - { - skip: - expected.control === undefined || skip(fixture, `control.${side}`), - }, - () => { - assert.equal( - event.data.control[side], - expected.control![side], - `control[${side}] mismatch (${debug()})`, - ); - }, - ); } + + await t.test( + "control", + { skip: expected.control === undefined || skip(fixture, "control") }, + () => { + assert.equal( + event.data.control, + expected.control, + `control mismatch (${debug()})`, + ); + }, + ); + + await t.test( + "position", + { skip: expected.position === undefined || skip(fixture, "position") }, + () => { + const position = + event.data.mode === "SZ" ? undefined : event.data.position; + if (expected.position === null || position == null) { + assert.equal(position, expected.position, `(${debug()})`); + return; + } + assert.ok( + Math.abs(position - expected.position!) <= POSITION_TOLERANCE, + `position ${position}, expected ${expected.position} (${debug()})`, + ); + }, + ); }); } @@ -206,6 +225,14 @@ for (const fixture of loadFixtures("negative")) { }); } +// a TC/RM frame shared with another detector's fixture (symlinked, e.g. a +// death overlay over the track) legitimately fires that detector's gate +const sharedFrames = new Map( + ["death", "kill"].map((dir) => [ + dir, + new Set(loadFixtures(dir).map((f) => realpathSync(f.framePath))), + ]), +); const otherDetectors: readonly [string, Detector][] = [ ["scoreboard", createScoreboardDetector(resources) as Detector], [ @@ -225,6 +252,9 @@ for (const fixture of fixtures.filter( )) { test(`other gates stay quiet on objective/${fixture.name}`, async () => { for (const [name, other] of otherDetectors) { + if (sharedFrames.get(name)?.has(realpathSync(fixture.framePath))) { + continue; + } const { gate } = await runDetectorOnFixture(other, fixture); assert.equal( gate.pass, diff --git a/locales/da/common.json b/locales/da/common.json index f8f4e4d83..286ed3ba6 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -217,6 +217,7 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "objectiveTimeline.pushed": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", "playerStatusTimeline.kill": "", diff --git a/locales/de/common.json b/locales/de/common.json index a4e7ef04e..9f9df8c14 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -217,6 +217,7 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "objectiveTimeline.pushed": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", "playerStatusTimeline.kill": "", diff --git a/locales/en/common.json b/locales/en/common.json index 506a8fa21..6fe4fc464 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -217,6 +217,7 @@ "objectiveTimeline.timeLeft": "{{time}} left", "objectiveTimeline.penalty": "+{{value}} penalty", "objectiveTimeline.inControl": "In control", + "objectiveTimeline.pushed": "Pushed {{value}}%", "playerStatusTimeline.splatted": "Splatted", "playerStatusTimeline.specialReady": "Special ready", "playerStatusTimeline.kill": "Kill", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 30f8ca9ad..161a4bbb0 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -217,6 +217,7 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "objectiveTimeline.pushed": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", "playerStatusTimeline.kill": "", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index 6d66220ce..0a105f5ef 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -217,6 +217,7 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "objectiveTimeline.pushed": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", "playerStatusTimeline.kill": "", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 1045b7040..957e9bb4f 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -217,6 +217,7 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "objectiveTimeline.pushed": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", "playerStatusTimeline.kill": "", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index a516264d4..8c5fbb0db 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -217,6 +217,7 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "objectiveTimeline.pushed": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", "playerStatusTimeline.kill": "", diff --git a/locales/he/common.json b/locales/he/common.json index f426a488c..958183a77 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -217,6 +217,7 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "objectiveTimeline.pushed": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", "playerStatusTimeline.kill": "", diff --git a/locales/it/common.json b/locales/it/common.json index 8419440ac..76a30f7d0 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -217,6 +217,7 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "objectiveTimeline.pushed": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", "playerStatusTimeline.kill": "", diff --git a/locales/ja/common.json b/locales/ja/common.json index c74f91955..26cd2e1c6 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -217,6 +217,7 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "objectiveTimeline.pushed": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", "playerStatusTimeline.kill": "", diff --git a/locales/ko/common.json b/locales/ko/common.json index 89171653c..646464bb2 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -217,6 +217,7 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "objectiveTimeline.pushed": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", "playerStatusTimeline.kill": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index 8a61ac994..a0885c622 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -217,6 +217,7 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "objectiveTimeline.pushed": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", "playerStatusTimeline.kill": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index 147ead147..6818e489a 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -217,6 +217,7 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "objectiveTimeline.pushed": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", "playerStatusTimeline.kill": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 71918cee0..f98bd8de3 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -217,6 +217,7 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "objectiveTimeline.pushed": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", "playerStatusTimeline.kill": "", diff --git a/locales/ru/common.json b/locales/ru/common.json index 7c7abd2ae..8441009f8 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -217,6 +217,7 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "objectiveTimeline.pushed": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", "playerStatusTimeline.kill": "", diff --git a/locales/zh/common.json b/locales/zh/common.json index 9d5890b3f..7ea09a6a8 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -217,6 +217,7 @@ "objectiveTimeline.timeLeft": "", "objectiveTimeline.penalty": "", "objectiveTimeline.inControl": "", + "objectiveTimeline.pushed": "", "playerStatusTimeline.splatted": "", "playerStatusTimeline.specialReady": "", "playerStatusTimeline.kill": "", diff --git a/scripts/scanner/status-audit.ts b/scripts/scanner/status-audit.ts index 1156361d7..4bb85cff1 100644 --- a/scripts/scanner/status-audit.ts +++ b/scripts/scanner/status-audit.ts @@ -227,7 +227,14 @@ function eventFromRow(type: string, row: string[]): DetectedEvent | null { return { type, ...base, data: parsePlayerStatusCell(players) }; } if (type === OBJECTIVE_EVENT_TYPE) { - return { type, ...base, data: parseObjectiveCell(players) }; + return { + type, + ...base, + data: parseObjectiveCell( + players, + MODE_BY_LABEL.get(row[columns.mode] ?? "") ?? null, + ), + }; } if (type === STRIP_WEAPONS_EVENT_TYPE) { return { type, ...base, data: parseStripWeaponsCell(players) }; @@ -284,20 +291,37 @@ function parsePlayerStatusCell(cell: string): PlayerStatusData { }; } -function parseObjectiveCell(cell: string): ObjectiveData { +/** SZ cells carry penalties, TC/RM cells the track position (` @ N`). */ +function parseObjectiveCell( + cell: string, + mode: ModeShort | null, +): ObjectiveData { const match = cell.match( - /^(?:(\d+):(\d{2}) · )?(\d+|\?)(?: \(\+(\d+)\))?( ctrl)? vs (\d+|\?)(?: \(\+(\d+)\))?( ctrl)?$/u, + /^(?:(\d+):(\d{2}) · )?(\d+|\?)(?: \(\+(\d+)\))?( ctrl)? vs (\d+|\?)(?: \(\+(\d+)\))?( ctrl)?(?: @ (-?\d+))?$/u, ); if (!match) throw new Error(`bad Objective cell: ${cell}`); - const [, m, s, scoreA, penA, ctrlA, scoreB, penB, ctrlB] = match; + const [, m, s, scoreA, penA, ctrlA, scoreB, penB, ctrlB, position] = match; const num = (v: string | undefined) => v === undefined || v === "?" ? null : Number(v); + if ( + mode !== "SZ" && + (position !== undefined || mode === "TC" || mode === "RM") + ) { + return { + mode: mode === "TC" || mode === "RM" ? mode : null, + time: m === undefined ? null : parseClock(m, s!), + score: [num(scoreA), num(scoreB)], + control: ctrlA ? 0 : ctrlB ? 1 : null, + position: num(position), + teamColor: [null, null], + }; + } return { mode: "SZ", time: m === undefined ? null : parseClock(m, s!), score: [num(scoreA), num(scoreB)], penalty: [num(penA), num(penB)], - control: [ctrlA !== undefined, ctrlB !== undefined], + control: ctrlA ? 0 : ctrlB ? 1 : null, teamColor: [null, null], }; }