diff --git a/app/features/scanner/core/match-sets.ts b/app/features/scanner/core/match-sets.ts index 98cfd14b5..ef112ebad 100644 --- a/app/features/scanner/core/match-sets.ts +++ b/app/features/scanner/core/match-sets.ts @@ -8,7 +8,7 @@ * readable names is inconclusive and never opens a new set. */ import type { ScannerMatch } from "./scanner-match"; -import { closestBy } from "./text"; +import { matchKey, rankBy } from "./text"; /** * closestBy score two reads of the same name must reach — 0.7 forgives one @@ -48,7 +48,7 @@ function rosterNames(match: ScannerMatch): string[] { /** * Whether two rosters read as the same eight players: every name of the - * smaller roster must find a fuzzy partner in the other, short of + * smaller roster must find a partner in the other, short of * MAX_SUBS_PER_GAME misses. Partial reads compare only what both saw, so a * minimap-sourced half-roster still chains a set together. */ @@ -56,11 +56,33 @@ function sameRoster(a: readonly string[], b: readonly string[]): boolean { const remaining = [...b]; let matched = 0; for (const name of a) { - const best = closestBy(name, remaining, (entry) => entry); - if (best && best.score >= SAME_NAME_SCORE) { + const partner = rankBy(name, remaining, (entry) => entry).find( + ({ entry, score }) => + score >= SAME_NAME_SCORE || sharedPrefixRead(name, entry), + ); + if (partner) { matched++; - remaining.splice(remaining.indexOf(best.entry), 1); + 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 agree on a leading prefix long enough that the + * difference reads as tail damage, not another player. CJK names run 2-4 + * glyphs, so a single truncated (れた → れ) or garbled (ほった → ほっ′`) + * tail glyph sinks the edit-distance score below any usable threshold while + * the OCR damage sits, as it nearly always does, at the end of the row. + */ +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); +} diff --git a/app/features/scanner/tests/match-sets.test.ts b/app/features/scanner/tests/match-sets.test.ts index 30128eccf..3159abd4c 100644 --- a/app/features/scanner/tests/match-sets.test.ts +++ b/app/features/scanner/tests/match-sets.test.ts @@ -72,6 +72,20 @@ test("assignMatchSets", async (t) => { assert.deepEqual(assignMatchSets(games), [1, 1]); }); + await t.test("tail damage on short CJK names is not a roster change", () => { + const games = [ + match( + ["まどろみ", "ほった", "きのちゃんねる !", "くてんちゃんねる"], + ["かなえる", "たん", "ロット", "れた"], + ), + match( + ["まどろみ", "ほっ′`", "きのちゃんねる !", "くてんちゃんねる"], + ["かなえる", "たん", "ロット", "れ"], + ), + ]; + assert.deepEqual(assignMatchSets(games), [1, 1]); + }); + await t.test("one sub is tolerated, two are a new set", () => { const oneSub = [ match(TEAM_A, TEAM_B),