diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index 6c9b21810..65578a0b1 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -84,7 +84,12 @@ sequenceDiagram + casted 8-player spectator variant), `objective` (ranked counter overlay: counts, penalties, holder, match timer — a mode-discriminated union with only the SZ member so far). Objective reads land on `ScannerMatch` as - progress samples anchored to the game clock. Reads grouping into a match + progress samples anchored to the game clock. Each read also carries a + per-side team ink color (`core/ink-color.ts` — the plate fill in + 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 diff --git a/app/features/scanner/components/LivePage.tsx b/app/features/scanner/components/LivePage.tsx index 6469744b3..1af822abe 100644 --- a/app/features/scanner/components/LivePage.tsx +++ b/app/features/scanner/components/LivePage.tsx @@ -16,10 +16,7 @@ import { type MapStartData, } from "../core/detectors/map-start/index"; import { MINIMAP_EVENT_TYPE } from "../core/detectors/minimap/index"; -import { - OBJECTIVE_EVENT_TYPE, - type ObjectiveData, -} from "../core/detectors/objective/index"; +import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index"; import { SCOREBOARD_EVENT_TYPES } from "../core/detectors/registry"; import type { DetectedEvent, GateResult } from "../core/detectors/types"; import type { BuiltMatch } from "../core/match-builder"; @@ -403,13 +400,13 @@ export function LivePage({ renderMatch={(built, justFormed) => { const id = built.sources[0]!.id!; const skipReason = skipReasons.get(built); - // counter reads render as one timeline chart, not a card each; - // a non-SZ match's reads (objective null) are never shown - const objectiveEvents = built.match.objective - ? built.sources - .filter((e) => e.type === OBJECTIVE_EVENT_TYPE) - .map((e) => ({ t: e.t, data: e.data as ObjectiveData })) - : []; + // counter reads render as one timeline chart, not a card each -- + // from the builder's samples, whose sides are team-stable (raw + // reads follow the specced player on casts); a non-SZ match's + // reads (objective null) are never shown + const objectiveEvents = ( + built.match.objective?.samples ?? [] + ).map((sample) => ({ t: sample.t, data: sample })); const cardEvents = withoutRepeatEvents(built.sources).filter( (e) => e.type !== OBJECTIVE_EVENT_TYPE, ); diff --git a/app/features/scanner/components/VodPage.tsx b/app/features/scanner/components/VodPage.tsx index 3172c753d..76aebf6b4 100644 --- a/app/features/scanner/components/VodPage.tsx +++ b/app/features/scanner/components/VodPage.tsx @@ -24,10 +24,7 @@ import { FormWithConfirm } from "~/components/FormWithConfirm"; import { ObjectiveTimeline } from "~/components/ObjectiveTimeline"; import { openSeekScan, probeWebCodecs } from "../capture/vod-frames"; import { connectAbilities } from "../core/ability-harvest"; -import { - OBJECTIVE_EVENT_TYPE, - type ObjectiveData, -} from "../core/detectors/objective/index"; +import { OBJECTIVE_EVENT_TYPE } from "../core/detectors/objective/index"; import { mergeScanTelemetry, type ScanTelemetry, @@ -719,13 +716,13 @@ export function VodPage({ ingestableBuilt.indexOf(built), ); const send = skipReason ? undefined : bulkSend; - // counter reads render as one timeline chart, not a card each; - // a non-SZ match's reads (objective null) are never shown - const objectiveEvents = built.match.objective - ? built.sources - .filter((e) => e.type === OBJECTIVE_EVENT_TYPE) - .map((e) => ({ t: e.t, data: e.data as ObjectiveData })) - : []; + // counter reads render as one timeline chart, not a card each -- + // from the builder's samples, whose sides are team-stable (raw + // reads follow the specced player on casts); a non-SZ match's + // reads (objective null) are never shown + const objectiveEvents = ( + built.match.objective?.samples ?? [] + ).map((sample) => ({ t: sample.t, data: sample })); const cardEvents = withoutRepeatEvents(built.sources).filter( (e) => e.type !== OBJECTIVE_EVENT_TYPE, ); diff --git a/app/features/scanner/core/detectors/minimap/index.ts b/app/features/scanner/core/detectors/minimap/index.ts index 9b2ab6649..343d846f6 100644 --- a/app/features/scanner/core/detectors/minimap/index.ts +++ b/app/features/scanner/core/detectors/minimap/index.ts @@ -37,6 +37,7 @@ import { meanBrightness, type Roi, } from "../../image"; +import { type InkRgb, meanInkColor } from "../../ink-color"; import type { ScoreboardResources } from "../scoreboard/index"; import { type ParsedName, parseName } from "../scoreboard/names"; import { @@ -127,6 +128,13 @@ export interface MinimapData { teammates: MinimapTeammate[]; /** enemy panel rows, top to bottom */ enemies: MinimapEnemy[]; + /** + * mean team-ink RGB per side sampled from the sub-weapon tiles + * ([teammates/alpha, enemies/bravo]); null when too little saturated + * ink. Anchors the objective counter's color-tracked sides to `teams` + * order on casted footage, which never shows a results screen. + */ + teamColors: [InkRgb | null, InkRgb | null]; } export const MINIMAP_EVENT_TYPE = "Minimap"; @@ -324,6 +332,7 @@ export function createMinimapDetector( const teammates: MinimapTeammate[] = []; const enemies: MinimapEnemy[] = []; + const sideSubTiles: [Roi[], Roi[]] = [[], []]; const cardDebug: Record[] = []; for (const dx of [0, SPECTATOR_ENEMY_DX]) { for (let row = 0; row < 4; row++) { @@ -333,6 +342,7 @@ export function createMinimapDetector( cardDebug.push({ dx, row, presence, skipped: true }); continue; } + sideSubTiles[dx === 0 ? 0 : 1].push(layout.subTile); const crossFraction = saturatedFraction(hsv, layout.cross); const occluded = crossFraction >= CROSS_MIN_FRACTION; const cornerMin = minTopCornerMean(gray, layout.weapon); @@ -407,6 +417,11 @@ export function createMinimapDetector( } debug.cards = cardDebug; + const teamColors: [InkRgb | null, InkRgb | null] = [ + meanInkColor(rgb, sideSubTiles[0]), + meanInkColor(rgb, sideSubTiles[1]), + ]; + const stageMatch = detectStage(frame, confidences); debug.stage = stageMatch; @@ -429,6 +444,7 @@ export function createMinimapDetector( spectator: true, teammates, enemies, + teamColors, }, debug, }, @@ -463,6 +479,7 @@ export function createMinimapDetector( // 1. own-team callout cards const teammates: MinimapTeammate[] = []; + const sideSubTiles: [Roi[], Roi[]] = [[], []]; const cardDebug: Record[] = []; for (const layout of CARD_LAYOUTS) { // presence: the card is crisp UI, absent slots show blurred scene @@ -540,6 +557,7 @@ export function createMinimapDetector( matched !== null || abilities.some((a) => a !== null); if (!hasEvidence) continue; + sideSubTiles[0].push(layout.subTile); teammates.push({ slot: layout.slot, name, @@ -604,6 +622,7 @@ export function createMinimapDetector( ? SPECIAL_READY_WEAPON_MIN_SCORE : WEAPON_MIN_SCORE; const matched = weapon !== null && weapon.score >= floor ? weapon : null; + sideSubTiles[1].push(enemySubTileRoi(cy)); enemies.push({ name: null, weaponId: matched ? toMainWeaponId(matched.id) : null, @@ -612,6 +631,11 @@ export function createMinimapDetector( } debug.enemies = enemyDebug; + const teamColors: [InkRgb | null, InkRgb | null] = [ + meanInkColor(rgb, sideSubTiles[0]), + meanInkColor(rgb, sideSubTiles[1]), + ]; + const stageMatch = detectStage(frame, confidences); debug.stage = stageMatch; @@ -635,6 +659,7 @@ export function createMinimapDetector( spectator: false, teammates, enemies, + teamColors, }, debug, }, diff --git a/app/features/scanner/core/detectors/objective/index.ts b/app/features/scanner/core/detectors/objective/index.ts index e4cd13ef2..5a131efe3 100644 --- a/app/features/scanner/core/detectors/objective/index.ts +++ b/app/features/scanner/core/detectors/objective/index.ts @@ -31,6 +31,7 @@ import { minChannel, type Roi, } from "../../image"; +import { type InkRgb, meanInkColor } from "../../ink-color"; import { type BannerScoreRead, isBetterRead, @@ -78,6 +79,14 @@ export interface SplatZonesObjectiveData { penalty: [number | null, number | null]; /** which team currently holds the zone (team-color plate fill) */ control: [boolean, boolean]; + /** + * mean team-ink RGB per side, sampled off the plate (the team-color + * fill while in control, the digit ink otherwise — one of the two is + * always drawn in the team's color); null when too little saturated + * ink was found. The stable team identity for casted footage, where + * the plates follow the specced player's side instead of alpha/bravo. + */ + teamColor: [InkRgb | null, InkRgb | null]; } export const OBJECTIVE_EVENT_TYPE = "Objective"; @@ -89,7 +98,8 @@ const CHECK_INTERVAL_SECONDS = 1; * Timeline content guard: consecutive counter reads merge only when they * show the same state, so every actual tick/penalty/control change becomes * its own event. `time` is deliberately not compared — the timer ticks - * every second, so comparing it would keep any two reads from ever merging. + * every second, so comparing it would keep any two reads from ever merging + * — and neither is `teamColor`, whose raw pixel means jitter per frame. */ export function sameObjectiveData(a: unknown, b: unknown): boolean { const da = a as ObjectiveData; @@ -110,6 +120,7 @@ interface SideRead { penalty: BannerScoreRead | null; control: boolean; fill: { mean: number; saturation: number }; + teamColor: InkRgb | null; } export function createObjectiveDetector( @@ -312,6 +323,12 @@ export function createObjectiveDetector( score.value !== null && fill.saturation >= CONTROL_PLATE_MIN_SATURATION, fill, + // the fill while in control, the digit ink otherwise — both + // ROIs together always cover whichever carries the team color + teamColor: meanInkColor(frame, [ + SCORE_ROIS[side], + PLATE_PROBE_ROIS[side], + ]), }; }) as [SideRead, SideRead]; const timer = readTimer(gray); @@ -338,6 +355,7 @@ export function createObjectiveDetector( sides[1].penalty?.value ?? null, ], control: [sides[0].control, sides[1].control], + teamColor: [sides[0].teamColor, sides[1].teamColor], }, debug: { timerReading: timer.reading, diff --git a/app/features/scanner/core/ink-color.ts b/app/features/scanner/core/ink-color.ts new file mode 100644 index 000000000..ebe516717 --- /dev/null +++ b/app/features/scanner/core/ink-color.ts @@ -0,0 +1,85 @@ +/** + * Team ink color sampling and comparison. Splatoon renders each team's UI + * accents — counter plate fills and digits, minimap sub tiles — in the + * team's ink color, fixed for the whole game and picked to contrast with + * the opponent's. That makes ink hue a per-frame team identity signal + * where screen position is not one: casted footage reorders HUD sides to + * follow the currently specced player. + */ +import type { Mat } from "./cv"; +import { copyRoi, type Roi } from "./image"; + +export interface InkRgb { + r: number; + g: number; + b: number; +} + +/** Channel spread (max-min) a pixel needs to count as ink, not chrome. */ +const INK_MIN_SATURATION = 60; + +/** Fewer qualifying pixels than this = no reliable ink in the ROIs. */ +const MIN_INK_PIXELS = 30; + +/** + * Mean RGB of the ink-saturated pixels across the ROIs (channel spread at + * least `minSaturation`); null when fewer than MIN_INK_PIXELS qualify. + * Averaging only saturated pixels keeps dark/white surroundings (plate + * fills, digit cores, tile backgrounds) from washing the hue out. + */ +export function meanInkColor( + frame: Mat, + rois: readonly Roi[], + minSaturation: number = INK_MIN_SATURATION, +): InkRgb | null { + let r = 0; + let g = 0; + let b = 0; + let count = 0; + 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 pr = data[i]!; + const pg = data[i + 1]!; + const pb = data[i + 2]!; + const spread = Math.max(pr, pg, pb) - Math.min(pr, pg, pb); + if (spread < minSaturation) continue; + r += pr; + g += pg; + b += pb; + count++; + } + crop.delete(); + } + if (count < MIN_INK_PIXELS) return null; + return { + r: Math.round(r / count), + g: Math.round(g / count), + b: Math.round(b / count), + }; +} + +/** 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); + const min = Math.min(color.r, color.g, color.b); + if (max === min) return 0; + const d = max - min; + let h: number; + if (max === color.r) { + h = ((color.g - color.b) / d) % 6; + } else if (max === color.g) { + h = (color.b - color.r) / d + 2; + } else { + h = (color.r - color.g) / d + 4; + } + return (h * 60 + 360) % 360; +} + +/** Shortest angular distance between two hues, 0-180 degrees. */ +export function hueDistance(a: number, b: number): number { + const d = Math.abs(a - b) % 360; + return d > 180 ? 360 - d : d; +} diff --git a/app/features/scanner/core/match-builder.ts b/app/features/scanner/core/match-builder.ts index 10f4f1776..c14e78f2e 100644 --- a/app/features/scanner/core/match-builder.ts +++ b/app/features/scanner/core/match-builder.ts @@ -36,6 +36,7 @@ import { type ScoreboardBattleLogReplayData, } from "./detectors/scoreboard-battle-log-replay/index"; import type { DetectedEvent } from "./detectors/types"; +import { hueDistance, hueOf, type InkRgb } from "./ink-color"; import { parseReplayTimestamp } from "./replay-time"; import type { ScannerMatch, @@ -70,6 +71,14 @@ const PLAYERS_PER_TEAM = 4; */ const EARLY_END_MARGIN_SECONDS = 10; +/** + * The two team-ink hues must be at least this far apart before color is + * trusted to orient counter reads: a game's color pair is picked to + * contrast (attested pairs measure >130° apart), so a closer seed pair is + * a misread, and orientation falls back to the as-read arrangement. + */ +const MIN_TEAM_HUE_SEPARATION = 30; + export interface BuiltMatch { match: ScannerMatch; /** @@ -357,6 +366,7 @@ function toBuiltMatch( t: event.t, data: event.data as ObjectiveData, })); + const minimaps = open.minimaps.map((event) => event.data as MinimapData); const mode = board?.mode ?? start?.mode ?? null; @@ -376,13 +386,12 @@ function toBuiltMatch( // only the SZ counter is parsed — reads on a known other-mode match // are misreads of a lookalike overlay, not progress data objective: - mode === null || mode === "SZ" ? buildObjective(objectives, board) : null, + mode === null || mode === "SZ" + ? buildObjective(objectives, board, minimapTeamColors(minimaps)) + : null, teams: board ? teamsFromScoreboard(board, deaths) - : teamsFromMinimaps( - open.minimaps.map((event) => event.data as MinimapData), - deaths, - ), + : teamsFromMinimaps(minimaps, deaths), winner: board ? 0 : null, pov: board && board.povIndex !== null @@ -401,46 +410,167 @@ function floorOrNull(t: number | undefined): number | null { } /** - * The counter reads as `objective` samples in `teams` order. The on-screen - * plates put the POV/alpha side left, which already is teams[0] for a - * minimap-grouped match; a scoreboard-closed match's teams are winner-first, - * so the sides swap when the POV seat sat on the losing team — or, with no - * POV arrow read, when the right plate's count got lower (in SZ the winner - * is the team whose remaining count went furthest down; ties keep the order - * as read). + * The counter reads as `objective` samples in `teams` order. On POV footage + * the left plate is the POV/alpha side for the whole game, but casted + * footage reorders the plates to follow the specced player — so each read + * is first oriented by its sides' team ink hues (clustered against the + * first read that saw both), making the series side-stable. The whole + * series then goes into `teams` order: a scoreboard-closed match is + * winner-first (POV seat when read; else in SZ the winner is the side + * whose remaining count went furthest down), a minimap-grouped match + * anchors on the minimap's own/alpha-vs-enemy/bravo ink colors, and with + * no signal the first read's arrangement stands. */ function buildObjective( objectives: readonly { t: number; data: ObjectiveData }[], board: ScoreboardData | undefined, + minimapColors: [InkRgb | null, InkRgb | null] | null, ): ScannerMatchObjective | null { if (objectives.length === 0) return null; + + const clusterHues = seedClusterHues(objectives); + const oriented = orientByTeamColor(objectives, clusterHues); + const swap = board ? board.povIndex !== null ? board.povIndex >= PLAYERS_PER_TEAM - : bestCount(objectives, 1) < bestCount(objectives, 0) - : false; - const samples = objectives.map(({ t, data }): ScannerMatchObjectiveSample => { + : bestCount(oriented, 1) < bestCount(oriented, 0) + : minimapAnchorSwap(clusterHues, minimapColors); + + const samples = oriented.map((read): ScannerMatchObjectiveSample => { const [a, b] = swap ? ([1, 0] as const) : ([0, 1] as const); return { - t: Math.max(0, Math.floor(t)), + t: Math.max(0, Math.floor(read.t)), + time: read.time, + score: [read.score[a], read.score[b]], + penalty: [read.penalty[a], read.penalty[b]], + control: [read.control[a], read.control[b]], + }; + }); + return { mode: "SZ", samples }; +} + +/** A counter read with its sides in cluster (first-read) order. */ +interface OrientedObjectiveRead { + t: number; + time: number | null; + score: [number | null, number | null]; + penalty: [number | null, number | null]; + control: [boolean, boolean]; +} + +/** + * The two team-ink cluster hues, seeded from the first read that saw both + * sides' colors far enough apart; null when no read qualifies (color + * orientation then stays at the as-read arrangement). + */ +function seedClusterHues( + objectives: readonly { data: ObjectiveData }[], +): [number, number] | null { + for (const { data } of objectives) { + const [left, right] = data.teamColor; + if (left === null || right === null) continue; + const hues: [number, number] = [hueOf(left), hueOf(right)]; + if (hueDistance(hues[0], hues[1]) >= MIN_TEAM_HUE_SEPARATION) return hues; + } + return null; +} + +/** + * Assign every read's sides to the color clusters: a read whose ink hues + * sit closer to the clusters crosswise is swapped (the cast switched the + * specced side). Reads with no readable color inherit the previous read's + * orientation — plate arrangement only changes with a camera change, which + * leaves the colors readable once the plates are back. + */ +function orientByTeamColor( + objectives: readonly { t: number; data: ObjectiveData }[], + clusterHues: [number, number] | null, +): OrientedObjectiveRead[] { + let previousSwapped = false; + return objectives.map(({ t, data }): OrientedObjectiveRead => { + const swapped = clusterHues + ? readSwapped(data, clusterHues, previousSwapped) + : false; + previousSwapped = swapped; + const [a, b] = swapped ? ([1, 0] as const) : ([0, 1] as const); + return { + t, time: data.time, score: [data.score[a], data.score[b]], penalty: [data.penalty[a], data.penalty[b]], control: [data.control[a], data.control[b]], }; }); - return { mode: "SZ", samples }; } -/** The lowest count a side's plate ever showed; Infinity when never read. */ +function readSwapped( + data: ObjectiveData, + clusterHues: [number, number], + previousSwapped: boolean, +): boolean { + const [left, right] = data.teamColor; + if (left === null && right === null) return previousSwapped; + const identityCost = + (left ? hueDistance(hueOf(left), clusterHues[0]) : 0) + + (right ? hueDistance(hueOf(right), clusterHues[1]) : 0); + const swappedCost = + (left ? hueDistance(hueOf(left), clusterHues[1]) : 0) + + (right ? hueDistance(hueOf(right), clusterHues[0]) : 0); + if (identityCost === swappedCost) return previousSwapped; + return swappedCost < identityCost; +} + +/** + * Whether the cluster order is bravo-first, judged against the minimap's + * ink colors (own/alpha column, enemy/bravo column) — the `teams` anchor + * for cast matches, which never see a results screen. + */ +function minimapAnchorSwap( + clusterHues: [number, number] | null, + minimapColors: [InkRgb | null, InkRgb | null] | null, +): boolean { + if (!clusterHues || !minimapColors) return false; + const [own, enemy] = minimapColors; + if (own === null && enemy === null) return false; + const identityCost = + (own ? hueDistance(hueOf(own), clusterHues[0]) : 0) + + (enemy ? hueDistance(hueOf(enemy), clusterHues[1]) : 0); + const swappedCost = + (own ? hueDistance(hueOf(own), clusterHues[1]) : 0) + + (enemy ? hueDistance(hueOf(enemy), clusterHues[0]) : 0); + return swappedCost < identityCost; +} + +/** + * Componentwise mean of the minimap reads' per-side ink colors; null when + * no read got a side's color (or there were no minimaps at all). + */ +function minimapTeamColors( + minimaps: readonly MinimapData[], +): [InkRgb | null, InkRgb | null] | null { + if (minimaps.length === 0) return null; + const sides = [0, 1].map((side): InkRgb | null => { + const colors = minimaps + .map((minimap) => minimap.teamColors[side as 0 | 1]) + .filter((color): color is InkRgb => color !== null); + if (colors.length === 0) return null; + return { + r: Math.round(colors.reduce((sum, c) => sum + c.r, 0) / colors.length), + g: Math.round(colors.reduce((sum, c) => sum + c.g, 0) / colors.length), + b: Math.round(colors.reduce((sum, c) => sum + c.b, 0) / colors.length), + }; + }) as [InkRgb | null, InkRgb | null]; + return sides[0] === null && sides[1] === null ? null : sides; +} + +/** The lowest count a side ever showed; Infinity when never read. */ function bestCount( - objectives: readonly { data: ObjectiveData }[], + oriented: readonly OrientedObjectiveRead[], side: 0 | 1, ): number { return Math.min( - ...objectives.map( - ({ data }) => data.score[side] ?? Number.POSITIVE_INFINITY, - ), + ...oriented.map((read) => read.score[side] ?? Number.POSITIVE_INFINITY), ); } diff --git a/app/features/scanner/core/scanner-match.ts b/app/features/scanner/core/scanner-match.ts index adba07492..e5ce29a03 100644 --- a/app/features/scanner/core/scanner-match.ts +++ b/app/features/scanner/core/scanner-match.ts @@ -83,9 +83,10 @@ export interface ScannerMatch { /** spectator/casted footage (the 8-player spectator map screen was seen) */ cast: boolean; /** - * counter progress samples in `teams` order (the on-screen left plate is - * the POV/alpha side; the builder reorients when teams[0] is the other - * side); null when no counter was read + * counter progress samples in `teams` order — the builder tracks each + * side's team ink color, so casted footage's plate swaps (the plates + * follow the specced player) can't scramble the series; null when no + * counter was read */ objective: ScannerMatchObjective | null; /** diff --git a/app/features/scanner/tests/dedupe-events.test.ts b/app/features/scanner/tests/dedupe-events.test.ts index 2d1baa665..8271e151a 100644 --- a/app/features/scanner/tests/dedupe-events.test.ts +++ b/app/features/scanner/tests/dedupe-events.test.ts @@ -44,7 +44,13 @@ function minimap( enemies = BRAVO.map((id) => enemy(id)), } = {}, ): DetectedEvent { - const data: MinimapData = { stage, spectator: true, teammates, enemies }; + const data: MinimapData = { + stage, + spectator: true, + teammates, + enemies, + teamColors: [null, null], + }; return { type: "Minimap", t, confidence: 0.8, data }; } diff --git a/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/expected.json b/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/expected.json new file mode 100644 index 000000000..3d10aa3bb --- /dev/null +++ b/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/expected.json @@ -0,0 +1,68 @@ +{ + "event": "Minimap", + "data": { + "stage": 1, + "spectator": true, + "teammates": [ + { + "slot": "up", + "name": "King Inate", + "abilities": ["LDE", "RSU", "RSU"], + "weaponLabel": "Zink Mini Splatling", + "weaponId": 4001 + }, + { + "slot": "right", + "name": "todo", + "abilities": ["CB", "SSU", "SJ"], + "weaponLabel": "Mint Decavitator", + "weaponId": 8020 + }, + { + "slot": "down", + "name": "tanaha", + "abilities": ["RSU", "SSU", "SCU"], + "weaponLabel": "Splat Brella", + "weaponId": 6000 + }, + { + "slot": "left", + "name": "soph", + "abilities": ["RSU", "RSU", "SJ"], + "weaponLabel": "Heavy Splatling", + "weaponId": 4010 + } + ], + "enemies": [ + { + "name": "Burstie", + "abilities": ["SSU", "SSU", "SSU"], + "weaponLabel": ".96 Gal", + "weaponId": 80 + }, + { + "name": "[K]yo!", + "abilities": ["CB", "QR", "SJ"], + "weaponLabel": "Slosher", + "weaponId": 3000 + }, + { + "name": "leafi !!", + "abilities": ["OG", "SSU", "SJ"], + "weaponLabel": "Custom Blaster", + "weaponId": 211 + }, + { + "name": "biscuit", + "abilities": ["LDE", "SCU", "ISM"], + "weaponLabel": "Snipewriter 5H", + "weaponId": 2070 + } + ], + "stageLabel": "Eeltail Alley" + }, + "options": { + "skipFields": ["teammates.0.name"], + "notes": "8-player spectator map screen from the SWS26 cast, same game as the objective/ splat-zones-cast-* fixtures: green team left column, purple right — the cross-detector anchor pair for objective team-color tracking. teammates.0.name skipped: King Inate reads King lnate — capital I and lowercase l are pixel-identical in BlitzMain at this size (the POV nameplate in the sibling frame confirms Inate)." + } +} diff --git a/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/frame.png b/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/frame.png new file mode 100644 index 000000000..7bdb9650c Binary files /dev/null and b/app/features/scanner/tests/fixtures/minimap/spectator-sws26-swiss/frame.png differ 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 new file mode 100644 index 000000000..5bfc07aac --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-overhead-purple-right/expected.json @@ -0,0 +1,13 @@ +{ + "event": "Objective", + "data": { + "mode": "SZ", + "time": 243, + "score": [49, 98], + "penalty": [39, null], + "control": [false, true] + }, + "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-overhead-purple-right/frame.png b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-overhead-purple-right/frame.png new file mode 100644 index 000000000..5a5d085a5 Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-overhead-purple-right/frame.png differ 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 new file mode 100644 index 000000000..6669d007b --- /dev/null +++ b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-specced-purple-left/expected.json @@ -0,0 +1,13 @@ +{ + "event": "Objective", + "data": { + "mode": "SZ", + "time": 248, + "score": [100, 49], + "penalty": [null, null], + "control": [false, false] + }, + "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-cast-specced-purple-left/frame.png b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-specced-purple-left/frame.png new file mode 100644 index 000000000..6214e9ae9 Binary files /dev/null and b/app/features/scanner/tests/fixtures/objective/splat-zones-cast-specced-purple-left/frame.png differ diff --git a/app/features/scanner/tests/match-builder.test.ts b/app/features/scanner/tests/match-builder.test.ts index e72973bcb..c2edc9572 100644 --- a/app/features/scanner/tests/match-builder.test.ts +++ b/app/features/scanner/tests/match-builder.test.ts @@ -58,9 +58,17 @@ function objective( score = [95, 53] as [number | null, number | null], penalty = [null, null] as [number | null, number | null], control = [true, false] as [boolean, boolean], + teamColor = [null, null] as ObjectiveData["teamColor"], } = {}, ): DetectedEvent { - const data: ObjectiveData = { mode: "SZ", time, score, penalty, control }; + const data: ObjectiveData = { + mode: "SZ", + time, + score, + penalty, + control, + teamColor, + }; return { type: "Objective", t, confidence: 0.9, data }; } @@ -147,6 +155,7 @@ function minimap( alpha = ALPHA as (MainWeaponId | null)[], bravo = BRAVO as (MainWeaponId | null)[], spectator = true, + teamColors = [null, null] as MinimapData["teamColors"], } = {}, ): DetectedEvent { const data: MinimapData = { @@ -154,6 +163,7 @@ function minimap( spectator, teammates: alpha.map(teammate), enemies: bravo.map(enemy), + teamColors, }; return { type: "Minimap", t, confidence: 0.8, data }; } @@ -279,6 +289,76 @@ test("an unknown-mode match keeps its objective reads", () => { assert.deepEqual(invalidObjectiveEvents(built), []); }); +const GREEN_INK = { r: 146, g: 180, b: 96 }; +const PURPLE_INK = { r: 130, g: 43, b: 130 }; + +test("casted plate swaps are reoriented by team ink color", () => { + const built = buildScannerMatches([ + minimap(0, { teamColors: [GREEN_INK, PURPLE_INK] }), + objective(60, { + score: [80, 90], + control: [true, false], + 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], + teamColor: [PURPLE_INK, GREEN_INK], + }), + // colors unreadable: the previous arrangement carries over + objective(125, { + score: [85, 75], + control: [true, false], + teamColor: [null, null], + }), + minimap(180), + ]); + assert.equal(built.length, 1); + const samples = built[0]!.match.objective!.samples; + assert.deepEqual( + samples.map((sample) => sample.score), + [ + [80, 90], + [75, 90], + [75, 85], + ], + ); + assert.deepEqual( + samples.map((sample) => sample.penalty), + [ + [null, null], + [null, 4], + [null, null], + ], + ); + assert.deepEqual( + samples.map((sample) => sample.control), + [ + [true, false], + [false, true], + [false, true], + ], + ); +}); + +test("minimap ink colors anchor a bravo-first cluster into teams order", () => { + const built = buildScannerMatches([ + minimap(0, { teamColors: [GREEN_INK, PURPLE_INK] }), + // every read had purple (bravo) on the left plate + objective(60, { + score: [90, 80], + control: [false, true], + 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]); +}); + test("without a pov the side whose count got lower is the winner side", () => { const built = buildScannerMatches([ mapStart(0), diff --git a/app/features/scanner/tests/minimap.test.ts b/app/features/scanner/tests/minimap.test.ts index ec6e49697..d3609cc50 100644 --- a/app/features/scanner/tests/minimap.test.ts +++ b/app/features/scanner/tests/minimap.test.ts @@ -17,6 +17,7 @@ import { createScoreboardDetector } from "../core/detectors/scoreboard/index"; import { createScoreboardBattleLogReplayDetector } from "../core/detectors/scoreboard-battle-log-replay/index"; import { createScoreboardOwnDetector } from "../core/detectors/scoreboard-own/index"; import type { Detector } from "../core/detectors/types"; +import { hueDistance, hueOf } from "../core/ink-color"; import { type Fixture, isFieldSkipped, @@ -182,6 +183,28 @@ for (const fixture of fixtures) { }); } +// The columns' sub-tile ink means anchor the objective counter's color +// clusters to `teams` order (match-builder); the SWS26 spectator fixture +// pairs with objective/splat-zones-cast-* from the same game, where the +// plates read green ~78° and purple ~302°. +test("spectator sub tiles read the two team ink colors", async () => { + const fixture = fixtures.find((f) => f.name === "spectator-sws26-swiss"); + assert.ok(fixture, "spectator-sws26-swiss fixture missing"); + const { events } = await runDetectorOnFixture( + detector, + fixture!, + ); + const teamColors = events[0]?.data.teamColors; + assert.ok(teamColors?.[0] && teamColors[1], "column ink color unreadable"); + const [alpha, bravo] = teamColors; + assert.ok( + hueDistance(hueOf(alpha), hueOf(bravo)) >= 90, + "the two columns' ink hues do not separate", + ); + assert.ok(hueDistance(hueOf(alpha), 84) <= 25, "left column is not green"); + assert.ok(hueDistance(hueOf(bravo), 300) <= 25, "right column is not purple"); +}); + // The map overlay replaces everything else on screen; its gate may not fire // on any other detector's positives, nor theirs on the minimap fixtures. const otherPositives = [ diff --git a/app/features/scanner/tests/objective.test.ts b/app/features/scanner/tests/objective.test.ts index 22a3491f5..10230a771 100644 --- a/app/features/scanner/tests/objective.test.ts +++ b/app/features/scanner/tests/objective.test.ts @@ -19,6 +19,7 @@ import { createScoreboardDetector } from "../core/detectors/scoreboard/index"; import { createScoreboardBattleLogReplayDetector } from "../core/detectors/scoreboard-battle-log-replay/index"; import { createScoreboardOwnDetector } from "../core/detectors/scoreboard-own/index"; import type { Detector } from "../core/detectors/types"; +import { hueDistance, hueOf } from "../core/ink-color"; import { type Fixture, isFieldSkipped, @@ -130,6 +131,41 @@ for (const fixture of fixtures) { }); } +// The cast fixture pair captures the same game under both camera +// arrangements (the specced team's plate sits left, so purple is left in +// one frame and right in the other): each frame's two ink hues must +// separate cleanly, and cross-frame the same team's hue must land on the +// same cluster with the sides swapped — the invariant cast score tracking +// (match-builder's color orientation) rests on. +test("cast fixture pair: team ink hues identify sides across camera swaps", async () => { + const pair = [ + "splat-zones-cast-specced-purple-left", + "splat-zones-cast-overhead-purple-right", + ].map((name) => fixtures.find((fixture) => fixture.name === name)); + assert.ok(pair[0] && pair[1], "cast fixture pair missing"); + + const colors = []; + for (const fixture of pair) { + const { events } = await runDetectorOnFixture( + detector, + fixture!, + ); + const teamColor = events[0]?.data.teamColor; + assert.ok(teamColor?.[0] && teamColor[1], "side ink color unreadable"); + colors.push([teamColor[0], teamColor[1]] as const); + } + + for (const [left, right] of colors) { + assert.ok( + hueDistance(hueOf(left), hueOf(right)) >= 90, + "the two teams' ink hues do not separate", + ); + } + const [specced, overhead] = colors; + assert.ok(hueDistance(hueOf(specced![0]), hueOf(overhead![1])) <= 20); + assert.ok(hueDistance(hueOf(specced![1]), hueOf(overhead![0])) <= 20); +}); + // Screens that replace gameplay can never show the counters — the gate must // stay quiet on their positives. const otherPositiveSets = [ diff --git a/app/features/scanner/tests/sendou-upload.test.ts b/app/features/scanner/tests/sendou-upload.test.ts index 3585cbb50..8bd5f2c29 100644 --- a/app/features/scanner/tests/sendou-upload.test.ts +++ b/app/features/scanner/tests/sendou-upload.test.ts @@ -62,6 +62,7 @@ test("weapons are padded to 4 slots per team so uneven rosters keep the team spl abilities: [], })), enemies: BRAVO.map((weaponId) => ({ name: null, weaponId, abilities: [] })), + teamColors: [null, null], }; const matches = prefilledMatches([ { type: "Minimap", t: 300, confidence: 0.9, data }, diff --git a/package.json b/package.json index dea9d7844..0cd0e1f29 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "test:e2e:flaky-detect": "playwright test --repeat-each=10 --max-failures=1", "check-plural-collapse": "node --experimental-strip-types scripts/collapse-single-plural-keys.ts --check", "checks": "pnpm run biome:fix && pnpm run test:unit:browser && pnpm run check-translation-jsons && pnpm run check-plural-collapse && pnpm run typecheck && pnpm run knip", + "checks:scanner": "pnpm checks && pnpm test:scanner", "seed": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/seed.ts", "setup": "cross-env DB_PATH=db.sqlite3 vite-node ./scripts/setup.ts", "i18n:sync": "node --experimental-strip-types scripts/collapse-single-plural-keys.ts && i18next-locales-sync -e true -p en -s da de es-ES es-US fr-CA fr-EU he it ja ko nl pl pt-BR ru zh -l locales && pnpm run biome:fix",