Dead/special from minimap

This commit is contained in:
Kalle
2026-08-09 10:51:48 +03:00
parent a56f49042e
commit 9e3fa42d41
23 changed files with 613 additions and 148 deletions

View File

@@ -30,7 +30,7 @@ import { Line } from "react-chartjs-2";
import { useTranslation } from "react-i18next";
import { useThemeColors } from "~/hooks/useThemeColors";
import styles from "./ObjectiveTimeline.module.css";
import { smoothPenalties } from "./objective-timeline-utils";
import { formatElapsed, smoothPenalties } from "./objective-timeline-utils";
ChartJS.register(
LinearScale,
@@ -266,16 +266,6 @@ function gridColor(
return value === 0 ? colors.borderHigh : colors.border;
}
/** Position on the x-axis: m:ss, growing an hours part only when needed. */
function formatElapsed(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const rest = String(Math.floor(seconds % 60)).padStart(2, "0");
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${rest}`
: `${minutes}:${rest}`;
}
/** the match timer's M:SS (215 → "3:35") */
function formatClock(seconds: number): string {
const minutes = Math.floor(seconds / 60);

View File

@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { abilityImageUrl } from "~/utils/urls";
import { Image, WeaponImage } from "./Image";
import { formatElapsed } from "./objective-timeline-utils";
import styles from "./PlayerStatusTimeline.module.css";
/** Consecutive reads further apart than this leave an unknown gap. */
@@ -59,6 +60,8 @@ export function PlayerStatusTimeline({
const leftOf = (span: StatusSpan) => `${((span.start - min) / range) * 100}%`;
const widthOf = (span: StatusSpan) =>
`${((span.end - span.start) / range) * 100}%`;
const titleOf = (label: string, span: StatusSpan) =>
`${label} · ${formatElapsed(span.start)}${formatElapsed(span.end)}`;
return (
<div className={styles.container}>
@@ -85,6 +88,10 @@ export function PlayerStatusTimeline({
key={`d${i}`}
className={styles.spanDead}
style={{ left: leftOf(span), width: widthOf(span) }}
title={titleOf(
t("common:playerStatusTimeline.splatted"),
span,
)}
/>
),
)}
@@ -96,6 +103,10 @@ export function PlayerStatusTimeline({
key={`s${i}`}
className={styles.spanSpecial}
style={{ left: leftOf(span), width: widthOf(span) }}
title={titleOf(
t("common:playerStatusTimeline.specialReady"),
span,
)}
/>
))}
</div>

View File

@@ -90,6 +90,19 @@ export function matchScoresFromObjective(
return [lastCountTaken(0), lastCountTaken(1)];
}
/**
* Seconds into the source formatted for display: m:ss, growing an hours
* part only when needed.
*/
export function formatElapsed(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const rest = String(Math.floor(seconds % 60)).padStart(2, "0");
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${rest}`
: `${minutes}:${rest}`;
}
function medianFilterValues(
values: readonly (number | null)[],
): (number | null)[] {

View File

@@ -109,7 +109,12 @@ sequenceDiagram
wholesale: same replay-wipe anchor, cast orientation inherited from the
nearest counter read, nulled together on non-SZ matches, and rendered as
per-player splat/special bands (`~/components/PlayerStatusTimeline.tsx`,
shared with the match page) above the objective chart. Parsing details
shared with the match page) above the objective chart. Minimap reads
feed the same samples: every card/row carries `dead` (respawn
cross-out) and `specialReady` (special camo) flags, merged in timerless
on the shared replay anchor — and mode-agnostic, so a known non-SZ
match keeps its minimap-sourced samples while its counter/status
misreads are voided. Parsing details
are in each detector's module
header; accuracy-critical matching internals in `core/glyphs.ts` and
`core/detectors/scoreboard/weapons.ts` — read those before touching

View File

@@ -69,6 +69,16 @@ function PlayerRow({
<span className="weapon-missing">?</span>
)}
<span className="name">{player.name ?? ""}</span>
{player.dead ? (
<span className="status-chip dead" title="respawning (struck out)">
</span>
) : null}
{player.specialReady ? (
<span className="status-chip special" title="special ready (camo)">
</span>
) : null}
<span className="abilities">
<AbilityRow abilities={player.abilities} />
</span>

View File

@@ -93,9 +93,12 @@ function formatMinimapPlayers(data: MinimapData): string {
name: string | null;
weaponId: number | null;
abilities: (string | null)[];
dead: boolean;
specialReady: boolean;
},
) =>
`${label} ${p.name ?? "?"} · ${mainWeaponLabel(p.weaponId as MainWeaponId | null) ?? "?"} · ${formatMinimapAbilities(p.abilities)}`;
`${label} ${p.name ?? "?"} · ${mainWeaponLabel(p.weaponId as MainWeaponId | null) ?? "?"} · ${formatMinimapAbilities(p.abilities)}` +
`${p.dead ? " · splatted" : ""}${p.specialReady ? " · special" : ""}`;
return [
...data.teammates.map((p) => fmt(p.slot, p)),
...data.enemies.map((p, i) => fmt(`enemy${i + 1}`, p)),

View File

@@ -123,12 +123,16 @@ function buildExpectedJson(
weaponLabel: mainWeaponLabel(p.weaponId),
weaponId: p.weaponId,
abilities: p.abilities,
dead: p.dead,
specialReady: p.specialReady,
})),
enemies: minimap.enemies.map((p) => ({
...(minimap.spectator && { name: p.name }),
weaponLabel: mainWeaponLabel(p.weaponId),
weaponId: p.weaponId,
abilities: p.abilities,
dead: p.dead,
specialReady: p.specialReady,
})),
},
},

