diff --git a/src/main.js b/src/main.js index 99178a7..fbf88ae 100644 --- a/src/main.js +++ b/src/main.js @@ -114,6 +114,48 @@ const TOKEN_KEY = "bcp_auth_token"; 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 {} }; +// 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 // already shows (e.g. "Take and Hold", "Priority Assets", …) — deriving it // 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); if (dm) { const captured = dm[1].trim(); - disposition = knownDisp.get(captured.toLowerCase()) || captured; + disposition = knownDisp.get(captured.toLowerCase()) || closestDisposition(captured) || captured; } if (!disposition) { 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; } } } @@ -546,7 +590,11 @@ function clearBanner() { function render() { if (!state) return; 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 tokenStatusEl = $("bcp-token-status");