Fix scanner counting battle log revisits and X Battle real names as new games

This commit is contained in:
Kalle
2026-10-18 10:56:59 +03:00
parent 160a275d26
commit 38c9aa38de
15 changed files with 375 additions and 77 deletions

View File

@@ -300,9 +300,16 @@ sequenceDiagram
match it opened splits only once no later minimap of that game reads it. A battle history screen (battle log, replay
browser) showing a game already built — at least 6 paint totals shared
order-free (a misread or unread row still joins), stage not contradicting
it, recording time within 20 min of its play time — joins that match's sources instead of forming a new
it, and either 6 whole stat lines (paint, K/A, D, S) shared or the recording time within 20 min of its play time (the console clock can be hours off the PC's: another timezone, a misread AM/PM) — joins that match's sources instead of forming a new
one, so browsing the log after playing neither adds a card nor re-uploads
(the match was already sent). The recording time's AM/PM letter reads as
(the match was already sent). X Battle shows every player under a random
alias (a gear name) until the log is asked for the real names: a history
screen whose rows pair with the results screen's by stat line under other
names puts its names on the match (and on the POV's kills), and the
timeline keeps it apart from the alias read of the same entry
(`renamedRows`). Only a results screen's names count as aliases, so a match
a history screen closed keeps its first names; the upload is not redone, so
sendou.ink keeps the aliases. The recording time's AM/PM letter reads as
"o" either way, so the header tells them apart by the letter's bottom rows
(A stands on two legs, P on its stem): a flipped one puts the time 12h off
and the screen turns into a duplicate card. Any other history screen closes the game

View File

@@ -16,8 +16,8 @@ import * as CoachEvents from "../core/CoachEvents";
import { WEAPON_KITS } from "../core/detectors/scoreboard/kits";
import { formatClock } from "../core/format";
import type { ScannerMatch } from "../core/scanner-match";
import { victimSlot } from "../core/scanner-match";
import styles from "./CoachLifeCard.module.css";
import { victimSlot } from "./game-timeline-view";
const SHOWN_S = 5;
/** kept mounted past the fade, as the video's time updates only a few times a second */

View File

@@ -223,7 +223,11 @@ export function SessionView({
}
>
{groups.map(({ group, matches }) => (
<SendouTab key={group} id={group} number={matches.length}>
<SendouTab
key={group}
id={group}
number={matches.filter(isShown).length}
>
{LOBBY_GROUP_LABELS[group]}
</SendouTab>
))}

View File

@@ -2,17 +2,7 @@
import type { ComponentProps } from "react";
import type { GameTimeline } from "~/components/GameTimeline";
import type { PlayerStatusTimelineTeam } from "~/components/PlayerStatusTimeline";
import type { ScannerMatch, ScannerMatchPlayer } from "../core/scanner-match";
import { rankBy } from "../core/text";
/**
* A kill-feed name pins its kill on the enemy row it reads exactly (after
* matchKey), else on the row it resembles clearly more than any other: OCR of
* the feed's font garbles symbols the scoreboard reads fine, so a loose best
* match stands when the other names are far off.
*/
const VICTIM_NAME_MIN_SIMILARITY = 0.4;
const VICTIM_NAME_MIN_MARGIN = 0.25;
import { type ScannerMatch, victimSlot } from "../core/scanner-match";
const PLAYER_SLOTS = [0, 1, 2, 3] as const;
@@ -101,21 +91,3 @@ export function gameTimelineProps(
: undefined,
};
}
/** The enemy slot a kill-feed name belongs to; null when no row clearly matches. */
export function victimSlot(
name: string | null,
enemies: readonly ScannerMatchPlayer[],
): number | null {
if (name === null) return null;
const named = enemies.flatMap((player, slot) =>
player.name !== null ? [{ slot, name: player.name }] : [],
);
const [best, runnerUp] = rankBy(name, named, (enemy) => enemy.name);
if (!best || best.score < VICTIM_NAME_MIN_SIMILARITY) return null;
if (best.score === 1 && runnerUp?.score !== 1) return best.entry.slot;
if (runnerUp && best.score - runnerUp.score < VICTIM_NAME_MIN_MARGIN) {
return null;
}
return best.entry.slot;
}

View File

@@ -65,15 +65,16 @@ import {
import type { DetectedEvent } from "./detectors/types";
import { hueDistance, hueOf, type InkRgb } from "./ink-color";
import { parseReplayTimestamp } from "./replay-time";
import type {
ScannerMatch,
ScannerMatchKill,
ScannerMatchObjective,
ScannerMatchObjectiveSample,
ScannerMatchPlayer,
ScannerMatchPlayerStatus,
ScannerMatchPlayerStatusSample,
ScannerMatchTeam,
import {
type ScannerMatch,
type ScannerMatchKill,
type ScannerMatchObjective,
type ScannerMatchObjectiveSample,
type ScannerMatchPlayer,
type ScannerMatchPlayerStatus,
type ScannerMatchPlayerStatusSample,
type ScannerMatchTeam,
victimSlot,
} from "./scanner-match";
import {
addStatusEvidence,
@@ -88,8 +89,13 @@ import {
type StripSnapshot,
slotRowPermutation,
} from "./slot-row-assignment";
import { editDistance, matchKey } from "./text";
import { multisetOverlap } from "./timeline/same-scoreboard";
import { nameSimilarity } from "./text";
import {
multisetOverlap,
renamedRows,
SAME_GAME_MIN_SHARED_STAT_LINES,
sharedStatLines,
} from "./timeline/same-scoreboard";
import { X_BATTLE_CARD_EVENT_TYPES } from "./x-battle";
/** How far back a scoreboard with no MapStart claims deaths: matches run well under 8 min. */
@@ -200,7 +206,8 @@ const SAME_GAME_MIN_SHARED_PAINTS = 6;
* How far a history screen's recording time may sit from the earlier read of
* the same game, or from the first read of the game it closes: it is on the
* console clock and marks the game's start, while a results screen's time is
* the PC clock at the game's end.
* the PC clock at the game's end. Boards sharing whole stat lines skip it: the
* console clock can be hours off the PC's (another timezone, a misread AM/PM).
*/
const REVISIT_PLAYED_AT_TOLERANCE_MS = 20 * 60 * 1000;
@@ -235,9 +242,10 @@ export type MatchBuildCache<E extends DetectedEvent> = WeakMap<
* Battle lobby cards (shown before that game's results screen) join the
* sources of the game they report on. A battle
* history screen showing an already built game (≥6 shared paint totals, stage
* and recording time not contradicting it) joins that match's `sources` instead of
* forming a new one, as does a results screen read again with no match
* opened since. Any other history screen closes the game being gathered (a
* not contradicting it, and ≥6 shared whole stat lines or a recording time not
* contradicting it) joins that match's `sources` instead of forming a new one,
* putting the real names it reveals on an X Battle's aliased results, as does
* a results screen read again with no match opened since. Any other history screen closes the game being gathered (a
* missed results screen amended from the log) unless its stage, mode or
* recording time contradicts that game's reads. Then (or with no game being
* gathered) it completes an earlier match whose results screen was missed
@@ -344,7 +352,7 @@ export function buildScannerMatches<E extends DetectedEvent>(
// the game already has its match, and the one being played (if
// any) keeps gathering events
built[built.indexOf(revisited)] = {
...revisited,
match: withRevealedNames(revisited, event),
sources: [...revisited.sources, event],
};
continue;
@@ -548,9 +556,9 @@ export function ingestSkipReasons<E extends DetectedEvent>(
* Whether a match was built off battle history screens alone (a battle log or
* replay browser entry browsed later): it holds no read of the game being played.
*/
export function isHistoryOnly<E extends DetectedEvent>(
built: BuiltMatch<E>,
): boolean {
export function isHistoryOnly(built: {
sources: readonly DetectedEvent[];
}): boolean {
return built.sources.every((event) =>
HISTORY_SCOREBOARD_EVENT_TYPES.includes(event.type),
);
@@ -1291,11 +1299,7 @@ function alignKillRows(
}
function sameRowName(a: string, b: string): boolean {
const ka = matchKey(a);
const kb = matchKey(b);
const similarity =
1 - editDistance(ka, kb) / Math.max(ka.length, kb.length, 1);
return similarity >= KILL_SAME_ROW_MIN_SIMILARITY;
return nameSimilarity(a, b) >= KILL_SAME_ROW_MIN_SIMILARITY;
}
/** The slot→row permutations of a scoreboard-closed match, per source. */
@@ -1984,8 +1988,9 @@ function historyData(
/**
* The earlier match a battle history screen shows again: its closing board
* shows the same game and its play time doesn't contradict the screen's
* recording time (either may be unknown, e.g. on VoD scans).
* shows the same game and either shares whole stat lines with the screen or
* its play time doesn't contradict the screen's recording time (either may be
* unknown, e.g. on VoD scans).
*/
function revisitedMatch<E extends DetectedEvent>(
built: readonly BuiltMatch<E>[],
@@ -1998,6 +2003,8 @@ function revisitedMatch<E extends DetectedEvent>(
return built.findLast((candidate) => {
if (!closesSameGame(candidate, board)) return false;
return (
sharedStatLines(closingBoardOf(candidate)!, board) >=
SAME_GAME_MIN_SHARED_STAT_LINES ||
shownPlayedAt === null ||
candidate.match.playedAt === null ||
Math.abs(shownPlayedAt - candidate.match.playedAt) <=
@@ -2143,9 +2150,7 @@ function closesSameGame<E extends DetectedEvent>(
built: BuiltMatch<E>,
board: ScoreboardData,
): boolean {
const closingBoard = built.sources.find((source) =>
SCOREBOARD_EVENT_TYPES.includes(source.type),
);
const closingBoard = closingBoardOf(built);
if (!closingBoard) return false;
if (
board.stage !== null &&
@@ -2155,13 +2160,60 @@ function closesSameGame<E extends DetectedEvent>(
return false;
}
return (
multisetOverlap(
paintsRead(closingBoard.data as ScoreboardData),
paintsRead(board),
) >= SAME_GAME_MIN_SHARED_PAINTS
multisetOverlap(paintsRead(closingBoard), paintsRead(board)) >=
SAME_GAME_MIN_SHARED_PAINTS
);
}
function closingBoardOf<E extends DetectedEvent>(
built: BuiltMatch<E>,
): ScoreboardData | undefined {
return built.sources.find((source) =>
SCOREBOARD_EVENT_TYPES.includes(source.type),
)?.data as ScoreboardData | undefined;
}
/**
* The match with the real names a battle log screen revealed: X Battle's
* results screen shows every player under a random alias, which the log
* swaps for the real names when asked. Only a results screen's names are
* taken for aliases — on a match closed by a history screen it can't be told
* which of two name sets is the real one. The POV player's splats follow
* their victims' rows.
*/
function withRevealedNames<E extends DetectedEvent>(
built: BuiltMatch<E>,
history: E,
): ScannerMatch {
const results = built.sources.find(
(source) => source.type === SCOREBOARD_EVENT_TYPE,
);
const realNames = results
? renamedRows(
results.data as ScoreboardData,
history.data as ScoreboardData,
)
: null;
if (!realNames) return built.match;
const { match } = built;
const teams = match.teams.map((team, side) => ({
players: team.players.map((player, index) => {
const name = realNames.get(side * PLAYERS_PER_TEAM + index);
return name ? { ...player, name } : player;
}),
})) as ScannerMatch["teams"];
const enemySide = match.pov?.team === 1 ? 0 : 1;
const kills =
match.kills?.map((kill) => {
const slot = victimSlot(kill.name, match.teams[enemySide].players);
if (slot === null) return kill;
return { ...kill, name: teams[enemySide].players[slot]!.name };
}) ?? null;
return { ...match, teams, kills };
}
function paintsRead(board: ScoreboardData): number[] {
return board.players.flatMap((player) =>
player.paint !== null ? [player.paint] : [],

View File

@@ -11,6 +11,16 @@ import type {
StageId,
} from "~/modules/in-game-lists/types";
import type { ScannerLobby } from "../scanner-types";
import { rankBy } from "./text";
/**
* A kill-feed name pins its kill on the enemy row it reads exactly (after
* matchKey), else on the row it resembles clearly more than any other: OCR of
* the feed's font garbles symbols the scoreboard reads fine, so a loose best
* match stands when the other names are far off.
*/
const VICTIM_NAME_MIN_SIMILARITY = 0.4;
const VICTIM_NAME_MIN_MARGIN = 0.25;
export interface ScannerMatchPlayer {
/** in-game name; null when unread (e.g. minimap POV enemy rows show none) */
@@ -140,3 +150,21 @@ export interface ScannerMatch {
/** the POV player's seat, when a scoreboard identified it */
pov: { team: 0 | 1; index: number } | null;
}
/** The enemy slot a kill-feed name belongs to; null when no row clearly matches. */
export function victimSlot(
name: string | null,
enemies: readonly ScannerMatchPlayer[],
): number | null {
if (name === null) return null;
const named = enemies.flatMap((player, slot) =>
player.name !== null ? [{ slot, name: player.name }] : [],
);
const [best, runnerUp] = rankBy(name, named, (enemy) => enemy.name);
if (!best || best.score < VICTIM_NAME_MIN_SIMILARITY) return null;
if (best.score === 1 && runnerUp?.score !== 1) return best.entry.slot;
if (runnerUp && best.score - runnerUp.score < VICTIM_NAME_MIN_MARGIN) {
return null;
}
return best.entry.slot;
}

View File

@@ -12,6 +12,7 @@ import { OBJECTIVE_EVENT_TYPE } from "./detectors/objective/index";
import { PLAYER_STATUS_EVENT_TYPE } from "./detectors/objective/player-status";
import { STRIP_WEAPONS_EVENT_TYPE } from "./detectors/objective/strip-weapons";
import type { DetectedEvent } from "./detectors/types";
import { isHistoryOnly } from "./match-builder";
import type { ScannerMatch } from "./scanner-match";
import { xBattleCards } from "./x-battle";
@@ -125,12 +126,17 @@ export function matchResult({
return winner === povTeam ? "win" : "loss";
}
/** The header line's numbers: games decided, W–L and K/D off the POV rows. */
/**
* The header line's numbers: games decided, W–L and K/D off the POV rows.
* Games only seen in the battle log were not played in the session and are
* left out, as they are hidden from its cards.
*/
export function sessionSummary(builds: readonly MatchBuild[]): SessionSummary {
let ka: number | null = null;
let d: number | null = null;
const summary: SessionSummary = { games: 0, wins: 0, losses: 0, ka, d };
for (const build of builds) {
if (isHistoryOnly(build)) continue;
const { match } = build;
const result = matchResult(build);
if (match.winner !== null || result !== null) summary.games++;

View File

@@ -20,7 +20,7 @@
*/
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import type { PlayerStatusFlags } from "./detectors/objective/player-status";
import { editDistance, matchKey } from "./text";
import { nameSimilarity } from "./text";
/** A slot→row permutation: `perm[slot]` is the scoreboard row the slot feeds. */
export type SlotRowPermutation = readonly [number, number, number, number];
@@ -226,9 +226,3 @@ export function applyPermutation<T>(
for (const [slot, row] of perm.entries()) out[row] = flags[slot]!;
return out;
}
function nameSimilarity(a: string, b: string): number {
const ka = matchKey(a);
const kb = matchKey(b);
return 1 - editDistance(ka, kb) / Math.max(ka.length, kb.length, 1);
}

View File

@@ -29,6 +29,13 @@ export function matchKey(s: string): string {
.replace(/\s+/g, "");
}
/** 1 = the same name (after `matchKey`), 0 = nothing in common. */
export function nameSimilarity(a: string, b: string): number {
const ka = matchKey(a);
const kb = matchKey(b);
return 1 - editDistance(ka, kb) / Math.max(ka.length, kb.length, 1);
}
export interface ClosestMatch<T> {
entry: T;
/** 1 = exact (ignoring case/spaces/diacritics), 0 = nothing in common */

View File

@@ -8,6 +8,7 @@
*/
import type { ScoreboardData } from "../detectors/scoreboard/index";
import type { ScoreboardBattleLogReplayData } from "../detectors/scoreboard-battle-log-replay/index";
import { nameSimilarity } from "../text";
/**
* Re-reads of one replay code on a low-fidelity capture differ in a few glyphs
@@ -18,6 +19,20 @@ const CODE_DIFF_MIN = 7;
/** Paint totals are reliable per-match fingerprints; compared only when both read this many of 8 rows. */
const PAINT_MIN_READ = 6;
/**
* Rows read whole (paint, K/A, deaths, specials) two boards must share to show
* the same game whatever else disagrees: a couple of misread rows tolerated,
* while different games practically never share even one such line.
*/
export const SAME_GAME_MIN_SHARED_STAT_LINES = 6;
/** Paired rows whose names must have been read on both boards before a rename can be told. */
const RENAME_MIN_NAMES_COMPARED = 4;
/** More paired rows than this keeping their name is the same names re-read, not a rename. */
const RENAME_MAX_NAMES_KEPT = 1;
/** Names at least this alike are one name re-read (OCR jitter); alias and real name share next to nothing. */
const SAME_NAME_MIN_SIMILARITY = 0.5;
/** Positions where two equal-length strings disagree (∞ on length mismatch). */
function charDiff(a: string, b: string): number {
if (a.length !== b.length) return Number.POSITIVE_INFINITY;
@@ -41,6 +56,90 @@ export function multisetOverlap(a: number[], b: number[]): number {
return n;
}
/**
* How many rows two boards share whole (paint, K/A, deaths, specials all read
* and equal), order-free: a history screen can misplace the winner panel.
*/
export function sharedStatLines(a: ScoreboardData, b: ScoreboardData): number {
const linesOf = (board: ScoreboardData) =>
board.players.flatMap((player) => {
const line = statLine(player);
return line === null ? [] : [line];
});
const bLines = linesOf(b);
let shared = 0;
for (const line of linesOf(a)) {
const index = bLines.indexOf(line);
if (index === -1) continue;
bLines.splice(index, 1);
shared++;
}
return shared;
}
/**
* `b`'s names for `a`'s rows (by row index) when `b` shows the same game's
* players under other names: X Battle draws random aliases on the results
* screen and the battle log until the log is asked for the real names. Rows
* pair by their stat line (unique ones only); null unless enough of them pair
* and next to none of their names agree.
*/
export function renamedRows(
a: ScoreboardData,
b: ScoreboardData,
): Map<number, string> | null {
const pairs = pairedRows(a, b);
if (pairs.length < SAME_GAME_MIN_SHARED_STAT_LINES) return null;
const named = pairs.filter(
([aRow, bRow]) =>
a.players[aRow]!.name.trim() !== "" &&
b.players[bRow]!.name.trim() !== "",
);
const kept = named.filter(
([aRow, bRow]) =>
nameSimilarity(a.players[aRow]!.name, b.players[bRow]!.name) >=
SAME_NAME_MIN_SIMILARITY,
);
if (
named.length < RENAME_MIN_NAMES_COMPARED ||
kept.length > RENAME_MAX_NAMES_KEPT
) {
return null;
}
return new Map(
named.map(([aRow, bRow]) => [aRow, b.players[bRow]!.name.trim()]),
);
}
/** Row index pairs whose stat line is read whole and unique on both boards. */
function pairedRows(
a: ScoreboardData,
b: ScoreboardData,
): Array<[number, number]> {
const uniqueLines = (board: ScoreboardData) => {
const rows = new Map<string, number | null>();
for (const [row, player] of board.players.entries()) {
const line = statLine(player);
if (line === null) continue;
rows.set(line, rows.has(line) ? null : row);
}
return rows;
};
const bRows = uniqueLines(b);
return [...uniqueLines(a)].flatMap(([line, aRow]) => {
const bRow = bRows.get(line);
return aRow !== null && typeof bRow === "number" ? [[aRow, bRow]] : [];
});
}
function statLine(player: ScoreboardData["players"][number]): string | null {
const { paint, ka, d, s } = player;
if (paint === null || ka === null || d === null || s === null) return null;
return `${paint}/${ka}/${d}/${s}`;
}
/** The non-null paint values, order-free (winner side can flip between reads). */
function paints(data: Partial<ScoreboardData>): number[] {
return (data.players ?? [])
@@ -58,6 +157,16 @@ export function sameScoreboardMatch(a: unknown, b: unknown): boolean {
const db = b as Partial<ScoreboardData> &
Partial<ScoreboardBattleLogReplayData>;
// the battle log revealing X Battle's real names: a view of its own, which
// a more confident alias read must not swallow
if (
da.players &&
db.players &&
renamedRows(da as ScoreboardData, db as ScoreboardData)
) {
return false;
}
const stageA = da.stage ?? null;
const stageB = db.stage ?? null;
if (stageA !== null && stageB !== null && stageA !== stageB) return false;

View File

@@ -166,11 +166,15 @@ function battleLogScoreboard(
paints = [] as (number | null)[],
stage = 0 as StageId | null,
mode = "SZ" as ModeShort | null,
names = NAMES,
ka = 10 as number | null,
} = {},
): DetectedEvent & { detectedAt?: number } {
const base = scoreboard(t, { paints, stage, mode }).data as ScoreboardData;
const base = scoreboard(t, { paints, stage, mode, names })
.data as ScoreboardData;
const data: ScoreboardBattleLogData = {
...base,
players: base.players.map((player) => ({ ...player, ka })),
timestamp,
matchScores: [100, 0],
};
@@ -1271,17 +1275,86 @@ test("a battle log view with its stage unread does not close the match still gat
);
});
test("a battle log view whose recording time contradicts the earlier read forms its own match", () => {
test("a battle log view sharing only paint totals whose recording time contradicts the earlier read forms its own match", () => {
const built = buildScannerMatches([
...playedGame(),
battleLogScoreboard(900, {
timestamp: "25.12.2025 19:30",
paints: GAME_PAINTS,
ka: null,
}),
]);
assert.equal(built.length, 2);
});
test("a battle log view sharing whole stat lines joins its match though its recording time is hours off", () => {
const view = battleLogScoreboard(900, {
timestamp: "25.12.2025 9:30",
paints: GAME_PAINTS,
});
const built = buildScannerMatches([...playedGame(), view]);
assert.equal(built.length, 1);
assert.equal(built[0]!.sources.at(-1), view);
});
const ALIASES = [
"Mesh Cap",
"Slugger",
"Sunshield",
"Bandana",
"Headband",
"Capped",
"Headphones",
"Now or Never Seven",
];
function xBattleGame(): DetectedEvent[] {
return [
mapStart(0),
kill(60, ["Headband"]),
kill(90, ["Capped"]),
scoreboard(300, { lobby: "X", paints: GAME_PAINTS, names: ALIASES }),
];
}
test("a battle log view revealing an X Battle's real names puts them on its match", () => {
const built = buildScannerMatches([
...xBattleGame(),
battleLogScoreboard(900, { paints: GAME_PAINTS, names: ALIASES }),
battleLogScoreboard(905, { paints: GAME_PAINTS }),
]);
assert.equal(built.length, 1);
assert.deepEqual(
built[0]!.match.teams.flatMap((team) =>
team.players.map((player) => player.name),
),
NAMES,
);
assert.deepEqual(
built[0]!.match.kills!.map((read) => read.name),
["l1", "l2"],
);
});
test("an X Battle's revealed names stay when the battle log shows the aliases again", () => {
const built = buildScannerMatches([
...xBattleGame(),
battleLogScoreboard(900, { paints: GAME_PAINTS }),
battleLogScoreboard(905, { paints: GAME_PAINTS, names: ALIASES }),
]);
assert.equal(built[0]!.match.teams[0].players[0]!.name, NAMES[0]);
});
test("a battle log view with other names keeps the names of a match a history screen closed", () => {
const built = buildScannerMatches([
battleLogScoreboard(900, { paints: GAME_PAINTS, names: ALIASES }),
battleLogScoreboard(905, { paints: GAME_PAINTS }),
]);
assert.equal(built.length, 1);
assert.equal(built[0]!.sources.length, 2);
assert.equal(built[0]!.match.teams[0].players[0]!.name, ALIASES[0]);
});
test("a battle log view with too few paint totals read forms its own match", () => {
const sparse = GAME_PAINTS.map((paint, i) => (i < 5 ? paint : null));
const results = scoreboard(300, { paints: sparse });

View File

@@ -74,7 +74,12 @@ function match(
};
}
function built(game: ScannerMatch, sources: DetectedEvent[] = []) {
function built(
game: ScannerMatch,
sources: DetectedEvent[] = [
{ type: "Scoreboard", t: 300, confidence: 0.9, data: {} },
],
) {
return { match: game, sources };
}
@@ -155,6 +160,16 @@ test("the summary counts decided games, the record and the POV K/D", () => {
assert.equal(kdRatio(summary)!.toFixed(2), "1.55");
});
test("the summary leaves out games only seen in the battle log", () => {
const summary = sessionSummary([
built(match({ result: "win", ka: 8, d: 2 })),
built(match({ result: "loss", ka: 4, d: 6 }), [
{ type: "ScoreboardBattleLog", t: 900, confidence: 0.9, data: {} },
]),
]);
assert.deepEqual(summary, { games: 1, wins: 1, losses: 0, ka: 8, d: 2 });
});
test("a match without a POV seat counts as a game but not a result", () => {
const summary = sessionSummary([built(match({ result: "win", pov: null }))]);
assert.equal(summary.games, 1);

View File

@@ -160,6 +160,27 @@ test("disjoint paint totals split when stage and code are unreadable", () => {
assert.equal(tl.events.length, 2);
});
test("a battle log revealing real names stays apart from the alias read of the same board", () => {
const tl = new TimelineBuilder({ minConfidence: 0 });
const board = (names: string[]): ScoreboardData["players"] =>
[327, 408, 270, 354, 381, 613, 456, 287].map((paint, i) => ({
name: names[i]!,
weaponId: null,
paint,
ka: i,
d: 3,
s: 1,
}));
const aliases = ["Mesh Cap", "Slugger", "Sunshield", "Bandana"];
const realNames = ["Sendou", "Kalle", "Jrod_14", "datkid"];
tl.push(replay(100, 0.9, { players: board([...aliases, ...aliases]) }));
const revealed = replay(103, 0.8, {
players: board([...realNames, ...realNames]),
});
assert.equal(tl.push(revealed).action, "added");
assert.equal(tl.events.length, 2);
});
test("too few readable paints never split", () => {
const tl = new TimelineBuilder({ minConfidence: 0 });
tl.push(

View File

@@ -0,0 +1,5 @@
---
navItem: scanner
type: bug
---
Fixed the scanner counting a game twice when it's opened in the battle log and the console's clock doesn't match the computer's

View File

@@ -0,0 +1,5 @@
---
navItem: scanner
type: bug
---
Fixed the scanner counting an X Battle twice when its real names are checked in the battle log, the game now shows the real names instead