View File

@@ -355,6 +355,29 @@ app/styles/vars.css (the emberz copies of those tokens were dropped).
gap: var(--s-0-5);
flex-shrink: 0;
}
& .status-chip {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: var(--radius-full);
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
line-height: 1;
&.dead {
background: var(--color-error-low);
color: var(--color-error-high);
}
&.special {
background: var(--color-warning-low);
color: var(--color-warning-high);
}
}
}
/* d-pad chevron / face-button pucks, same round-solid language as weapon icons */

View File

@@ -3,11 +3,12 @@
* own-team callout cards (name, main weapon, the three main-ability
* badges), the enemy panel rows (weapon, abilities; the game shows no
* enemy names) — plus the stage, matched from the drawn map (stage.ts).
* The goal is the most complete read of every card/row; per-match state
* (respawn cross-outs, special charge, map control) is deliberately not
* reported.
* The goal is the most complete read of every card/row; map control is
* deliberately not reported.
*
* Two screen states still steer the reads without being emitted:
* Two per-player screen states are reported (`dead`, `specialReady` — the
* match builder merges them into the death/special timeline alongside the
* icon-strip PlayerStatus reads) and steer the reads themselves:
* - a respawning player's card is struck through with a large team-color
* X that covers the name and badges (own cards also lose the weapon;
* enemy rows keep theirs — the X spares the row's weapon icon). Reading
@@ -96,6 +97,10 @@ export interface MinimapTeammate {
* unreadable badge); empty when a respawn cross-out sits over the badges
*/
abilities: (AbilityWithUnknown | null)[];
/** struck through with the respawn cross-out at the read */
dead: boolean;
/** on the light camo surface of a charged special at the read */
specialReady: boolean;
}
export interface MinimapEnemy {
@@ -107,6 +112,10 @@ export interface MinimapEnemy {
/** readable even on struck rows: the cross-out spares the weapon icon */
weaponId: MainWeaponId | null;
abilities: (AbilityWithUnknown | null)[];
/** struck through with the respawn cross-out at the read */
dead: boolean;
/** on the light camo surface of a charged special at the read */
specialReady: boolean;
}
export interface MinimapData {
@@ -139,6 +148,30 @@ export interface MinimapData {
export const MINIMAP_EVENT_TYPE = "Minimap";
/**
* Timeline content guard: minimap frames inside the merge window collapse
* only while every card/row keeps its dead/special state, so each flip a
* map-open catches (a respawn, a special charged or spent) stays its own
* event. Names, weapons and badges are not compared — OCR wobble on an
* unchanged screen is still the same state.
*/
export function sameMinimapStatusData(a: unknown, b: unknown): boolean {
const da = a as MinimapData;
const db = b as MinimapData;
const sameSide = (
xs: readonly { dead: boolean; specialReady: boolean }[],
ys: readonly { dead: boolean; specialReady: boolean }[],
): boolean =>
xs.length === ys.length &&
xs.every(
(x, i) =>
x.dead === ys[i]!.dead && x.specialReady === ys[i]!.specialReady,
);
return (
sameSide(da.teammates, db.teammates) && sameSide(da.enemies, db.enemies)
);
}
/** Badge match below this is reported as null (kept in debug). */
const ABILITY_MIN_SCORE = 0.45;
@@ -407,6 +440,8 @@ export function createMinimapDetector(
name,
weaponId: matched ? toMainWeaponId(matched.id) : null,
abilities,
dead: occluded,
specialReady: lightSurface,
};
if (dx === 0) {
teammates.push({ slot: SPECTATOR_SLOTS[row]!, ...fields });
@@ -563,6 +598,8 @@ export function createMinimapDetector(
name,
weaponId: matched ? toMainWeaponId(matched.id) : null,
abilities,
dead: occluded,
specialReady: lightSurface,
});
}
debug.cards = cardDebug;
@@ -627,6 +664,8 @@ export function createMinimapDetector(
name: null,
weaponId: matched ? toMainWeaponId(matched.id) : null,
abilities,
dead: occluded,
specialReady: lightSurface,
});
}
debug.enemies = enemyDebug;

