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:
+51
-3
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user