feat: fuzzy-match disposition names to the 5 canonical ITC dispositions

Hand-typed disposition text (submitted-list free text, BCP's own field)
often has small typos like "Priority Assests". Match it against the fixed
Purge the Foe / Take and Hold / Priority Assets / Disruption / Reconnaissance
list via Levenshtein distance so typo'd variants merge into the correct bar
instead of forking off as duplicates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 12:26:00 -05:00
co-authored by Claude Sonnet 5
parent 812e81e220
commit 6649f0e0da
+51 -3
View File
@@ -114,6 +114,48 @@ const TOKEN_KEY = "bcp_auth_token";
const getToken = () => { try { return localStorage.getItem(TOKEN_KEY) || ""; } catch { return ""; } }; const getToken = () => { try { return localStorage.getItem(TOKEN_KEY) || ""; } catch { return ""; } };
const setToken = t => { try { t ? localStorage.setItem(TOKEN_KEY, t) : localStorage.removeItem(TOKEN_KEY); } catch {} }; const setToken = t => { try { t ? localStorage.setItem(TOKEN_KEY, t) : localStorage.removeItem(TOKEN_KEY); } catch {} };
// This event tracks the five ITC-standard mission dispositions. Hand-typed
// sources (a "Dispositions Used:" line in a submitted list, BCP's own free
// text) routinely have small typos (e.g. "Priority Assests"), so every
// disposition value is fuzzy-matched against this fixed list before being
// counted or displayed, keeping typo'd variants from forking off as their
// own duplicate bar.
const CANONICAL_DISPOSITIONS = ["Purge the Foe", "Take and Hold", "Priority Assets", "Disruption", "Reconnaissance"];
function levenshtein(a, b) {
const m = a.length, n = b.length;
if (!m) return n;
if (!n) return m;
let prev = Array.from({ length: n + 1 }, (_, i) => i);
for (let i = 1; i <= m; i++) {
const cur = [i];
for (let j = 1; j <= n; j++) {
cur[j] = a[i - 1] === b[j - 1] ? prev[j - 1] : 1 + Math.min(prev[j - 1], prev[j], cur[j - 1]);
}
prev = cur;
}
return prev[n];
}
// Nearest canonical disposition, or null if nothing is close enough to be
// confident it's a typo rather than unrelated text. Threshold scales with
// name length (~25%) so e.g. "Priority Assests" (1 char off "Priority
// Assets") matches, but a short unrelated line doesn't accidentally match
// "Disruption".
function closestDisposition(name) {
const norm = String(name || "").trim().toLowerCase().replace(/[^a-z ]/g, "").replace(/\s+/g, " ");
if (!norm) return null;
let best = null, bestDist = Infinity;
for (const canonical of CANONICAL_DISPOSITIONS) {
const dist = levenshtein(norm, canonical.toLowerCase());
if (dist < bestDist) { bestDist = dist; best = canonical; }
}
const threshold = Math.max(1, Math.round(best.length * 0.25));
return bestDist <= threshold ? best : null;
}
const correctDisposition = name => closestDisposition(name) || name;
// The vocabulary of disposition names is whatever this event's own roster // The vocabulary of disposition names is whatever this event's own roster
// already shows (e.g. "Take and Hold", "Priority Assets", …) — deriving it // already shows (e.g. "Take and Hold", "Priority Assets", …) — deriving it
// from the event rather than hardcoding keeps this working for other game // from the event rather than hardcoding keeps this working for other game
@@ -172,11 +214,13 @@ function extractFactionAndDisposition(armyList, knownDisp) {
const dm = text.match(/^Dispositions?\s+Used:\s*(.+)$/im); const dm = text.match(/^Dispositions?\s+Used:\s*(.+)$/im);
if (dm) { if (dm) {
const captured = dm[1].trim(); const captured = dm[1].trim();
disposition = knownDisp.get(captured.toLowerCase()) || captured; disposition = knownDisp.get(captured.toLowerCase()) || closestDisposition(captured) || captured;
} }
if (!disposition) { if (!disposition) {
for (const rawLine of text.split("\n")) { for (const rawLine of text.split("\n")) {
const canonical = knownDisp.get(rawLine.trim().toLowerCase()); const line = rawLine.trim();
if (!line) continue;
const canonical = knownDisp.get(line.toLowerCase()) || closestDisposition(line);
if (canonical) { disposition = canonical; break; } if (canonical) { disposition = canonical; break; }
} }
} }
@@ -546,7 +590,11 @@ function clearBanner() {
function render() { function render() {
if (!state) return; if (!state) return;
const { event, teamplayers, isTeamEvent, itcRanks, itcLoading, backfill, backfillStatus } = state; const { event, teamplayers, isTeamEvent, itcRanks, itcLoading, backfill, backfillStatus } = state;
const roster = applyBackfill(state.roster, backfill); const roster = applyBackfill(state.roster, backfill).map(r =>
r.subFaction?.name
? { ...r, subFaction: { ...r.subFaction, name: correctDisposition(r.subFaction.name) } }
: r
);
const teams = isTeamEvent ? computeTeamRollups(roster, teamplayers) : []; const teams = isTeamEvent ? computeTeamRollups(roster, teamplayers) : [];
const tokenStatusEl = $("bcp-token-status"); const tokenStatusEl = $("bcp-token-status");