View File

@@ -28,6 +28,7 @@ import {
import {
PLAYER_STATUS_EVENT_TYPE,
type PlayerStatusData,
type PlayerStatusFlags,
} from "./detectors/objective/player-status";
import { SCOREBOARD_EVENT_TYPES } from "./detectors/registry";
import type { ScoreboardData } from "./detectors/scoreboard/index";
@@ -403,21 +404,25 @@ function toBuiltMatch<E extends DetectedEvent>(
t: event.t,
data: event.data as PlayerStatusData,
}));
const minimaps = open.minimaps.map((event) => event.data as MinimapData);
const minimapReads = open.minimaps.map((event) => ({
t: event.t,
data: event.data as MinimapData,
}));
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
// misreads of a lookalike overlay, not progress data (statuses included:
// they only ever ride along with counter reads)
const progress =
mode === null || mode === "SZ"
? buildProgress(
objectives,
playerStatuses,
board,
minimapTeamColors(minimaps),
)
: { objective: null, playerStatus: null };
// they only ever ride along with counter reads). Minimap card states are
// mode-agnostic, so they feed the status samples regardless
const counterModeValid = mode === null || mode === "SZ";
const progress = buildProgress(
counterModeValid ? objectives : [],
counterModeValid ? playerStatuses : [],
minimapReads,
board,
minimapTeamColors(minimaps),
);
const match: ScannerMatch = {
startsAt:
@@ -479,10 +484,22 @@ function floorOrNull(t: number | undefined): number | null {
* no signal the first read's arrangement stands. A side's four slots keep
* their on-screen left-to-right order through a side swap — whether the
* game mirrors slot order across sides is unattested so far.
*
* Minimap reads contribute status samples too (their card cross-out and
* special-camo states), interleaved with the icon-strip reads on the same
* replay-wipe anchor (timerless, so each follows its anchored neighbor).
* Their sides are own/alpha-vs-enemy/bravo — camera-stable, unlike the
* plates — so they skip the per-read cluster orientation and map to
* `teams` through the same minimap ink anchor the whole match uses
* (identity on a minimap-grouped match by construction). Whether the
* minimap's card order matches the icon strip's slot order within a side
* is unattested so far; both follow the order `teams` players are seated
* in for their respective sources.
*/
function buildProgress(
objectives: readonly { t: number; data: ObjectiveData }[],
playerStatuses: readonly { t: number; data: PlayerStatusData }[],
minimapReads: readonly { t: number; data: MinimapData }[],
board: ScoreboardData | undefined,
minimapColors: [InkRgb | null, InkRgb | null] | null,
): {
@@ -491,7 +508,17 @@ function buildProgress(
} {
const dominant = dominantAnchorOf([...objectives, ...playerStatuses]);
const live = withoutReplayReads(objectives, dominant);
const liveStatuses = withoutReplayReads(playerStatuses, dominant);
const statusReads = [
...playerStatuses.map(
(read): StatusRead => ({
t: read.t,
fromMinimap: false,
data: read.data,
}),
),
...minimapStatusReads(minimapReads),
].sort((a, b) => a.t - b.t);
const liveStatuses = withoutReplayReads(statusReads, dominant);
const clusterHues = seedClusterHues(live);
const swapFlags = readSwapFlags(live, clusterHues);
@@ -502,6 +529,7 @@ function buildProgress(
? board.povIndex >= PLAYERS_PER_TEAM
: bestCount(oriented, 1) < bestCount(oriented, 0)
: minimapAnchorSwap(clusterHues, minimapColors);
const minimapSwapped = swap !== minimapAnchorSwap(clusterHues, minimapColors);
const objective =
oriented.length === 0
@@ -525,9 +553,10 @@ function buildProgress(
? null
: {
samples: liveStatuses.map((read): ScannerMatchPlayerStatusSample => {
const clusterSwapped = nearestSwapFlag(live, swapFlags, read.t);
const [a, b] =
clusterSwapped !== swap ? ([1, 0] as const) : ([0, 1] as const);
const swapped = read.fromMinimap
? minimapSwapped
: nearestSwapFlag(live, swapFlags, read.t) !== swap;
const [a, b] = swapped ? ([1, 0] as const) : ([0, 1] as const);
return {
t: Math.max(0, Math.floor(read.t)),
time: read.data.time,
@@ -540,6 +569,57 @@ function buildProgress(
return { objective, playerStatus };
}
/** A status read from either source, sides as read (pre-orientation). */
interface StatusRead {
t: number;
/** minimap sides are own/enemy — camera-stable, unlike the HUD plates */
fromMinimap: boolean;
data: {
time: number | null;
special: [PlayerStatusFlags, PlayerStatusFlags];
dead: [PlayerStatusFlags, PlayerStatusFlags];
};
}
/**
* The minimap reads' card/row states as status reads: own/alpha side
* first, slots in card order — the same order `teamsFromMinimaps` seats
* players — with absent cards padded false (nothing to chart, never a
* fabricated state). A read that saw no cards at all identifies nobody
* and contributes nothing.
*/
function minimapStatusReads(
minimapReads: readonly { t: number; data: MinimapData }[],
): StatusRead[] {
return minimapReads.flatMap((read): StatusRead[] => {
const { teammates, enemies } = read.data;
if (teammates.length === 0 && enemies.length === 0) return [];
return [
{
t: read.t,
fromMinimap: true,
data: {
time: null,
special: [
sideFlags(teammates, "specialReady"),
sideFlags(enemies, "specialReady"),
],
dead: [sideFlags(teammates, "dead"), sideFlags(enemies, "dead")],
},
},
];
});
}
function sideFlags(
players: readonly { dead: boolean; specialReady: boolean }[],
key: "dead" | "specialReady",
): PlayerStatusFlags {
return [0, 1, 2, 3].map(
(slot) => players[slot]?.[key] ?? false,
) as PlayerStatusFlags;
}
/**
* The cluster-orientation flag of the counter read nearest in time —
* status reads are emitted off the same frames as counter reads, so the

View File

@@ -4,6 +4,10 @@
* highest-confidence version; events below a confidence floor are dropped.
*/
import {
MINIMAP_EVENT_TYPE,
sameMinimapStatusData,
} from "../detectors/minimap/index";
import {
OBJECTIVE_EVENT_TYPE,
sameObjectiveData,
@@ -43,7 +47,8 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
// of one death land within the window while consecutive deaths are outside;
// players flick the map open for 1-3s and each open is a fresh sample
// (slots read differently across opens), so minimap frames merge only
// within one open
// within one open — and a dead/special flip caught mid-open stays its
// own event via the content guard
// objective counter reads repeat every check second; the content guard
// below keeps every actual change while the window collapses static
// stretches into one event per state. Player statuses can revisit an
@@ -51,7 +56,7 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
// window must stay under that
mergeWindowByType: {
Death: 8,
Minimap: 5,
[MINIMAP_EVENT_TYPE]: 5,
[OBJECTIVE_EVENT_TYPE]: 10,
[PLAYER_STATUS_EVENT_TYPE]: 5,
},
@@ -59,6 +64,7 @@ const DEFAULT_TIMELINE_OPTIONS: TimelineOptions = {
[SCOREBOARD_EVENT_TYPE]: sameScoreboardMatch,
[SCOREBOARD_BATTLE_LOG_REPLAY_EVENT_TYPE]: sameScoreboardMatch,
[SCOREBOARD_BATTLE_LOG_EVENT_TYPE]: sameScoreboardMatch,
[MINIMAP_EVENT_TYPE]: sameMinimapStatusData,
[OBJECTIVE_EVENT_TYPE]: sameObjectiveData,
[PLAYER_STATUS_EVENT_TYPE]: samePlayerStatusData,
},

View File

@@ -36,12 +36,16 @@ interface ExpectedPlayer {
}
interface ExpectedMinimapTeammate {
slot?: "up" | "left" | "right" | "self";
slot?: "up" | "left" | "right" | "self" | "down";
name?: string | null;
/** informational for the human corrector; tests compare weaponId */
weaponLabel?: string | null;
weaponId?: MainWeaponId | null;
abilities?: (AbilityWithUnknown | null)[];
/** struck through with the respawn cross-out */
dead?: boolean;
/** on the light camo surface of a charged special */
specialReady?: boolean;
}
interface ExpectedMinimapEnemy {
@@ -51,6 +55,10 @@ interface ExpectedMinimapEnemy {
weaponLabel?: string | null;
weaponId?: MainWeaponId | null;
abilities?: (AbilityWithUnknown | null)[];
/** struck through with the respawn cross-out */
dead?: boolean;
/** on the light camo surface of a charged special */
specialReady?: boolean;
}
interface ExpectedScoreboard {

View File

@@ -24,16 +24,24 @@ function teammate(
{
name = null as string | null,
abilities = [] as (AbilityWithUnknown | null)[],
dead = false,
} = {},
): MinimapTeammate {
return { slot: SPECTATOR_SLOTS[i]!, name, weaponId, abilities };
return {
slot: SPECTATOR_SLOTS[i]!,
name,
weaponId,
abilities,
dead,
specialReady: false,
};
}
function enemy(
weaponId: MainWeaponId | null,
{ name = null as string | null } = {},
): MinimapEnemy {
return { name, weaponId, abilities: [] };
return { name, weaponId, abilities: [], dead: false, specialReady: false };
}
function minimap(
@@ -90,6 +98,16 @@ test("a changed ability read keeps both minimaps", () => {
assert.equal(kept.length, 2);
});
test("a changed dead state keeps both minimaps", () => {
const kept = withoutRepeatEvents([
minimap(70),
minimap(73, {
teammates: ALPHA.map((id, i) => teammate(id, i, { dead: i === 0 })),
}),
]);
assert.equal(kept.length, 2);
});
test("dedupes across interleaved events of other types, dropping none of them", () => {
const kept = withoutRepeatEvents([minimap(70), death(72), minimap(74)]);
assert.deepEqual(

View File

@@ -12,7 +12,9 @@
"SJ"
],
"weaponLabel": "Splattershot",
"weaponId": 40
"weaponId": 40,
"dead": false,
"specialReady": false
},
{
"slot": "left",
@@ -23,7 +25,9 @@
"SJ"
],
"weaponLabel": "Splatana Stamper",
"weaponId": 8000
"weaponId": 8000,
"dead": false,
"specialReady": true
},
{
"slot": "right",
@@ -34,7 +38,9 @@
"ISS"
],
"weaponLabel": "Splattershot Jr.",
"weaponId": 10
"weaponId": 10,
"dead": false,
"specialReady": false
},
{
"slot": "self",
@@ -45,7 +51,9 @@
"SJ"
],
"weaponLabel": "Custom Blaster",
"weaponId": 211
"weaponId": 211,
"dead": false,
"specialReady": false
}
],
"enemies": [
@@ -56,7 +64,9 @@
"SJ"
],
"weaponLabel": "Inkbrush Nouveau",
"weaponId": 1101
"weaponId": 1101,
"dead": false,
"specialReady": true
},
{
"abilities": [
@@ -65,7 +75,9 @@
"SJ"
],
"weaponLabel": ".52 Gal",
"weaponId": 50
"weaponId": 50,
"dead": false,
"specialReady": false
},
{
"abilities": [
@@ -74,7 +86,9 @@
"SCU"
],
"weaponLabel": "Big Swig Roller",
"weaponId": 1040
"weaponId": 1040,
"dead": false,
"specialReady": true
},
{
"abilities": [
@@ -83,7 +97,9 @@
"SSU"
],
"weaponLabel": "Luna Blaster Neo",
"weaponId": 201
"weaponId": 201,
"dead": false,
"specialReady": false
}
],
"stageLabel": "Undertow Spillway"

View File

@@ -13,7 +13,9 @@
"SJ"
],
"weaponLabel": "Splash-o-matic",
"weaponId": 20
"weaponId": 20,
"dead": false,
"specialReady": false
},
{
"slot": "right",
@@ -24,7 +26,9 @@
"SJ"
],
"weaponLabel": "Dread Wringer",
"weaponId": 3050
"weaponId": 3050,
"dead": false,
"specialReady": false
},
{
"slot": "down",
@@ -35,7 +39,9 @@
"SJ"
],
"weaponLabel": "Custom Blaster",
"weaponId": 211
"weaponId": 211,
"dead": false,
"specialReady": false
},
{
"slot": "left",
@@ -46,7 +52,9 @@
"ISM"
],
"weaponLabel": "Snipewriter 5H",
"weaponId": 2070
"weaponId": 2070,
"dead": false,
"specialReady": false
}
],
"enemies": [
@@ -58,7 +66,9 @@
"SJ"
],
"weaponLabel": "Snipewriter 5H",
"weaponId": 2070
"weaponId": 2070,
"dead": false,
"specialReady": false
},
{
"name": "ねこすき",
@@ -68,7 +78,9 @@
"SJ"
],
"weaponLabel": "S-BLAST '91",
"weaponId": 261
"weaponId": 261,
"dead": false,
"specialReady": false
},
{
"name": "そっぴ",
@@ -78,7 +90,9 @@
"SJ"
],
"weaponLabel": "Zink Mini Splatling",
"weaponId": 4001
"weaponId": 4001,
"dead": false,
"specialReady": false
},
{
"name": "しゅーた",
@@ -88,7 +102,9 @@
"SJ"
],
"weaponLabel": ".52 Gal",
"weaponId": 50
"weaponId": 50,
"dead": false,
"specialReady": false
}
],
"stageLabel": "Manta Maria"

