Files
sendou.ink/app/features/scanner/core/match-sets.ts
2026-09-02 21:02:01 +03:00

81 lines
2.9 KiB
TypeScript

/**
* Divides a chronological run of ScannerMatches into sets — consecutive games
* by the same eight players. Rosters are compared by names pooled across both
* teams (sides swap between games), fuzzily since two OCR reads of a name can
* differ by a glyph or two; one differing player is tolerated as a sub. A
* match with no readable names is inconclusive and never opens a new set.
*/
import type { ScannerMatch } from "./scanner-match";
import { matchKey, rankBy } from "./text";
/** closestBy score two reads of a name must reach: 0.7 forgives one bad glyph on four letters. */
const SAME_NAME_SCORE = 0.7;
/** Players allowed to differ between consecutive games of one set. */
const MAX_SUBS_PER_GAME = 1;
/**
* Each match's 1-based set number, aligned by index with the (chronological)
* input. A roster disagreeing with the current set's opens the next set.
*/
export function assignMatchSets(matches: readonly ScannerMatch[]): number[] {
const setNumbers: number[] = [];
let setNumber = 1;
let roster: string[] | null = null;
for (const match of matches) {
const names = rosterNames(match);
if (names.length > 0) {
if (roster !== null && !sameRoster(roster, names)) setNumber++;
roster = names;
}
setNumbers.push(setNumber);
}
return setNumbers;
}
function rosterNames(match: ScannerMatch): string[] {
return match.teams
.flatMap((team) => team.players)
.map((player) => player.name)
.filter((name): name is string => name !== null);
}
/**
* Whether two rosters read as the same eight players: every name of the
* smaller roster must find a partner in the other, short of MAX_SUBS_PER_GAME
* misses. Partial reads compare only what both saw.
*/
function sameRoster(a: readonly string[], b: readonly string[]): boolean {
const remaining = [...b];
let matched = 0;
for (const name of a) {
const partner = rankBy(name, remaining, (entry) => entry).find(
({ entry, score }) =>
score >= SAME_NAME_SCORE || sharedPrefixRead(name, entry),
);
if (partner) {
matched++;
remaining.splice(remaining.indexOf(partner.entry), 1);
}
}
return Math.min(a.length, b.length) - matched <= MAX_SUBS_PER_GAME;
}
/**
* Whether two reads of a name share a prefix long enough that the difference
* reads as tail damage. CJK names run 2-4 glyphs, so one truncated (れた → れ)
* or garbled (ほった → ほっ′`) tail glyph sinks the edit-distance score below
* any usable threshold, while OCR damage nearly always sits at the row's end.
*/
function sharedPrefixRead(a: string, b: string): boolean {
const keyA = matchKey(a);
const keyB = matchKey(b);
const shorter = Math.min(keyA.length, keyB.length);
if (shorter === 0) return false;
let prefix = 0;
while (prefix < shorter && keyA[prefix] === keyB[prefix]) prefix++;
// a whole-read prefix is a truncation; a 2+ glyph prefix covering two
// thirds of the shorter read is a solid read with a garbled tail
return prefix === shorter || (prefix >= 2 && prefix * 3 >= shorter * 2);
}