mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-27 04:54:25 -05:00
Fix scanner counting battle log revisits and X Battle real names as new games
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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] : [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
5
changelog/2026-10-18-scanner-battle-log-clock.md
Normal file
5
changelog/2026-10-18-scanner-battle-log-clock.md
Normal 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
|
||||
5
changelog/2026-10-18-scanner-x-battle-real-names.md
Normal file
5
changelog/2026-10-18-scanner-x-battle-real-names.md
Normal 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
|
||||
Reference in New Issue
Block a user