View File

@@ -13,7 +13,9 @@
"ISM"
],
"weaponLabel": "Snipewriter 5H",
"weaponId": 2070
"weaponId": 2070,
"dead": false,
"specialReady": false
},
{
"slot": "right",
@@ -24,7 +26,9 @@
"SSU"
],
"weaponLabel": "Splash-o-matic",
"weaponId": 20
"weaponId": 20,
"dead": false,
"specialReady": false
},
{
"slot": "down",
@@ -35,7 +39,9 @@
"SJ"
],
"weaponLabel": "Custom Blaster",
"weaponId": 211
"weaponId": 211,
"dead": false,
"specialReady": false
},
{
"slot": "left",
@@ -46,7 +52,9 @@
"SJ"
],
"weaponLabel": "Splatana Stamper",
"weaponId": 8000
"weaponId": 8000,
"dead": false,
"specialReady": false
}
],
"enemies": [
@@ -58,7 +66,9 @@
"SJ"
],
"weaponLabel": "Mint Decavitator",
"weaponId": 8020
"weaponId": 8020,
"dead": false,
"specialReady": false
},
{
"name": "たなは",
@@ -68,7 +78,9 @@
"SCU"
],
"weaponLabel": "Splat Brella",
"weaponId": 6000
"weaponId": 6000,
"dead": false,
"specialReady": false
},
{
"name": "いなてんきんぐ",
@@ -78,7 +90,9 @@
"RSU"
],
"weaponLabel": "Zink Mini Splatling",
"weaponId": 4001
"weaponId": 4001,
"dead": false,
"specialReady": false
},
{
"name": "そふ",
@@ -88,7 +102,9 @@
"RES"
],
"weaponLabel": "Heavy Splatling",
"weaponId": 4010
"weaponId": 4010,
"dead": false,
"specialReady": false
}
],
"stageLabel": "Mincemeat Metalworks"

