diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index 6d923780c..5711676c1 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -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 diff --git a/app/features/scanner/components/CoachLifeCard.tsx b/app/features/scanner/components/CoachLifeCard.tsx index b13fa5145..c6cf2a1fa 100644 --- a/app/features/scanner/components/CoachLifeCard.tsx +++ b/app/features/scanner/components/CoachLifeCard.tsx @@ -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 */ diff --git a/app/features/scanner/components/SessionView.tsx b/app/features/scanner/components/SessionView.tsx index 69cd739de..11b96ba64 100644 --- a/app/features/scanner/components/SessionView.tsx +++ b/app/features/scanner/components/SessionView.tsx @@ -223,7 +223,11 @@ export function SessionView({ } > {groups.map(({ group, matches }) => ( - + {LOBBY_GROUP_LABELS[group]} ))} diff --git a/app/features/scanner/components/game-timeline-view.ts b/app/features/scanner/components/game-timeline-view.ts index 4977b71e6..ba17fd014 100644 --- a/app/features/scanner/components/game-timeline-view.ts +++ b/app/features/scanner/components/game-timeline-view.ts @@ -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; -} diff --git a/app/features/scanner/core/match-builder.ts b/app/features/scanner/core/match-builder.ts index 2cac0340e..eb08e619b 100644 --- a/app/features/scanner/core/match-builder.ts +++ b/app/features/scanner/core/match-builder.ts @@ -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 = 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( // 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( * 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( - built: BuiltMatch, -): 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( built: readonly BuiltMatch[], @@ -1998,6 +2003,8 @@ function revisitedMatch( 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( built: BuiltMatch, 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( 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( + built: BuiltMatch, +): 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( + built: BuiltMatch, + 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] : [], diff --git a/app/features/scanner/core/scanner-match.ts b/app/features/scanner/core/scanner-match.ts index a75bb1f84..249b3a6d3 100644 --- a/app/features/scanner/core/scanner-match.ts +++ b/app/features/scanner/core/scanner-match.ts @@ -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; +} diff --git a/app/features/scanner/core/sessions.ts b/app/features/scanner/core/sessions.ts index 8e6de07f4..ca3e33a2b 100644 --- a/app/features/scanner/core/sessions.ts +++ b/app/features/scanner/core/sessions.ts @@ -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++; diff --git a/app/features/scanner/core/slot-row-assignment.ts b/app/features/scanner/core/slot-row-assignment.ts index 009006105..55e2b1c38 100644 --- a/app/features/scanner/core/slot-row-assignment.ts +++ b/app/features/scanner/core/slot-row-assignment.ts @@ -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( 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); -} diff --git a/app/features/scanner/core/text.ts b/app/features/scanner/core/text.ts index 8b001a29c..16c69a18a 100644 --- a/app/features/scanner/core/text.ts +++ b/app/features/scanner/core/text.ts @@ -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 { entry: T; /** 1 = exact (ignoring case/spaces/diacritics), 0 = nothing in common */ diff --git a/app/features/scanner/core/timeline/same-scoreboard.ts b/app/features/scanner/core/timeline/same-scoreboard.ts index 6284e3f46..5ddcba29e 100644 --- a/app/features/scanner/core/timeline/same-scoreboard.ts +++ b/app/features/scanner/core/timeline/same-scoreboard.ts @@ -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 | 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(); + 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): number[] { return (data.players ?? []) @@ -58,6 +157,16 @@ export function sameScoreboardMatch(a: unknown, b: unknown): boolean { const db = b as Partial & Partial; + // 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; diff --git a/app/features/scanner/tests/logic/match-builder.test.ts b/app/features/scanner/tests/logic/match-builder.test.ts index cd865cc8b..752e451cb 100644 --- a/app/features/scanner/tests/logic/match-builder.test.ts +++ b/app/features/scanner/tests/logic/match-builder.test.ts @@ -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 }); diff --git a/app/features/scanner/tests/logic/sessions.test.ts b/app/features/scanner/tests/logic/sessions.test.ts index 38ccba427..993fb9455 100644 --- a/app/features/scanner/tests/logic/sessions.test.ts +++ b/app/features/scanner/tests/logic/sessions.test.ts @@ -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); diff --git a/app/features/scanner/tests/timeline.test.ts b/app/features/scanner/tests/timeline.test.ts index 3fea09bf6..3795829b0 100644 --- a/app/features/scanner/tests/timeline.test.ts +++ b/app/features/scanner/tests/timeline.test.ts @@ -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( diff --git a/changelog/2026-10-18-scanner-battle-log-clock.md b/changelog/2026-10-18-scanner-battle-log-clock.md new file mode 100644 index 000000000..d05132972 --- /dev/null +++ b/changelog/2026-10-18-scanner-battle-log-clock.md @@ -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 diff --git a/changelog/2026-10-18-scanner-x-battle-real-names.md b/changelog/2026-10-18-scanner-x-battle-real-names.md new file mode 100644 index 000000000..0641b3c2c --- /dev/null +++ b/changelog/2026-10-18-scanner-x-battle-real-names.md @@ -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