mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-28 06:08:13 -05:00
Dead/special from minimap
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)[] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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})`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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)`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user