View File

@@ -13,7 +13,9 @@
"SJ"
],
"weaponLabel": ".96 Gal",
"weaponId": 80
"weaponId": 80,
"dead": false,
"specialReady": false
},
{
"slot": "right",
@@ -24,7 +26,9 @@
"ISM"
],
"weaponLabel": "Flingza Roller",
"weaponId": 1030
"weaponId": 1030,
"dead": false,
"specialReady": false
},
{
"slot": "down",
@@ -35,7 +39,9 @@
"SJ"
],
"weaponLabel": "Order Slosher Replica",
"weaponId": 3005
"weaponId": 3005,
"dead": false,
"specialReady": false
},
{
"slot": "left",
@@ -46,7 +52,9 @@
"ISM"
],
"weaponLabel": "Rapid Blaster Pro WNT-R",
"weaponId": 252
"weaponId": 252,
"dead": false,
"specialReady": false
}
],
"enemies": [
@@ -58,7 +66,9 @@
"SJ"
],
"weaponLabel": "S-BLAST '91",
"weaponId": 261
"weaponId": 261,
"dead": false,
"specialReady": false
},
{
"name": "バッラスト",
@@ -68,7 +78,9 @@
"ISM"
],
"weaponLabel": "Snipewriter 5H",
"weaponId": 2070
"weaponId": 2070,
"dead": false,
"specialReady": false
},
{
"name": "Jared",
@@ -78,7 +90,9 @@
"SJ"
],
"weaponLabel": "Twinklez Splat Dualies",
"weaponId": 5012
"weaponId": 5012,
"dead": false,
"specialReady": false
},
{
"name": "Reiyu C:3",
@@ -88,7 +102,9 @@
"SJ"
],
"weaponLabel": "Splash-o-matic",
"weaponId": 20
"weaponId": 20,
"dead": false,
"specialReady": false
}
],
"stageLabel": "Crableg Capital"

