mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
1278 lines
36 KiB
TypeScript
1278 lines
36 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import type {
|
|
AbilityWithUnknown,
|
|
MainWeaponId,
|
|
ModeShort,
|
|
StageId,
|
|
} from "@sendou/in-game-lists/types";
|
|
import type { DeathData } from "@sendou/scanner-core/detectors/death/index";
|
|
import type {
|
|
MinimapData,
|
|
MinimapEnemy,
|
|
MinimapTeammate,
|
|
} from "@sendou/scanner-core/detectors/minimap/index";
|
|
import { SPECTATOR_SLOTS } from "@sendou/scanner-core/detectors/minimap/rois";
|
|
import type { ObjectiveData } from "@sendou/scanner-core/detectors/objective/index";
|
|
import type { PlayerStatusData } from "@sendou/scanner-core/detectors/objective/player-status";
|
|
import type { StripWeaponsData } from "@sendou/scanner-core/detectors/objective/strip-weapons";
|
|
import type { ScoreboardData } from "@sendou/scanner-core/detectors/scoreboard/index";
|
|
import type { ScoreboardBattleLogData } from "@sendou/scanner-core/detectors/scoreboard-battle-log/index";
|
|
import type { ScoreboardBattleLogReplayData } from "@sendou/scanner-core/detectors/scoreboard-battle-log-replay/index";
|
|
import type { DetectedEvent } from "@sendou/scanner-core/detectors/types";
|
|
import {
|
|
buildScannerMatches,
|
|
ingestSkipReasons,
|
|
invalidObjectiveEvents,
|
|
} from "@sendou/scanner-core/match-builder";
|
|
import type { ScannerLobby } from "@sendou/scanner-core/scanner-types";
|
|
import test from "../node-test-compat";
|
|
|
|
const NAMES = ["w1", "w2", "w3", "w4", "l1", "l2", "l3", "l4"];
|
|
const ALPHA: MainWeaponId[] = [40, 1001, 2010, 3030];
|
|
const BRAVO: MainWeaponId[] = [50, 210, 4010, 8000];
|
|
const ALL = [...ALPHA, ...BRAVO];
|
|
|
|
function mapStart(
|
|
t: number,
|
|
{ mode = "SZ" as ModeShort | null, stage = 0 as StageId | null } = {},
|
|
): DetectedEvent {
|
|
return { type: "MapStart", t, confidence: 0.9, data: { mode, stage } };
|
|
}
|
|
|
|
function death(
|
|
t: number,
|
|
name: string,
|
|
abilities: AbilityWithUnknown[][] = [["ISM", "ISS", "ISS", "ISS"]],
|
|
): DetectedEvent {
|
|
const data: DeathData = {
|
|
weaponId: null,
|
|
weaponType: "MAIN",
|
|
abilities,
|
|
name,
|
|
};
|
|
return { type: "Death", t, confidence: 0.9, data };
|
|
}
|
|
|
|
// default timer stays consistent with t (clock zero projected at 300s of
|
|
// footage) so reads register as one live game to the replay filter
|
|
function objective(
|
|
t: number,
|
|
{
|
|
time = (300 - Math.round(t)) as number | null,
|
|
score = [95, 53] as [number | null, number | null],
|
|
penalty = [null, null] as [number | null, number | null],
|
|
control = [true, false] as [boolean, boolean],
|
|
teamColor = [null, null] as ObjectiveData["teamColor"],
|
|
} = {},
|
|
): DetectedEvent {
|
|
const data: ObjectiveData = {
|
|
mode: "SZ",
|
|
time,
|
|
score,
|
|
penalty,
|
|
control,
|
|
teamColor,
|
|
};
|
|
return { type: "Objective", t, confidence: 0.9, data };
|
|
}
|
|
|
|
function scoreboard(
|
|
t: number,
|
|
{
|
|
lobby = "PRIVATE" as ScannerLobby | null,
|
|
mode = "SZ" as ModeShort | null,
|
|
stage = 0 as StageId | null,
|
|
weapons = ALL as (MainWeaponId | null)[],
|
|
povIndex = 0 as number | null,
|
|
matchScores = [100, 47] as [number | null, number | null],
|
|
} = {},
|
|
): DetectedEvent {
|
|
const data: ScoreboardData = {
|
|
lobby,
|
|
mode,
|
|
stage,
|
|
matchScores,
|
|
players: weapons.map((weaponId, i) => ({
|
|
name: NAMES[i] ?? `p${i}`,
|
|
weaponId,
|
|
paint: 1000,
|
|
ka: 10,
|
|
d: 5,
|
|
s: 2,
|
|
})),
|
|
povIndex,
|
|
};
|
|
return { type: "Scoreboard", t, confidence: 0.9, data };
|
|
}
|
|
|
|
function replayScoreboard(
|
|
t: number,
|
|
{
|
|
timestamp = null as string | null,
|
|
replayCode = "RABC-DEFG-HIJK-LMNO" as string | null,
|
|
} = {},
|
|
): DetectedEvent & { detectedAt?: number } {
|
|
const base = scoreboard(t).data as ScoreboardData;
|
|
const data: ScoreboardBattleLogReplayData = {
|
|
...base,
|
|
timestamp,
|
|
replayCode,
|
|
matchScores: [88, 71],
|
|
};
|
|
return { type: "ScoreboardBattleLogReplay", t, confidence: 0.9, data };
|
|
}
|
|
|
|
function battleLogScoreboard(
|
|
t: number,
|
|
{ timestamp = null as string | null } = {},
|
|
): DetectedEvent & { detectedAt?: number } {
|
|
const base = scoreboard(t).data as ScoreboardData;
|
|
const data: ScoreboardBattleLogData = {
|
|
...base,
|
|
timestamp,
|
|
matchScores: [100, 0],
|
|
};
|
|
return { type: "ScoreboardBattleLog", t, confidence: 0.9, data };
|
|
}
|
|
|
|
function teammate(weaponId: MainWeaponId | null, i: number): MinimapTeammate {
|
|
return {
|
|
slot: SPECTATOR_SLOTS[i]!,
|
|
name: null,
|
|
weaponId,
|
|
abilities: [],
|
|
dead: false,
|
|
specialReady: false,
|
|
};
|
|
}
|
|
|
|
function enemy(weaponId: MainWeaponId | null): MinimapEnemy {
|
|
return {
|
|
name: null,
|
|
weaponId,
|
|
abilities: [],
|
|
dead: false,
|
|
specialReady: false,
|
|
};
|
|
}
|
|
|
|
function minimap(
|
|
t: number,
|
|
{
|
|
stage = 0 as StageId | null,
|
|
alpha = ALPHA as (MainWeaponId | null)[],
|
|
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((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 };
|
|
}
|
|
|
|
function weapons(match: {
|
|
teams: [
|
|
{ players: { weaponId: MainWeaponId | null }[] },
|
|
{ players: { weaponId: MainWeaponId | null }[] },
|
|
];
|
|
}): (MainWeaponId | null)[] {
|
|
return match.teams.flatMap((team) =>
|
|
team.players.map((player) => player.weaponId),
|
|
);
|
|
}
|
|
|
|
test("groups map start, deaths and scoreboard into one match", () => {
|
|
const events = [
|
|
mapStart(0),
|
|
death(60, "l2"),
|
|
death(120, "l3"),
|
|
scoreboard(300),
|
|
];
|
|
const built = buildScannerMatches(events);
|
|
assert.equal(built.length, 1);
|
|
assert.deepEqual(
|
|
built[0]!.sources.map((e) => e.type),
|
|
["MapStart", "Death", "Death", "Scoreboard"],
|
|
);
|
|
// sources are the exact input objects
|
|
assert.equal(built[0]!.sources[0], events[0]);
|
|
});
|
|
|
|
test("scoreboard fields land on the match", () => {
|
|
const built = buildScannerMatches([mapStart(0), scoreboard(300)]);
|
|
const match = built[0]!.match;
|
|
assert.equal(match.startsAt, 0);
|
|
assert.equal(match.endsAt, 300);
|
|
assert.equal(match.lobby, "PRIVATE");
|
|
assert.equal(match.mode, "SZ");
|
|
assert.equal(match.stage, 0);
|
|
assert.equal(match.winner, 0);
|
|
assert.deepEqual(match.pov, { team: 0, index: 0 });
|
|
assert.deepEqual(match.matchScores, [100, 47]);
|
|
assert.deepEqual(
|
|
match.teams.map((team) => team.players.map((p) => p.name)),
|
|
[
|
|
["w1", "w2", "w3", "w4"],
|
|
["l1", "l2", "l3", "l4"],
|
|
],
|
|
);
|
|
assert.deepEqual(weapons(match), ALL);
|
|
assert.equal(match.cast, false);
|
|
assert.equal(match.replayCode, null);
|
|
assert.equal(match.objective, null);
|
|
});
|
|
|
|
test("a losing-side pov index maps to the second team", () => {
|
|
const built = buildScannerMatches([scoreboard(300, { povIndex: 6 })]);
|
|
assert.deepEqual(built[0]!.match.pov, { team: 1, index: 2 });
|
|
});
|
|
|
|
test("objective reads become teams-order samples on the match", () => {
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
objective(120.4, { time: 215, penalty: [4, null] }),
|
|
objective(180, { time: 155, score: [80, 53] }),
|
|
scoreboard(300),
|
|
]);
|
|
assert.deepEqual(built[0]!.match.objective, {
|
|
mode: "SZ",
|
|
samples: [
|
|
{
|
|
t: 120,
|
|
time: 215,
|
|
score: [95, 53],
|
|
penalty: [4, null],
|
|
control: [true, false],
|
|
},
|
|
{
|
|
t: 180,
|
|
time: 155,
|
|
score: [80, 53],
|
|
penalty: [null, null],
|
|
control: [true, false],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("a losing-side pov swaps objective samples into teams order", () => {
|
|
const built = buildScannerMatches([
|
|
objective(120, { penalty: [4, null] }),
|
|
scoreboard(300, { povIndex: 6 }),
|
|
]);
|
|
assert.deepEqual(built[0]!.match.objective!.samples[0], {
|
|
t: 120,
|
|
time: 180,
|
|
score: [53, 95],
|
|
penalty: [null, 4],
|
|
control: [false, true],
|
|
});
|
|
});
|
|
|
|
test("a known non-SZ match drops its objective reads", () => {
|
|
const events = [
|
|
mapStart(0, { mode: "CB" }),
|
|
objective(60),
|
|
objective(120),
|
|
scoreboard(300, { mode: "CB" }),
|
|
];
|
|
const built = buildScannerMatches(events);
|
|
assert.equal(built[0]!.match.objective, null);
|
|
assert.deepEqual(invalidObjectiveEvents(built), [events[1], events[2]]);
|
|
});
|
|
|
|
test("an unknown-mode match keeps its objective reads", () => {
|
|
const built = buildScannerMatches([
|
|
mapStart(0, { mode: null }),
|
|
objective(60),
|
|
scoreboard(300, { mode: null }),
|
|
]);
|
|
assert.equal(built[0]!.match.objective!.samples.length, 1);
|
|
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),
|
|
objective(60, { score: [95, 53] }),
|
|
objective(200, { score: [60, 20] }),
|
|
scoreboard(300, { povIndex: null }),
|
|
]);
|
|
assert.deepEqual(
|
|
built[0]!.match.objective!.samples.map((sample) => sample.score),
|
|
[
|
|
[53, 95],
|
|
[20, 60],
|
|
],
|
|
);
|
|
});
|
|
|
|
test("a transient score dip is voided against the surrounding countdown", () => {
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
objective(60, { score: [54, 100] }),
|
|
// truncated misread of "50" — only the trailing 0 was read
|
|
objective(61, { score: [0, 100], penalty: [4, null] }),
|
|
objective(62, { score: [49, 100] }),
|
|
objective(63, { score: [47, 100] }),
|
|
scoreboard(300),
|
|
]);
|
|
const samples = built[0]!.match.objective!.samples;
|
|
assert.deepEqual(
|
|
samples.map((sample) => sample.score),
|
|
[
|
|
[54, 100],
|
|
[null, 100],
|
|
[49, 100],
|
|
[47, 100],
|
|
],
|
|
);
|
|
assert.deepEqual(samples[1]!.penalty, [4, null]);
|
|
});
|
|
|
|
test("post-game replay wipes are dropped by their clock projection", () => {
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
objective(60, { score: [80, 6], penalty: [null, 56] }),
|
|
objective(61, { score: [78, 6], penalty: [null, 56] }),
|
|
objective(62, { score: [77, 6], penalty: [null, 56] }),
|
|
// broadcast re-runs the opening moments, clock jumped back to 4:51
|
|
objective(90, { time: 291, score: [100, 100] }),
|
|
objective(91, { time: 290, score: [99, 100] }),
|
|
// then the closing moments again
|
|
objective(100, { time: 62, score: [6, 77], penalty: [56, null] }),
|
|
objective(101, { time: 61, score: [6, 75], penalty: [56, null] }),
|
|
scoreboard(300),
|
|
]);
|
|
const samples = built[0]!.match.objective!.samples;
|
|
assert.deepEqual(
|
|
samples.map((sample) => sample.t),
|
|
[60, 61, 62],
|
|
);
|
|
assert.deepEqual(samples.at(-1)!.penalty, [null, 56]);
|
|
});
|
|
|
|
test("timerless reads share their live neighbor's replay-filter fate", () => {
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
// timerless head inherits from the first anchored read
|
|
objective(59, { time: null, score: [82, 6] }),
|
|
objective(60, { score: [80, 6] }),
|
|
objective(61, { score: [79, 6] }),
|
|
objective(62, { time: null, score: [78, 6] }),
|
|
// replay wipe, including a timerless read inside it
|
|
objective(90, { time: 291, score: [100, 100] }),
|
|
objective(91, { time: null, score: [99, 100] }),
|
|
objective(92, { time: 289, score: [97, 100] }),
|
|
scoreboard(300),
|
|
]);
|
|
assert.deepEqual(
|
|
built[0]!.match.objective!.samples.map((sample) => sample.t),
|
|
[59, 60, 61, 62],
|
|
);
|
|
});
|
|
|
|
test("a stray full-count blip is voided against the surrounding countdown", () => {
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
objective(60, { score: [80, 100] }),
|
|
objective(61, { score: [100, 100] }),
|
|
objective(62, { score: [75, 100] }),
|
|
objective(63, { score: [73, 100] }),
|
|
scoreboard(300),
|
|
]);
|
|
assert.deepEqual(
|
|
built[0]!.match.objective!.samples.map((sample) => sample.score),
|
|
[
|
|
[80, 100],
|
|
[null, 100],
|
|
[75, 100],
|
|
[73, 100],
|
|
],
|
|
);
|
|
});
|
|
|
|
test("enriches players with abilities from the match's deaths", () => {
|
|
const build: AbilityWithUnknown[][] = [
|
|
["ISM", "ISS", "ISS", "ISS"],
|
|
["QR", "QSJ", "QSJ", "QSJ"],
|
|
["SSU", "RSU", "RSU", "RSU"],
|
|
];
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
death(60, "l2", build),
|
|
scoreboard(300),
|
|
]);
|
|
const teams = built[0]!.match.teams;
|
|
assert.deepEqual(teams[1].players[1]!.abilities, build);
|
|
assert.equal(teams[0].players[0]!.abilities, undefined);
|
|
});
|
|
|
|
test("deaths from an earlier match do not leak into the next match", () => {
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
death(60, "l2"),
|
|
scoreboard(300),
|
|
mapStart(400),
|
|
scoreboard(700),
|
|
]);
|
|
assert.equal(built.length, 2);
|
|
assert.equal(built[1]!.match.teams[1].players[1]!.abilities, undefined);
|
|
});
|
|
|
|
test("a scoreboard without a preceding map start claims the last 8 minutes of deaths", () => {
|
|
const built = buildScannerMatches([death(60, "l2"), scoreboard(300)]);
|
|
assert.equal(built.length, 1);
|
|
assert.deepEqual(
|
|
built[0]!.sources.map((e) => e.type),
|
|
["Death", "Scoreboard"],
|
|
);
|
|
assert.notEqual(built[0]!.match.teams[1].players[1]!.abilities, undefined);
|
|
});
|
|
|
|
test("deaths older than 8 minutes do not join a map-start-less match", () => {
|
|
const built = buildScannerMatches([
|
|
death(60, "l2"),
|
|
death(700, "l3"),
|
|
scoreboard(1000),
|
|
]);
|
|
assert.equal(built.length, 1);
|
|
assert.deepEqual(
|
|
built[0]!.sources.map((e) => e.t),
|
|
[700, 1000],
|
|
);
|
|
});
|
|
|
|
test("every event belongs to at most one match", () => {
|
|
const events = [
|
|
death(10, "l2"), // orphan invalidated by the map start
|
|
mapStart(30),
|
|
death(60, "l3"),
|
|
minimap(90),
|
|
scoreboard(300),
|
|
death(320, "l4"), // orphan claimed by the next scoreboard
|
|
scoreboard(700),
|
|
minimap(800),
|
|
minimap(1200), // gap-splits into its own match
|
|
scoreboard(1300),
|
|
];
|
|
const built = buildScannerMatches(events);
|
|
assert.equal(built.length, 4);
|
|
|
|
const seen = new Set<DetectedEvent>();
|
|
for (const b of built) {
|
|
for (const source of b.sources) {
|
|
assert.equal(seen.has(source), false);
|
|
seen.add(source);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("the fallback window does not reach past the previous scoreboard", () => {
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
death(60, "l2"),
|
|
scoreboard(300),
|
|
death(400, "l3"),
|
|
scoreboard(700),
|
|
]);
|
|
assert.equal(built.length, 2);
|
|
assert.deepEqual(
|
|
built[1]!.sources.map((e) => e.t),
|
|
[400, 700],
|
|
);
|
|
});
|
|
|
|
test("non-private lobbies are recorded and skipped on ingest", () => {
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
scoreboard(300, { lobby: "X" }),
|
|
mapStart(400),
|
|
scoreboard(700),
|
|
]);
|
|
const skipped = ingestSkipReasons(built);
|
|
assert.equal(built.length, 2);
|
|
assert.equal(built[0]!.match.lobby, "X");
|
|
assert.equal(skipped.get(built[0]!), "lobby");
|
|
assert.equal(skipped.get(built[1]!), undefined);
|
|
});
|
|
|
|
test("a scoreless match whose counters had no time to run out is a disconnect", () => {
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
objective(140, { time: 183, score: [10, 43], penalty: [69, 0] }),
|
|
scoreboard(155, { matchScores: [null, null] }),
|
|
]);
|
|
assert.equal(built.length, 1);
|
|
assert.equal(ingestSkipReasons(built).get(built[0]!), "disconnect");
|
|
});
|
|
|
|
test("a scoreless match a knockout could have ended is kept", () => {
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
objective(140, { time: 30, score: [5, 90], penalty: [0, 0] }),
|
|
scoreboard(200, { matchScores: [null, null] }),
|
|
]);
|
|
assert.equal(built.length, 1);
|
|
assert.equal(ingestSkipReasons(built).size, 0);
|
|
});
|
|
|
|
test("a scoreless match replayed on the same map is a disconnect", () => {
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
scoreboard(300, { matchScores: [null, null] }),
|
|
mapStart(400),
|
|
scoreboard(700),
|
|
]);
|
|
const skipped = ingestSkipReasons(built);
|
|
assert.equal(built.length, 2);
|
|
assert.equal(skipped.get(built[0]!), "disconnect");
|
|
assert.equal(skipped.get(built[1]!), undefined);
|
|
});
|
|
|
|
test("a scoreless match the next map moves on from is kept", () => {
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
scoreboard(300, { matchScores: [null, null] }),
|
|
mapStart(400, { stage: 1 }),
|
|
scoreboard(700, { stage: 1 }),
|
|
]);
|
|
assert.equal(built.length, 2);
|
|
assert.equal(ingestSkipReasons(built).size, 0);
|
|
});
|
|
|
|
test("an unreadable lobby is ingestable", () => {
|
|
const built = buildScannerMatches([scoreboard(300, { lobby: null })]);
|
|
assert.equal(built.length, 1);
|
|
assert.equal(ingestSkipReasons(built).size, 0);
|
|
});
|
|
|
|
test("a match whose scoreboard was missed is dropped on the next map start", () => {
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
death(60, "l2"),
|
|
mapStart(400),
|
|
death(460, "l3"),
|
|
scoreboard(700),
|
|
]);
|
|
assert.equal(built.length, 1);
|
|
assert.deepEqual(
|
|
built[0]!.sources.map((e) => e.t),
|
|
[400, 460, 700],
|
|
);
|
|
});
|
|
|
|
test("a trailing map start with deaths but no scoreboard identifies no match", () => {
|
|
assert.deepEqual(buildScannerMatches([mapStart(0), death(60, "l2")]), []);
|
|
});
|
|
|
|
test("event types that identify no match are ignored", () => {
|
|
const own: DetectedEvent = {
|
|
type: "ScoreboardOwn",
|
|
t: 310,
|
|
confidence: 0.9,
|
|
data: {
|
|
lobby: "PRIVATE",
|
|
mode: null,
|
|
stage: null,
|
|
weaponId: null,
|
|
abilities: [],
|
|
},
|
|
};
|
|
const built = buildScannerMatches([mapStart(0), own, scoreboard(300), own]);
|
|
assert.equal(built.length, 1);
|
|
assert.deepEqual(
|
|
built[0]!.sources.map((e) => e.type),
|
|
["MapStart", "Scoreboard"],
|
|
);
|
|
});
|
|
|
|
test("a replay scoreboard supplies replay code, set score and recording time", () => {
|
|
const event = replayScoreboard(300, { timestamp: "25.12.2025 21:30" });
|
|
event.detectedAt = Date.UTC(2025, 11, 26, 12, 0);
|
|
const built = buildScannerMatches([event]);
|
|
const match = built[0]!.match;
|
|
assert.equal(match.replayCode, "RABC-DEFG-HIJK-LMNO");
|
|
assert.deepEqual(match.matchScores, [88, 71]);
|
|
assert.equal(match.playedAt, new Date(2025, 11, 25, 21, 30).getTime());
|
|
});
|
|
|
|
test("a battle log scoreboard closes a match and supplies the recording time without a replay code", () => {
|
|
const event = battleLogScoreboard(300, { timestamp: "25.12.2025 21:30" });
|
|
event.detectedAt = Date.UTC(2025, 11, 26, 12, 0);
|
|
const built = buildScannerMatches([event]);
|
|
const match = built[0]!.match;
|
|
assert.equal(match.replayCode, null);
|
|
assert.deepEqual(match.matchScores, [100, 0]);
|
|
assert.equal(match.playedAt, new Date(2025, 11, 25, 21, 30).getTime());
|
|
});
|
|
|
|
test("without a replay timestamp, playedAt falls back to the scoreboard's detection time", () => {
|
|
const event = scoreboard(300) as DetectedEvent & { detectedAt?: number };
|
|
event.detectedAt = 1_700_000_000_000;
|
|
const built = buildScannerMatches([event]);
|
|
assert.equal(built[0]!.match.playedAt, 1_700_000_000_000);
|
|
});
|
|
|
|
test("a minimap-only match has no playedAt and no winner", () => {
|
|
const built = buildScannerMatches([minimap(70), minimap(120)]);
|
|
const match = built[0]!.match;
|
|
assert.equal(match.playedAt, null);
|
|
assert.equal(match.winner, null);
|
|
assert.equal(match.pov, null);
|
|
assert.equal(match.matchScores, null);
|
|
});
|
|
|
|
test("a spectator map's minimaps become one cast match: weapons + stage from the minimap, mode unread", () => {
|
|
const built = buildScannerMatches([minimap(70), minimap(120)]);
|
|
assert.equal(built.length, 1);
|
|
const match = built[0]!.match;
|
|
assert.equal(match.startsAt, 70);
|
|
assert.equal(match.endsAt, 120);
|
|
assert.equal(match.mode, null);
|
|
assert.equal(match.stage, 0);
|
|
assert.equal(match.cast, true);
|
|
assert.deepEqual(weapons(match), ALL);
|
|
});
|
|
|
|
test("a pov overlay minimap is not flagged as cast", () => {
|
|
const built = buildScannerMatches([minimap(70, { spectator: false })]);
|
|
assert.equal(built[0]!.match.cast, false);
|
|
});
|
|
|
|
test("a lone misread stage neither splits the match nor poisons its stage", () => {
|
|
const built = buildScannerMatches([
|
|
minimap(70, { stage: 0 }),
|
|
minimap(90, { stage: 1 }),
|
|
minimap(110, { stage: 0 }),
|
|
minimap(130, { stage: 0 }),
|
|
]);
|
|
assert.equal(built.length, 1);
|
|
assert.equal(built[0]!.match.stage, 0);
|
|
});
|
|
|
|
test("a confirmed stage change splits even when the misread-looking frame is mid-stream", () => {
|
|
const built = buildScannerMatches([
|
|
minimap(70, { stage: 0 }),
|
|
minimap(90, { stage: 1 }),
|
|
minimap(110, { stage: 1 }),
|
|
]);
|
|
assert.equal(built.length, 2);
|
|
assert.deepEqual(
|
|
built.map((b) => b.match.stage),
|
|
[0, 1],
|
|
);
|
|
assert.deepEqual(
|
|
built.map((b) => b.match.startsAt),
|
|
[70, 90],
|
|
);
|
|
});
|
|
|
|
// KNOWN LIMITATION (documented, not desired): two consecutive games on the
|
|
// SAME stage with a between-games break shorter than MATCH_GAP_SECONDS merge
|
|
// into one match — no native UI delimits them on casted footage and the
|
|
// simplified minimap carries no signal to split on. Real mode/game detection
|
|
// should replace this.
|
|
test("same-stage rematch within the gap window merges into one match (known limitation)", () => {
|
|
const game1 = [minimap(70), minimap(150)];
|
|
const game2 = [minimap(380), minimap(460)];
|
|
assert.equal(buildScannerMatches([...game1, ...game2]).length, 1);
|
|
});
|
|
|
|
test("a stage change splits minimaps into separate per-map matches", () => {
|
|
const built = buildScannerMatches([
|
|
minimap(70, { stage: 0 }),
|
|
minimap(120, { stage: 0 }),
|
|
minimap(400, { stage: 1 }),
|
|
]);
|
|
assert.equal(built.length, 2);
|
|
assert.deepEqual(
|
|
built.map((b) => b.match.stage),
|
|
[0, 1],
|
|
);
|
|
assert.deepEqual(
|
|
built.map((b) => b.match.startsAt),
|
|
[70, 400],
|
|
);
|
|
});
|
|
|
|
test("a large time gap splits even same-stage minimaps (different games)", () => {
|
|
const built = buildScannerMatches([minimap(70), minimap(90), minimap(600)]);
|
|
assert.equal(built.length, 2);
|
|
assert.deepEqual(
|
|
built.map((b) => b.match.startsAt),
|
|
[70, 600],
|
|
);
|
|
});
|
|
|
|
test("minimaps of one game (close in time, same stage) stay one match", () => {
|
|
const built = buildScannerMatches([minimap(70), minimap(90), minimap(250)]);
|
|
assert.equal(built.length, 1);
|
|
assert.equal(built[0]!.match.startsAt, 70);
|
|
});
|
|
|
|
test("weapon slots are merged across a match's minimap frames", () => {
|
|
const frame1 = minimap(70, { alpha: [null, 1001, null, 3030] });
|
|
const frame2 = minimap(90, { alpha: [40, null, 2010, 3030] });
|
|
const built = buildScannerMatches([frame1, frame2]);
|
|
assert.deepEqual(weapons(built[0]!.match), ALL);
|
|
});
|
|
|
|
test("a slot no frame read stays null for consumers to skip on", () => {
|
|
const built = buildScannerMatches([
|
|
minimap(70, { alpha: [40, 1001, 2010, null] }),
|
|
]);
|
|
assert.deepEqual(weapons(built[0]!.match), [40, 1001, 2010, null, ...BRAVO]);
|
|
});
|
|
|
|
test("a MapStart supplies the real mode and opens a match", () => {
|
|
const built = buildScannerMatches([
|
|
mapStart(30, { mode: "RM", stage: 6 }),
|
|
minimap(70, { stage: 6 }),
|
|
]);
|
|
assert.equal(built.length, 1);
|
|
assert.equal(built[0]!.match.mode, "RM");
|
|
assert.equal(built[0]!.match.startsAt, 30);
|
|
});
|
|
|
|
test("a scoreboard is the preferred weapon/mode source and closes a match", () => {
|
|
const boardWeapons: (MainWeaponId | null)[] = [
|
|
10, 10, 10, 10, 20, 20, 20, 20,
|
|
];
|
|
const built = buildScannerMatches([
|
|
minimap(70),
|
|
scoreboard(330, { mode: "TC", weapons: boardWeapons }),
|
|
]);
|
|
assert.equal(built.length, 1);
|
|
assert.equal(built[0]!.match.mode, "TC");
|
|
assert.deepEqual(weapons(built[0]!.match), boardWeapons);
|
|
assert.equal(built[0]!.match.startsAt, 70);
|
|
});
|
|
|
|
test("no minimaps and no scoreboard means no match", () => {
|
|
assert.deepEqual(buildScannerMatches([mapStart(30), mapStart(400)]), []);
|
|
});
|
|
|
|
// ---- player-status samples ----
|
|
|
|
const ALL_FALSE = [
|
|
[false, false, false, false],
|
|
[false, false, false, false],
|
|
] as PlayerStatusData["special"];
|
|
|
|
function playerStatus(
|
|
t: number,
|
|
{
|
|
time = (300 - Math.round(t)) as number | null,
|
|
special = ALL_FALSE,
|
|
dead = ALL_FALSE,
|
|
layout = "pov" as PlayerStatusData["layout"],
|
|
castProven = false,
|
|
} = {},
|
|
): DetectedEvent {
|
|
const data: PlayerStatusData = { time, special, dead, layout, castProven };
|
|
return { type: "PlayerStatus", t, confidence: 0.9, data };
|
|
}
|
|
|
|
function stripWeaponsEvent(
|
|
t: number,
|
|
slots: [(MainWeaponId | null)[], (MainWeaponId | null)[]],
|
|
{ score = 0.6, time = (300 - Math.round(t)) as number | null } = {},
|
|
): DetectedEvent {
|
|
const data: StripWeaponsData = {
|
|
time,
|
|
layout: "cast",
|
|
slots: slots.map((side) =>
|
|
side.map((weaponId) =>
|
|
weaponId === null ? null : [{ weaponId, score }],
|
|
),
|
|
) as StripWeaponsData["slots"],
|
|
};
|
|
return { type: "StripWeapons", t, confidence: score, data };
|
|
}
|
|
|
|
test("player-status reads become teams-order samples on the match", () => {
|
|
const special = [
|
|
[true, false, false, false],
|
|
[false, false, false, false],
|
|
] as PlayerStatusData["special"];
|
|
const dead = [
|
|
[false, false, false, false],
|
|
[false, false, true, false],
|
|
] as PlayerStatusData["dead"];
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
playerStatus(120, { special, dead }),
|
|
scoreboard(300),
|
|
]);
|
|
assert.deepEqual(built[0]!.match.playerStatus, {
|
|
samples: [{ t: 120, time: 180, special, dead }],
|
|
});
|
|
});
|
|
|
|
test("a losing-side pov swaps player-status samples into teams order", () => {
|
|
const built = buildScannerMatches([
|
|
playerStatus(120, {
|
|
special: [
|
|
[true, false, false, false],
|
|
[false, false, false, false],
|
|
],
|
|
dead: [
|
|
[false, false, false, false],
|
|
[false, true, false, false],
|
|
],
|
|
}),
|
|
scoreboard(300, { povIndex: 5 }),
|
|
]);
|
|
const sample = built[0]!.match.playerStatus!.samples[0]!;
|
|
assert.deepEqual(sample.special, [
|
|
[false, false, false, false],
|
|
[true, false, false, false],
|
|
]);
|
|
assert.deepEqual(sample.dead, [
|
|
[false, true, false, false],
|
|
[false, false, false, false],
|
|
]);
|
|
});
|
|
|
|
test("status reads inherit the nearest counter read's cast orientation", () => {
|
|
const built = buildScannerMatches([
|
|
minimap(0, { teamColors: [GREEN_INK, PURPLE_INK] }),
|
|
objective(60, {
|
|
score: [80, 90],
|
|
teamColor: [GREEN_INK, PURPLE_INK],
|
|
}),
|
|
playerStatus(60, {
|
|
dead: [
|
|
[true, false, false, false],
|
|
[false, false, false, false],
|
|
],
|
|
layout: "cast",
|
|
}),
|
|
// the caster specs a purple player: sides swap
|
|
objective(120, {
|
|
score: [90, 75],
|
|
teamColor: [PURPLE_INK, GREEN_INK],
|
|
}),
|
|
playerStatus(120, {
|
|
dead: [
|
|
[true, false, false, false],
|
|
[false, false, false, false],
|
|
],
|
|
layout: "cast",
|
|
}),
|
|
minimap(180),
|
|
]);
|
|
// the two minimap reads contribute their own (all-clear) samples
|
|
const samples = built[0]!.match.playerStatus!.samples;
|
|
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[2]!.dead, [
|
|
[false, false, false, false],
|
|
[true, false, false, false],
|
|
]);
|
|
assert.equal(built[0]!.match.cast, true);
|
|
});
|
|
|
|
test("sub-2s dead-flag blips between dense opposite reads get flipped", () => {
|
|
const deadAt = (slots: number[]) =>
|
|
[
|
|
[false, false, false, false],
|
|
[0, 1, 2, 3].map((slot) => slots.includes(slot)),
|
|
] as PlayerStatusData["dead"];
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
// slot0: a real death 101-107 with a one-read false "respawn" at 104
|
|
// (background ink bleeding through the crossed-out icon), plus a
|
|
// one-read false death at 111 after the real respawn
|
|
playerStatus(100, { dead: deadAt([]) }),
|
|
playerStatus(101, { dead: deadAt([0]) }),
|
|
playerStatus(102, { dead: deadAt([0]) }),
|
|
playerStatus(103, { dead: deadAt([0]) }),
|
|
playerStatus(104, { dead: deadAt([]) }),
|
|
playerStatus(105, { dead: deadAt([0]) }),
|
|
playerStatus(106, { dead: deadAt([0]) }),
|
|
playerStatus(107, { dead: deadAt([0]) }),
|
|
playerStatus(108, { dead: deadAt([]) }),
|
|
playerStatus(109, { dead: deadAt([]) }),
|
|
playerStatus(110, { dead: deadAt([]) }),
|
|
playerStatus(111, { dead: deadAt([0]) }),
|
|
playerStatus(112, { dead: deadAt([]) }),
|
|
playerStatus(113, { dead: deadAt([]) }),
|
|
scoreboard(300),
|
|
]);
|
|
const slot0Deads = built[0]!.match.playerStatus!.samples.map(
|
|
(sample) => sample.dead[1][0],
|
|
);
|
|
assert.deepEqual(slot0Deads, [
|
|
false,
|
|
...Array.from({ length: 7 }, () => true),
|
|
...Array.from({ length: 6 }, () => false),
|
|
]);
|
|
});
|
|
|
|
test("a lone dead read between sparse reads is kept", () => {
|
|
const dead = [
|
|
[false, false, false, false],
|
|
[true, false, false, false],
|
|
] as PlayerStatusData["dead"];
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
playerStatus(60),
|
|
playerStatus(120, { dead }),
|
|
playerStatus(180),
|
|
scoreboard(300),
|
|
]);
|
|
assert.deepEqual(built[0]!.match.playerStatus!.samples[1]!.dead, dead);
|
|
});
|
|
|
|
test("a sub-10s not-ready gap between ready reads with no death bridges to ready", () => {
|
|
const specialAt = (on: boolean) =>
|
|
[
|
|
[on, false, false, false],
|
|
[false, false, false, false],
|
|
] as PlayerStatusData["special"];
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
playerStatus(100, { special: specialAt(true) }),
|
|
playerStatus(102, { special: specialAt(false) }),
|
|
playerStatus(104, { special: specialAt(false) }),
|
|
playerStatus(106, { special: specialAt(true) }),
|
|
playerStatus(108, { special: specialAt(false) }),
|
|
scoreboard(300),
|
|
]);
|
|
const slot0Specials = built[0]!.match.playerStatus!.samples.map(
|
|
(sample) => sample.special[0][0],
|
|
);
|
|
// the interior gap bridges; the trailing not-ready run is an edge and stays
|
|
assert.deepEqual(slot0Specials, [true, true, true, true, false]);
|
|
});
|
|
|
|
test("a not-ready gap explained by a death inside it is kept", () => {
|
|
const read = (special: boolean, dead: boolean) => ({
|
|
special: [
|
|
[special, false, false, false],
|
|
[false, false, false, false],
|
|
] as PlayerStatusData["special"],
|
|
dead: [
|
|
[dead, false, false, false],
|
|
[false, false, false, false],
|
|
] as PlayerStatusData["dead"],
|
|
});
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
playerStatus(100, read(true, false)),
|
|
playerStatus(102, read(false, true)),
|
|
playerStatus(106, read(false, false)),
|
|
playerStatus(108, read(true, false)),
|
|
scoreboard(300),
|
|
]);
|
|
const slot0Specials = built[0]!.match.playerStatus!.samples.map(
|
|
(sample) => sample.special[0][0],
|
|
);
|
|
assert.deepEqual(slot0Specials, [true, false, false, true]);
|
|
});
|
|
|
|
test("a not-ready gap wide enough to regain a special is kept", () => {
|
|
const specialAt = (on: boolean) =>
|
|
[
|
|
[on, false, false, false],
|
|
[false, false, false, false],
|
|
] as PlayerStatusData["special"];
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
playerStatus(100, { special: specialAt(true) }),
|
|
playerStatus(102, { special: specialAt(false) }),
|
|
playerStatus(112, { special: specialAt(false) }),
|
|
playerStatus(114, { special: specialAt(true) }),
|
|
scoreboard(300),
|
|
]);
|
|
const slot0Specials = built[0]!.match.playerStatus!.samples.map(
|
|
(sample) => sample.special[0][0],
|
|
);
|
|
assert.deepEqual(slot0Specials, [true, false, false, true]);
|
|
});
|
|
|
|
test("a known non-SZ match drops its player-status reads too", () => {
|
|
const events = [
|
|
mapStart(0, { mode: "CB" }),
|
|
objective(60),
|
|
playerStatus(61),
|
|
scoreboard(300, { mode: "CB" }),
|
|
];
|
|
const built = buildScannerMatches(events);
|
|
assert.equal(built[0]!.match.playerStatus, null);
|
|
assert.deepEqual(invalidObjectiveEvents(built), [events[1], events[2]]);
|
|
});
|
|
|
|
test("replay wipes drop status reads by the shared clock projection", () => {
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
objective(60, { score: [80, 6] }),
|
|
playerStatus(60),
|
|
objective(61, { score: [78, 6] }),
|
|
// broadcast re-runs the opening moments, clock jumped back
|
|
playerStatus(90, { time: 291 }),
|
|
objective(91, { time: 290, score: [99, 100] }),
|
|
scoreboard(300),
|
|
]);
|
|
assert.deepEqual(
|
|
built[0]!.match.playerStatus!.samples.map((sample) => sample.t),
|
|
[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],
|
|
]);
|
|
});
|
|
|
|
// ---- strip-slot → scoreboard-row assignment ----
|
|
|
|
test("strip weapon evidence reorders status slots into scoreboard rows", () => {
|
|
// strip seating [2010, 40, 3030, 1001] vs scoreboard rows ALPHA
|
|
// [40, 1001, 2010, 3030]: slot0 belongs to row2
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
playerStatus(120, {
|
|
dead: [
|
|
[true, false, false, false],
|
|
[false, false, false, false],
|
|
],
|
|
}),
|
|
stripWeaponsEvent(121, [
|
|
[2010, 40, 3030, 1001],
|
|
[null, null, null, null],
|
|
]),
|
|
scoreboard(300),
|
|
]);
|
|
const sample = built[0]!.match.playerStatus!.samples[0]!;
|
|
assert.deepEqual(sample.dead, [
|
|
[false, false, true, false],
|
|
[false, false, false, false],
|
|
]);
|
|
});
|
|
|
|
test("weapon evidence below the assignment floor keeps the as-drawn order", () => {
|
|
const dead = [
|
|
[true, false, false, false],
|
|
[false, false, false, false],
|
|
] as PlayerStatusData["dead"];
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
playerStatus(120, { dead }),
|
|
stripWeaponsEvent(
|
|
121,
|
|
[
|
|
[2010, null, null, null],
|
|
[null, null, null, null],
|
|
],
|
|
{
|
|
score: 0.5,
|
|
},
|
|
),
|
|
scoreboard(300),
|
|
]);
|
|
assert.deepEqual(built[0]!.match.playerStatus!.samples[0]!.dead, dead);
|
|
});
|
|
|
|
test("minimap enemy-card weapons vote the strip assignment too", () => {
|
|
// enemy cards in strip seating [4010, 50, 8000, 210] vs rows BRAVO
|
|
// [50, 210, 4010, 8000]: the strip-sourced side1 slot0 belongs to row2
|
|
const seating: (MainWeaponId | null)[] = [4010, 50, 8000, 210];
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
minimap(60, { bravo: seating }),
|
|
minimap(90, { bravo: seating }),
|
|
playerStatus(120, {
|
|
dead: [
|
|
[false, false, false, false],
|
|
[true, false, false, false],
|
|
],
|
|
}),
|
|
scoreboard(300),
|
|
]);
|
|
const strip = built[0]!.match.playerStatus!.samples.at(-1)!;
|
|
assert.deepEqual(strip.dead, [
|
|
[false, false, false, false],
|
|
[false, false, true, false],
|
|
]);
|
|
});
|
|
|
|
test("pov diamond cards map to scoreboard rows by name", () => {
|
|
const cards = [
|
|
{ ...teammate(ALPHA[1]!, 0), name: "w2", dead: true },
|
|
{ ...teammate(ALPHA[0]!, 1), name: "w1" },
|
|
{ ...teammate(ALPHA[3]!, 2), name: "w4" },
|
|
{ ...teammate(ALPHA[2]!, 3), name: "w3" },
|
|
];
|
|
const data: MinimapData = {
|
|
stage: 0 as StageId,
|
|
spectator: false,
|
|
teammates: cards,
|
|
enemies: BRAVO.map((id) => enemy(id)),
|
|
teamColors: [null, null],
|
|
};
|
|
const built = buildScannerMatches([
|
|
mapStart(0),
|
|
{ type: "Minimap", t: 90, confidence: 0.8, data } as DetectedEvent,
|
|
scoreboard(300),
|
|
]);
|
|
const sample = built[0]!.match.playerStatus!.samples[0]!;
|
|
assert.deepEqual(sample.dead, [
|
|
[false, true, false, false],
|
|
[false, false, false, false],
|
|
]);
|
|
});
|