Resolve cast side

This commit is contained in:
Kalle
2026-08-08 13:25:26 +03:00
parent 730ba45774
commit 39af841ef5
20 changed files with 550 additions and 51 deletions

View File

@@ -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

View File

@@ -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,
);

View File

@@ -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,
);

View File

@@ -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<string, unknown>[] = [];
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<string, unknown>[] = [];
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,
},

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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<E extends DetectedEvent> {
match: ScannerMatch;
/**
@@ -357,6 +366,7 @@ function toBuiltMatch<E extends DetectedEvent>(
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<E extends DetectedEvent>(
// 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),
);
}

View File

@@ -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;
/**

View File

@@ -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 };
}

View File

@@ -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)."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -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)."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@@ -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."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@@ -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),

View File

@@ -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<MinimapData>(
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 = [

View File

@@ -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<ObjectiveData>(
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 = [

View File

@@ -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 },

View File

@@ -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",