View File

@@ -1,68 +1,118 @@
{
"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)."
}
"event": "Minimap",
"data": {
"stage": 1,
"spectator": true,
"teammates": [
{
"slot": "up",
"name": "King Inate",
"abilities": [
"LDE",
"RSU",
"RSU"
],
"weaponLabel": "Zink Mini Splatling",
"weaponId": 4001,
"dead": false,
"specialReady": false
},
{
"slot": "right",
"name": "todo",
"abilities": [
"CB",
"SSU",
"SJ"
],
"weaponLabel": "Mint Decavitator",
"weaponId": 8020,
"dead": false,
"specialReady": false
},
{
"slot": "down",
"name": "tanaha",
"abilities": [
"RSU",
"SSU",
"SCU"
],
"weaponLabel": "Splat Brella",
"weaponId": 6000,
"dead": false,
"specialReady": false
},
{
"slot": "left",
"name": "soph",
"abilities": [
"RSU",
"RSU",
"SJ"
],
"weaponLabel": "Heavy Splatling",
"weaponId": 4010,
"dead": false,
"specialReady": false
}
],
"enemies": [
{
"name": "Burstie",
"abilities": [
"SSU",
"SSU",
"SSU"
],
"weaponLabel": ".96 Gal",
"weaponId": 80,
"dead": false,
"specialReady": false
},
{
"name": "[K]yo!",
"abilities": [
"CB",
"QR",
"SJ"
],
"weaponLabel": "Slosher",
"weaponId": 3000,
"dead": false,
"specialReady": false
},
{
"name": "leafi !!",
"abilities": [
"OG",
"SSU",
"SJ"
],
"weaponLabel": "Custom Blaster",
"weaponId": 211,
"dead": false,
"specialReady": false
},
{
"name": "biscuit",
"abilities": [
"LDE",
"SCU",
"ISM"
],
"weaponLabel": "Snipewriter 5H",
"weaponId": 2070,
"dead": false,
"specialReady": false
}
],
"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)."
}
}

View File

@@ -12,7 +12,9 @@
"SJ"
],
"weaponLabel": "Tri-Stringer",
"weaponId": 7010
"weaponId": 7010,
"dead": false,
"specialReady": false
},
{
"slot": "left",
@@ -23,14 +25,18 @@
"SS"
],
"weaponLabel": "Splattershot Jr.",
"weaponId": 10
"weaponId": 10,
"dead": false,
"specialReady": false
},
{
"slot": "right",
"name": null,
"abilities": [],
"weaponLabel": null,
"weaponId": null
"weaponId": null,
"dead": true,
"specialReady": false
},
{
"slot": "self",
@@ -41,7 +47,9 @@
"SS"
],
"weaponLabel": "Ballpoint Splatling",
"weaponId": 4030
"weaponId": 4030,
"dead": false,
"specialReady": false
}
],
"enemies": [
@@ -52,17 +60,23 @@
"IRU"
],
"weaponLabel": "Slosher",
"weaponId": 3000
"weaponId": 3000,
"dead": false,
"specialReady": false
},
{
"abilities": [],
"weaponLabel": "Splat Roller",
"weaponId": 1010
"weaponId": 1010,
"dead": true,
"specialReady": false
},
{
"abilities": [],
"weaponLabel": "Dualie Squelchers",
"weaponId": 5030
"weaponId": 5030,
"dead": true,
"specialReady": true
},
{
"abilities": [
@@ -71,7 +85,9 @@
"SS"
],
"weaponLabel": "Splattershot",
"weaponId": 40
"weaponId": 40,
"dead": false,
"specialReady": false
}
],
"stageLabel": "Museum d'Alfonsino"

View File

@@ -140,6 +140,8 @@ function teammate(weaponId: MainWeaponId | null, i: number): MinimapTeammate {
name: null,
weaponId,
abilities: [],
dead: false,
specialReady: false,
};
}
@@ -148,6 +150,8 @@ function enemy(weaponId: MainWeaponId | null): MinimapEnemy {
name: null,
weaponId,
abilities: [],
dead: false,
specialReady: false,
};
}
@@ -159,13 +163,23 @@ function minimap(
bravo = BRAVO as (MainWeaponId | null)[],
spectator = true,
teamColors = [null, null] as MinimapData["teamColors"],
dead = [[], []] as [number[], number[]],
specialReady = [[], []] as [number[], number[]],
} = {},
): DetectedEvent {
const data: MinimapData = {
stage,
spectator,
teammates: alpha.map(teammate),
enemies: bravo.map(enemy),
teammates: alpha.map((id, i) => ({
...teammate(id, i),
dead: dead[0].includes(i),
specialReady: specialReady[0].includes(i),
})),
enemies: bravo.map((id, i) => ({
...enemy(id),
dead: dead[1].includes(i),
specialReady: specialReady[1].includes(i),
})),
teamColors,
};
return { type: "Minimap", t, confidence: 0.8, data };
@@ -916,13 +930,18 @@ test("status reads inherit the nearest counter read's cast orientation", () => {
}),
minimap(180),
]);
// the two minimap reads contribute their own (all-clear) samples
const samples = built[0]!.match.playerStatus!.samples;
assert.deepEqual(samples[0]!.dead, [
assert.deepEqual(
samples.map((sample) => sample.t),
[0, 60, 120, 180],
);
assert.deepEqual(samples[1]!.dead, [
[true, false, false, false],
[false, false, false, false],
]);
// the same on-screen left side is now the other team
assert.deepEqual(samples[1]!.dead, [
assert.deepEqual(samples[2]!.dead, [
[false, false, false, false],
[true, false, false, false],
]);
@@ -957,3 +976,66 @@ test("replay wipes drop status reads by the shared clock projection", () => {
[60],
);
});
test("minimap card states become timerless player-status samples", () => {
const built = buildScannerMatches([
minimap(70, { dead: [[2], [0]], specialReady: [[], [3]] }),
minimap(120),
]);
assert.deepEqual(built[0]!.match.playerStatus, {
samples: [
{
t: 70,
time: null,
special: [
[false, false, false, false],
[false, false, false, true],
],
dead: [
[false, false, true, false],
[true, false, false, false],
],
},
{
t: 120,
time: null,
special: ALL_FALSE,
dead: ALL_FALSE,
},
],
});
});
test("a known non-SZ match still gets its minimap-sourced status samples", () => {
const events = [
mapStart(0, { mode: "CB" }),
objective(60),
playerStatus(61),
minimap(90, { spectator: false, dead: [[0], []] }),
scoreboard(300, { mode: "CB" }),
];
const built = buildScannerMatches(events);
assert.equal(built[0]!.match.objective, null);
const samples = built[0]!.match.playerStatus!.samples;
assert.deepEqual(
samples.map((sample) => sample.t),
[90],
);
assert.deepEqual(samples[0]!.dead, [
[true, false, false, false],
[false, false, false, false],
]);
assert.deepEqual(invalidObjectiveEvents(built), [events[1], events[2]]);
});
test("a losing-side pov swaps minimap-sourced samples into teams order", () => {
const built = buildScannerMatches([
minimap(90, { spectator: false, dead: [[0], []] }),
scoreboard(300, { povIndex: 6 }),
]);
const sample = built[0]!.match.playerStatus!.samples[0]!;
assert.deepEqual(sample.dead, [
[false, false, false, false],
[true, false, false, false],
]);
});

View File

@@ -134,6 +134,16 @@ for (const fixture of fixtures) {
`abilities (debug: ${cardDebug})`,
);
}
if (want.dead !== undefined) {
assert.equal(got.dead, want.dead, `dead (debug: ${cardDebug})`);
}
if (want.specialReady !== undefined) {
assert.equal(
got.specialReady,
want.specialReady,
`specialReady (debug: ${cardDebug})`,
);
}
},
);
}
@@ -177,6 +187,16 @@ for (const fixture of fixtures) {
`abilities (debug: ${rowDebug})`,
);
}
if (want.dead !== undefined) {
assert.equal(got.dead, want.dead, `dead (debug: ${rowDebug})`);
}
if (want.specialReady !== undefined) {
assert.equal(
got.specialReady,
want.specialReady,
`specialReady (debug: ${rowDebug})`,
);
}
},
);
}

View File

@@ -60,8 +60,16 @@ test("weapons are padded to 4 slots per team so uneven rosters keep the team spl
name: null,
weaponId: ALPHA[i]!,
abilities: [],
dead: false,
specialReady: false,
})),
enemies: BRAVO.map((weaponId) => ({
name: null,
weaponId,
abilities: [],
dead: false,
specialReady: false,
})),
enemies: BRAVO.map((weaponId) => ({ name: null, weaponId, abilities: [] })),
teamColors: [null, null],
};
const matches = prefilledMatches([

View File

@@ -349,6 +349,7 @@ for (const config of configs) {
names: { ok: 0, total: 0 } as Tally,
abilities: { ok: 0, total: 0 } as Tally,
stage: { ok: 0, total: 0 } as Tally,
status: { ok: 0, total: 0 } as Tally,
};
let charEdits = 0;
let charTotal = 0;
@@ -372,11 +373,15 @@ for (const config of configs) {
name?: string | null;
weaponId?: number | null;
abilities?: (string | null)[];
dead?: boolean;
specialReady?: boolean;
}[],
{
name?: string | null;
weaponId: number | null;
abilities: (string | null)[];
dead: boolean;
specialReady: boolean;
}[],
][] = [
["teammate", expected.teammates ?? [], event.data.teammates],
@@ -415,6 +420,15 @@ for (const config of configs) {
`${fixture.name} ${side}${i}: ability [${slot}] "${gotId}" != "${wantId}"`,
);
});
for (const flag of ["dead", "specialReady"] as const) {
if (want[flag] === undefined) continue;
tally.status.total++;
if (got[flag] === want[flag]) tally.status.ok++;
else
misses.push(
`${fixture.name} ${side}${i}: ${flag} ${got[flag]} != ${want[flag]}`,
);
}
});
}
if (expected.stage !== undefined) {
@@ -433,6 +447,7 @@ for (const config of configs) {
console.info(`names ${pct(tally.names)}`);
console.info(`abilities ${pct(tally.abilities)}`);
console.info(`stage ${pct(tally.stage)}`);
console.info(`status ${pct(tally.status)}`);
console.info(
`name CER ${charTotal ? ((100 * charEdits) / charTotal).toFixed(2) : "n/a"}% (${charEdits} edits / ${charTotal} chars)`,
);