Add Generate Matrix: auto-build the team scouting matchup matrix

Duplicates the team's Google Sheets matchup-matrix template once per
opposing team at a loaded event, pre-filled with names, factions,
career win% (all already computed by this page), and cleaned army
lists. New "Which team is yours?" selector + Generate Matrix button,
team-events only.

- Auth: Google Identity Services token client (drive.file +
  spreadsheets scopes) — the viewer signs in with their own Google
  account via a real OAuth popup, no backend, no client secret.
- Template layout is located at runtime by searching each tab for its
  own placeholder text ("Player N - Faction", "Opponent N") rather
  than hardcoded A1 ranges, so it survives the template changing later.
  Distinguishes the real input area from the template's own "mobile
  matrix" mirror and reference sections (which repeat the same
  placeholder text) by checking for literal typed values vs formulas —
  CSV export can't tell these apart, only the real Sheets API response
  can, which is how this was actually verified before writing this.
  Auto-picks between the template's 5-man/8-man tab variants by team
  size.
- List cleaning via a vendored copy of desjani's 40k-compactor
  (src/vendor/40k-compactor/, see NOTICE.md) — it isn't actually
  published to npm despite documenting `npm install 40k-compactor`
  (verified: 404 against the registry under every plausible name), so
  it can't be a normal dependency; vendored instead, MIT per direct
  confirmation from the maintainer. Verified against 40k-compactor's
  own sample list fixtures with real Node before committing.
- Generating a matrix needs the same BCP auth token already used for
  faction/disposition backfill (list text is a subscriber-gated
  endpoint) — reuses the existing token UI/storage as-is; missing
  lists are reported, not a hard failure.
- nginx CSP updated (script/connect/frame-src) for Google's identity
  script and the Drive/Sheets REST APIs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBzcNkW7JcYipnAX6HfgmK
This commit is contained in:
2026-09-04 18:09:48 -05:00
co-authored by Claude Sonnet 5
parent 345f952970
commit 06b0cf4418
18 changed files with 6974 additions and 1 deletions
+20
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gateway Gamers — Scouting</title>
<link rel="stylesheet" href="/src/style.css" />
<!-- Google Identity Services — powers the "Generate Matrix" Google sign-in popup -->
<script src="https://accounts.google.com/gsi/client" async defer></script>
</head>
<body class="min-h-screen flex flex-col">
@@ -133,6 +135,24 @@
<input id="player-filter" type="text" placeholder="Filter by name, team, or faction…" class="input input-bordered input-sm w-full sm:w-72" />
</div>
<!-- Generate Matrix (team events only) -->
<div class="card card-border bg-base-200 mb-4 hidden" id="matrix-card">
<div class="card-body py-4">
<div class="flex items-center gap-2 flex-wrap">
<label class="text-sm opacity-70" for="matrix-team-select">Which team is yours?</label>
<select id="matrix-team-select" class="select select-bordered select-sm max-w-xs"></select>
<button id="matrix-generate-btn" type="button" class="btn btn-sm btn-warning uppercase tracking-wide" disabled>
Generate Matrix
</button>
</div>
<p class="text-xs opacity-50 mt-1">
Duplicates the team matrix template, once per opposing team, pre-filled with names, factions, career win%, and cleaned lists.
List formatting by <a href="https://desjani.github.io/40kCompactor/" target="_blank" rel="noopener" class="link">40k Compactor</a> (Desjani), MIT licensed.
</p>
<p class="text-sm mt-2 hidden" id="matrix-status"></p>
</div>
</div>
<!-- Team accordion (team events) -->
<div class="space-y-2 hidden" id="team-list"></div>
@@ -33,7 +33,10 @@ server {
# blocks without it (script-src stays locked down — this only affects
# CSS, and nothing here renders untrusted external content into a
# style attribute).
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://newprod-api.bestcoastpairings.com; img-src 'self' data:;" always;
# script-src/connect-src/frame-src additions are for "Generate Matrix":
# Google Identity Services (accounts.google.com) for the sign-in popup,
# and the Drive/Sheets REST APIs the browser calls directly afterward.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline'; connect-src 'self' https://newprod-api.bestcoastpairings.com https://www.googleapis.com https://sheets.googleapis.com https://accounts.google.com https://oauth2.googleapis.com; frame-src https://accounts.google.com; img-src 'self' data:;" always;
location /images/ {
alias /var/www/domains/gateway-gamers.net/shared/images/;
+58
View File
@@ -1,3 +1,5 @@
import { generateMatrix, configureMatrixHelpers } from "./matrix.js";
/* ---- Shared: header / footer ---- */
function loadHTML(file, elementId, callback) {
fetch(file)
@@ -611,6 +613,22 @@ function renderTeamList(teams, itcRanks, itcLoading, careerRecords, careerLoadin
`<p class="text-sm opacity-50">No teams match${q ? ` "${esc(filterText)}"` : ""}.</p>`;
}
// Rebuilding the <select> on every render() (which fires often — ITC/career
// loads, backfill, the filter box) would blow away whatever the viewer
// already picked. Only rebuild when the actual team list changed.
let lastMatrixTeamSignature = null;
function populateMatrixTeamSelect(teams) {
const signature = teams.map(t => t.id).join(",");
if (signature === lastMatrixTeamSignature) return;
lastMatrixTeamSignature = signature;
const sel = $("matrix-team-select");
const prev = sel.value;
sel.innerHTML = teams.map(t => `<option value="${esc(t.id)}">${esc(t.name)}</option>`).join("");
if (teams.some(t => t.id === prev)) sel.value = prev;
$("matrix-generate-btn").disabled = false;
}
function renderPlayers(roster, itcRanks, itcLoading, careerRecords, careerLoading, filterText) {
const q = (filterText || "").trim().toLowerCase();
const rows = roster.filter(r => {
@@ -655,6 +673,7 @@ function render() {
: r
);
const teams = isTeamEvent ? computeTeamRollups(roster, teamplayers) : [];
state.teams = teams; // stashed for the Generate Matrix button, which runs outside render()
const tokenStatusEl = $("bcp-token-status");
if (tokenStatusEl) {
@@ -699,6 +718,10 @@ function render() {
$("team-list").classList.toggle("hidden", !isTeamEvent);
$("player-table-wrap").classList.toggle("hidden", isTeamEvent);
const showMatrix = isTeamEvent && teams.length >= 2;
$("matrix-card").classList.toggle("hidden", !showMatrix);
if (showMatrix) populateMatrixTeamSelect(teams);
const filterText = $("player-filter").value;
if (isTeamEvent) {
renderTeamList(teams, itcRanks, itcLoading, careerRecords, careerLoading, filterText);
@@ -814,6 +837,41 @@ $("bcp-token-clear").addEventListener("click", () => {
$("bcp-token-status").textContent = "Cleared.";
});
configureMatrixHelpers({ fullName, fmtWpct });
$("matrix-generate-btn").addEventListener("click", async () => {
if (!state || !state.teams?.length) return;
const ourTeamId = $("matrix-team-select").value;
const ourTeam = state.teams.find(t => t.id === ourTeamId);
const opposingTeams = state.teams.filter(t => t.id !== ourTeamId && t.members.length);
if (!ourTeam || !opposingTeams.length) return;
const btn = $("matrix-generate-btn");
const statusEl = $("matrix-status");
btn.disabled = true;
statusEl.classList.remove("hidden", "text-error", "text-success");
statusEl.textContent = "Starting…";
try {
const { url, skipped } = await generateMatrix({
event: state.event,
ourTeam,
opposingTeams,
careerRecords: state.careerRecords,
bcpToken: getToken(),
onProgress: msg => { statusEl.textContent = msg; },
});
statusEl.classList.add("text-success");
statusEl.innerHTML = `Done — <a href="${esc(url)}" target="_blank" rel="noopener" class="link">open the matrix</a>.` +
(skipped.length ? `<br>${skipped.length} note(s): ${skipped.map(s => esc(`${s.team}: ${s.reason}`)).join(" · ")}` : "");
} catch (err) {
statusEl.classList.add("text-error");
statusEl.textContent = `Couldn't generate the matrix: ${String(err.message || err)}`;
} finally {
btn.disabled = false;
}
});
/* ---- Boot: seed from ?event= if present ---- */
const seeded = new URLSearchParams(location.search).get("event");
if (seeded) {
+353
View File
@@ -0,0 +1,353 @@
/* ---- Generate Matrix: duplicate the team's Google Sheets matchup matrix
template, once per opposing team, pre-filled from this page's own roster
data plus cleaned army lists (via the vendored 40k-compactor). ----
Auth: Google Identity Services (GIS) token client — the viewer signs in
with their own Google account (a real OAuth popup), no backend, no
client secret. Scopes are drive.file (only files this app creates, not
full Drive access) and spreadsheets. */
import { detectFormat, parseGwAppV11, parseWarOrganV11, parseV11List, generateDiscordText, buildAbbreviationIndex } from "./vendor/40k-compactor/index.js";
import skippableWargear from "./vendor/40k-compactor/skippable_wargear.json";
const GOOGLE_CLIENT_ID = "698705216189-rqbg3f7vlosi3077c3n410a7lpmsn16r.apps.googleusercontent.com";
const TEMPLATE_FILE_ID = "1tSG1BxqLwmMtwFnaHozaLqVsKlmk_QsCXRorIxQBcMo";
const SCOPES = "https://www.googleapis.com/auth/drive.file https://www.googleapis.com/auth/spreadsheets";
const DRIVE_API = "https://www.googleapis.com/drive/v3";
const SHEETS_API = "https://sheets.googleapis.com/v4/spreadsheets";
/* ---- List cleaning (vendored 40k-compactor — see vendor/40k-compactor/NOTICE.md) ---- */
// detectFormat() only distinguishes the 11th-edition dialects this parser
// set understands; NR_TOURNAMENT isn't separately parseable from the
// functions this tool exports (no dedicated parser in the vendored code),
// so it falls back to the generic V11 parser same as an unrecognized format.
const PARSERS = {
GW_APP_V11: parseGwAppV11,
WAR_ORGAN_V11: parseWarOrganV11,
V11_GENERIC: parseV11List,
};
// Returns cleaned, sheet-friendly (plain, not Discord-markdown) list text,
// or null if the list couldn't be parsed — callers should fall back to the
// raw text rather than dropping the list entirely.
function cleanListText(rawText) {
if (!rawText) return null;
try {
const lines = String(rawText).split("\n");
const format = detectFormat(lines);
const parser = PARSERS[format] || parseV11List;
const parsed = parser(lines, skippableWargear);
if (!parsed) return null;
const abbrIndex = buildAbbreviationIndex(parsed);
return generateDiscordText(
parsed,
true, // plain text — this is going in a spreadsheet cell, not Discord
true, // use abbreviations
abbrIndex,
false, // hide subunits?
skippableWargear,
true, // combine identical units?
{ multilineHeader: true, colorMode: "none" }
);
} catch (err) {
console.warn("[matrix] list cleaning failed, falling back to raw text:", err);
return null;
}
}
/* ---- Google auth ---- */
let tokenClient = null;
let cachedToken = null; // { access_token, expires_at }
function ensureTokenClient() {
if (tokenClient) return tokenClient;
if (!window.google?.accounts?.oauth2) {
throw new Error("Google Identity Services hasn't loaded yet — try again in a moment.");
}
tokenClient = window.google.accounts.oauth2.initTokenClient({
client_id: GOOGLE_CLIENT_ID,
scope: SCOPES,
callback: () => {}, // overridden per-request in getAccessToken()
});
return tokenClient;
}
function getAccessToken() {
if (cachedToken && cachedToken.expires_at > Date.now() + 30_000) {
return Promise.resolve(cachedToken.access_token);
}
return new Promise((resolve, reject) => {
const client = ensureTokenClient();
client.callback = resp => {
if (resp.error) { reject(new Error(`Google sign-in failed: ${resp.error}`)); return; }
cachedToken = { access_token: resp.access_token, expires_at: Date.now() + (resp.expires_in || 3600) * 1000 };
resolve(resp.access_token);
};
client.requestAccessToken({ prompt: cachedToken ? "" : "consent" });
});
}
/* ---- Google API helpers ---- */
async function googleFetch(url, token, options = {}) {
const r = await fetch(url, {
...options,
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...options.headers },
});
if (!r.ok) {
const body = await r.text().catch(() => "");
throw new Error(`Google API ${r.status}: ${body.slice(0, 300)}`);
}
return r.json();
}
const copyTemplate = (token, name) =>
googleFetch(`${DRIVE_API}/files/${TEMPLATE_FILE_ID}/copy?fields=id,webViewLink`, token, {
method: "POST",
body: JSON.stringify({ name }),
});
const getSpreadsheet = (token, fileId) =>
googleFetch(`${SHEETS_API}/${fileId}?includeGridData=true`, token);
const batchUpdate = (token, fileId, requests) =>
googleFetch(`${SHEETS_API}/${fileId}:batchUpdate`, token, {
method: "POST",
body: JSON.stringify({ requests }),
});
const batchUpdateValues = (token, fileId, data) =>
googleFetch(`${SHEETS_API}/${fileId}/values:batchUpdate`, token, {
method: "POST",
body: JSON.stringify({ valueInputOption: "USER_ENTERED", data }),
});
/* ---- Template layout ----
Deliberately not hardcoding A1 ranges: the implementation searches each
template tab for its own placeholder text ("Player N - Faction", the
"Opponent N" row) and works relative to what it finds, so this survives
the template being tweaked later (rows/columns inserted, etc.) without
needing a code change. See the plan file for how this was mapped out. */
const PLAYER_ROW_RE = /^Player (\d+) - Faction$/;
const OPPONENT_COL_RE = /^Opponent (\d+)$/;
// The template repeats "Player N - Faction" / "Opponent N" in more than one
// place — a primary input area, plus a "mobile matrix" mirror and a couple
// of reference/lookup sections the sheet's own instructions say to ignore
// ("Ignore the mobile matrix... I have no clue what the purpose is"). CSV
// export can't tell these apart (it only shows computed values), but the
// real API response can: only treat a cell as a genuine placeholder if it's
// literal typed text (userEnteredValue.stringValue), not a formula result
// (e.g. a mirror cell like `=B6`) that merely displays the same text.
function isLiteralText(cell, expected) {
return cell.userEnteredValue?.stringValue === expected && (cell.formattedValue || "") === expected;
}
// Scans a tab's grid for the template's placeholder cells. Returns null if
// this isn't a matrix template tab (e.g. the instructions tab, or a tab a
// team has already filled in and renamed).
function locateTemplate(sheet) {
const rows = sheet.data?.[0]?.rowData || [];
// { col -> [{ row, size }] } — group by column so mirrored/reference
// occurrences (which land in different columns) don't get mixed together.
const playerHitsByCol = new Map();
const opponentHitsByRow = new Map();
rows.forEach((rowData, rIdx) => {
(rowData.values || []).forEach((cell, cIdx) => {
const text = cell.formattedValue || "";
const pm = text.match(PLAYER_ROW_RE);
if (pm && isLiteralText(cell, text)) {
if (!playerHitsByCol.has(cIdx)) playerHitsByCol.set(cIdx, []);
playerHitsByCol.get(cIdx).push({ row: rIdx, size: Number(pm[1]) });
}
const om = text.match(OPPONENT_COL_RE);
if (om && isLiteralText(cell, text)) {
if (!opponentHitsByRow.has(rIdx)) opponentHitsByRow.set(rIdx, []);
opponentHitsByRow.get(rIdx).push({ col: cIdx, size: Number(om[1]) });
}
});
});
// The real input area is the single column/row with the longest run of
// *consecutive* placeholder numbers (1, 2, 3, …) — mirrors elsewhere in
// the sheet may repeat the same set, but this is how we'd tell a genuine
// partial/edited copy apart from a stray one-off match either way.
const longestRun = hits => {
const sorted = [...hits].sort((a, b) => a.size - b.size);
let best = [], current = [];
for (const h of sorted) {
if (current.length && h.size !== current[current.length - 1].size + 1) { current = []; }
current.push(h);
if (current.length > best.length) best = current;
}
return best;
};
let playerCol = null, playerRows = [];
for (const [col, hits] of playerHitsByCol) {
const run = longestRun(hits);
if (run.length > playerRows.length) { playerCol = col; playerRows = run; }
}
let opponentRowIdx = null, opponentHits = [];
for (const [row, hits] of opponentHitsByRow) {
const run = longestRun(hits);
if (run.length > opponentHits.length) { opponentRowIdx = row; opponentHits = run; }
}
if (!playerRows.length || !opponentHits.length) return null;
return {
sheetId: sheet.properties.sheetId,
title: sheet.properties.title,
teamSize: Math.max(...opponentHits.map(c => c.size)),
playerCol,
playerRows: playerRows.map(p => p.row).sort((a, b) => a - b),
opponentRow: opponentRowIdx, // "Name and Win Rate" row
factionRow: opponentRowIdx - 1, // one row up, per the template's own layout
listRow: opponentRowIdx - 2, // two rows up — "top of each appropriate column"
opponentCols: opponentHits.sort((a, b) => a.size - b.size).map(c => c.col),
};
}
// Finds every matrix-template tab in the copied file (there's one per
// supported team size, e.g. 5-man and 8-man) and returns them keyed by size.
function findTemplates(spreadsheet) {
const templates = new Map(); // teamSize -> layout
for (const sheet of spreadsheet.sheets) {
const layout = locateTemplate(sheet);
if (layout) templates.set(layout.teamSize, layout);
}
return templates;
}
function colLetter(idx) {
let s = "", n = idx;
do { s = String.fromCharCode(65 + (n % 26)) + s; n = Math.floor(n / 26) - 1; } while (n >= 0);
return s;
}
const a1 = (sheetTitle, col, row) => `'${sheetTitle.replace(/'/g, "''")}'!${colLetter(col)}${row + 1}`;
/* ---- Row/column value builders ---- */
function playerLabel(member) {
const name = fullNameOf(member);
const faction = member.faction?.name || "Unknown";
return `${name} - ${faction}`;
}
// fullName/fmtWpct live in main.js today; passed in rather than duplicated.
let fullNameOf = () => "";
let fmtWpctOf = () => "—";
export function configureMatrixHelpers({ fullName, fmtWpct }) {
fullNameOf = fullName;
fmtWpctOf = fmtWpct;
}
function opponentNameAndWinRate(member, careerRecords) {
const name = fullNameOf(member);
const uid = member.userId || member.user?.id;
const rec = careerRecords.get(uid);
const wpct = rec ? fmtWpctOf(rec.w, rec.l, rec.t) : "—";
return `${name} (${wpct})`;
}
/* ---- Orchestration ---- */
// team: one entry from computeTeamRollups(). onProgress(msg) for UI status.
export async function generateMatrix({ event, ourTeam, opposingTeams, careerRecords, bcpToken, onProgress }) {
onProgress?.("Signing in to Google…");
const token = await getAccessToken();
onProgress?.("Copying the matrix template…");
const copy = await copyTemplate(token, `${event.name || "Event"} — Matrix`);
onProgress?.("Reading the template…");
const spreadsheet = await getSpreadsheet(token, copy.id);
const templates = findTemplates(spreadsheet);
if (!templates.size) {
throw new Error("Couldn't find a recognizable template tab (looking for 'Player 1 - Faction' / 'Opponent 1' placeholders) in the copied spreadsheet.");
}
const skipped = [];
let done = 0;
for (const oppTeam of opposingTeams) {
onProgress?.(`Building tab ${++done}/${opposingTeams.length}: ${oppTeam.name}`);
// Nearest template size at or above this team's actual size (an
// undersized opponent just leaves trailing columns blank).
const sizes = [...templates.keys()].sort((a, b) => a - b);
const size = sizes.find(s => s >= Math.max(oppTeam.members.length, ourTeam.members.length)) || sizes[sizes.length - 1];
const template = templates.get(size);
let title;
try {
const dup = await batchUpdate(token, copy.id, [{
duplicateSheet: {
sourceSheetId: template.sheetId,
newSheetName: oppTeam.name.slice(0, 100),
},
}]);
title = dup.replies[0].duplicateSheet.properties.title;
} catch (err) {
skipped.push({ team: oppTeam.name, reason: `Couldn't duplicate tab: ${err.message}` });
continue;
}
const data = [];
ourTeam.members.forEach((member, i) => {
const row = template.playerRows[i];
if (row == null) return; // template has fewer rows than our roster
data.push({ range: a1(title, template.playerCol, row), values: [[playerLabel(member)]] });
});
let listSkips = 0;
for (let i = 0; i < oppTeam.members.length; i++) {
const col = template.opponentCols[i];
if (col == null) break; // opposing team is larger than this template supports
const member = oppTeam.members[i];
data.push({ range: a1(title, col, template.opponentRow), values: [[opponentNameAndWinRate(member, careerRecords)]] });
data.push({ range: a1(title, col, template.factionRow), values: [[member.faction?.name || "Unknown"]] });
if (member.listId && bcpToken) {
try {
const list = await fetchArmyListText(member.listId, bcpToken);
const cleaned = cleanListText(list) || list;
data.push({ range: a1(title, col, template.listRow), values: [[cleaned]] });
} catch {
listSkips++;
}
} else if (member.listId) {
listSkips++;
}
}
if (listSkips) skipped.push({ team: oppTeam.name, reason: `${listSkips} list(s) not fetched — add a BCP auth token above to include them.` });
try {
await batchUpdateValues(token, copy.id, data);
} catch (err) {
skipped.push({ team: oppTeam.name, reason: `Tab created but couldn't write data: ${err.message}` });
}
}
return { url: copy.webViewLink || `https://docs.google.com/spreadsheets/d/${copy.id}/edit`, skipped };
}
// Same BCP endpoint/shape as fetchArmyList() in main.js (kept local rather
// than imported to keep this module's dependency surface self-contained).
async function fetchArmyListText(listId, token) {
const r = await fetch(`https://newprod-api.bestcoastpairings.com/v1/armylists/${listId}`, {
headers: { "client-id": "web-app", "env": "bcp", "accept": "application/json", authorization: `Bearer ${token}` },
});
if (!r.ok) throw new Error(`armylists/${r.status}`);
const list = await r.json();
return list.armyListText || "";
}
+21
View File
@@ -0,0 +1,21 @@
# 40k Compactor (vendored)
Source: https://github.com/desjani/40kCompactor (tool page: https://desjani.github.io/40kCompactor/)
Author: Desjani
This directory is a copy of the `40k-compactor` npm package's source
(v1.8.1) — it isn't actually published to the npm registry (verified:
404 on `registry.npmjs.org/40k-compactor` and every plausible name
variant, despite the project's own README documenting `npm install
40k-compactor`), so it can't be a normal `package.json` dependency.
Vendored directly instead.
**License:** MIT, per direct confirmation from the maintainer (the
upstream repo has no LICENSE file or `license` field as of this
writing — this notice documents how permission was obtained). Used
here to reformat submitted army lists before they're written into a
generated scouting matrix — see `src/matrix.js`.
Unmodified from upstream except this file. To update, re-fetch
`index.js`, everything under `modules/`, and `skippable_wargear.json`
from the repo's `main` branch.
+13
View File
@@ -0,0 +1,13 @@
import { detectFormat, parseV11List, parseGwAppV11, parseWarOrganV11 } from './modules/parsers.js';
import { generateDiscordText, buildFactionColorMap } from './modules/renderers.js';
import { buildAbbreviationIndex } from './modules/abbreviations.js';
export {
detectFormat,
parseV11List,
parseGwAppV11,
parseWarOrganV11,
generateDiscordText,
buildFactionColorMap,
buildAbbreviationIndex
};
@@ -0,0 +1,318 @@
// Dynamic abbreviation generator: build abbreviation map from parsed list data
// The goal: do not depend on any external JSON (wargear.json or abbreviation_rules.json).
// We'll derive reasonable abbreviations by: using explicit nameshort from parsed items if
// present, else generating a short token by taking initials of significant words.
import { normalizeWargearName } from './utils.js';
// Generate a base abbreviation following project rules:
// - Trim parenthetical content and punctuation, split on words
// - Single word -> first two letters uppercase (e.g. "Plasma" -> "PL")
// - Multi-word -> for each word:
// * if word === 'and' -> use '&'
// * if word === 'of' -> use 'o' (lowercase)
// * otherwise take first letter uppercase
// Example: "Icon of Khorne" -> "IoK" ; "Skullsmasher and Mangler" -> "S&M"
function makeBaseAbbreviation(name) {
if (!name) return null;
const cleaned = name.replace(/\(.*?\)/g, '').replace(/[\-]/g, ' ').replace(/["'`.,;:?!]/g, '').trim();
const parts = cleaned.split(/\s+/).filter(Boolean);
if (parts.length === 0) return null;
if (parts.length === 1) {
return parts[0].slice(0, 2).toUpperCase();
}
const tokens = parts.map(p => {
const low = p.toString().toLowerCase();
if (low === 'and') return '&';
if (low === 'of') return 'o';
return p[0].toUpperCase();
});
return tokens.join('');
}
// Helper exported so renderers can reuse the exact same abbreviation logic when
// the dynamic map isn't available at runtime.
export function makeAbbrevForName(name) {
return makeBaseAbbreviation(name);
}
export function buildAbbreviationIndex(parsedData, customAbbrs = {}) {
// Build a flat map: lowercased full item name -> abbreviation string
const flat = Object.create(null);
// Keep namespace-specific used and collision maps.
// Namespaces: 'unit' (units/subunits) and 'wargear' (wargear/specials)
const used = {
unit: Object.create(null),
wargear: Object.create(null)
};
const collisionGroups = {
unit: Object.create(null),
wargear: Object.create(null)
};
// 1. Load Custom Abbreviations (Priority 999)
if (customAbbrs) {
for (const [name, abbr] of Object.entries(customAbbrs)) {
if (!name || !abbr) continue;
const lowerName = normalizeWargearName(name);
flat[lowerName] = abbr;
// If multiple custom rules map to the same abbr, the last one wins in 'used' map,
// but 'flat' map will hold all of them.
// We treat custom rules as "locked" in both namespaces.
for (const ns of ['unit', 'wargear']) {
if (!used[ns][abbr]) {
used[ns][abbr] = { name: name, keys: [lowerName], priority: 999 };
} else {
// If collision with another custom rule, just append key
if (used[ns][abbr].priority === 999) {
used[ns][abbr].keys.push(lowerName);
} else {
// Should not happen if we process custom first, but just in case
used[ns][abbr] = { name: name, keys: [lowerName], priority: 999 };
}
}
}
}
}
const getPriority = (it) => {
if (!it || !it.type) return 1;
if (it.type === 'unit') return 5;
if (it.type === 'subunit') return 4;
if (it.type === 'wargear') return 3;
if (it.type === 'special') return 2;
return 1;
};
const getNamespace = (type) => {
if (type === 'unit' || type === 'subunit') return 'unit';
return 'wargear';
};
// Produce an abbreviation taking first letter uppercase + `step` more letters lowercase per significant word.
const makeAbbrevWithStep = (name, step = 0) => {
if (!name) return '';
const cleaned = name.replace(/\(.*?\)/g, '').replace(/[\-]/g, ' ').replace(/["'`.,;:?!]/g, '').trim();
const parts = cleaned.split(/\s+/).filter(Boolean);
if (parts.length === 0) return '';
if (parts.length === 1) {
const w = parts[0];
const head = w.slice(0, 1).toUpperCase();
const extra = w.slice(1, 1 + Math.max(1, step)).toLowerCase();
return (head + extra) || w.slice(0, 2).toUpperCase();
}
const tokens = parts.map(p => {
const low = p.toString().toLowerCase();
if (low === 'and') return '&';
if (low === 'of') return 'o';
const head = p.slice(0, 1).toUpperCase();
const extra = p.slice(1, 1 + Math.max(0, step)).toLowerCase();
return head + extra;
});
return tokens.join('');
};
const reassignFlatKeys = (keys, newAbbr) => {
(keys || []).forEach(k => { flat[k] = newAbbr; });
};
// Resolve a collision group by iteratively expanding only conflicting members
// one letter per word per iteration until all become unique and don't clash with external 'used'.
const resolveGroup = (base, ns) => {
const grp = collisionGroups[ns][base];
if (!grp) return;
// Initialize steps/abbrs if missing
for (const m of grp.members) {
if (typeof m.step !== 'number') m.step = 0;
}
const nsUsed = used[ns];
const maxIterations = 100; // safety guard
let iter = 0;
while (iter++ < maxIterations) {
// Compute current abbreviations for members
const abbrMap = new Map(); // abbr -> members[]
for (const m of grp.members) {
const ab = makeAbbrevWithStep(m.name, m.step);
m.nextAbbr = ab;
if (!abbrMap.has(ab)) abbrMap.set(ab, []);
abbrMap.get(ab).push(m);
}
// Determine which members are in conflict (internal or external)
let anyConflict = false;
for (const m of grp.members) {
const ab = m.nextAbbr;
const internalConflict = (abbrMap.get(ab) || []).length > 1;
const external = nsUsed[ab];
// If external exists and is high priority (custom), we must conflict
const externalIsCustom = external && external.priority === 999;
const externalConflict = !!(external && (!external.keys || !m.keys || external.keys.some(k => !(m.keys||[]).includes(k))));
// Note: if external is one of our group's previous assignments for the same member, this shouldn't count as conflict
const externalIsSelf = external && external.name === m.name;
const conflict = internalConflict || (externalConflict && !externalIsSelf) || (externalIsCustom && !externalIsSelf);
m._conflict = conflict;
if (conflict) anyConflict = true;
}
if (!anyConflict) {
// Commit: update used and flat for all members
for (const m of grp.members) {
const ab = m.nextAbbr;
if (m.abbr && m.abbr !== ab && nsUsed[m.abbr]) delete nsUsed[m.abbr];
reassignFlatKeys(m.keys, ab);
nsUsed[ab] = { name: m.name, keys: m.keys, priority: m.priority };
m.abbr = ab;
}
// Clean temp fields
grp.members.forEach(m => { delete m.nextAbbr; delete m._conflict; });
return;
}
// Increment step for conflicting members only (per spec)
for (const m of grp.members) {
if (m._conflict && m.priority !== 999) m.step = (m.step || 0) + 1;
}
}
// Fallback: if still conflicted after max iterations, append numerals to force uniqueness
const abbrCount = Object.create(null);
for (const m of grp.members) {
let ab = makeAbbrevWithStep(m.name, m.step || 0);
if (abbrCount[ab]) {
abbrCount[ab] += 1;
ab = ab + String(abbrCount[ab]);
} else {
abbrCount[ab] = 1;
}
if (m.abbr && m.abbr !== ab && nsUsed[m.abbr]) delete nsUsed[m.abbr];
reassignFlatKeys(m.keys, ab);
nsUsed[ab] = { name: m.name, keys: m.keys, priority: m.priority };
m.abbr = ab;
}
grp.members.forEach(m => { delete m.nextAbbr; delete m._conflict; });
};
const registerInGroup = (name, key, priority, base, ns) => {
let grp = collisionGroups[ns][base];
const nsUsed = used[ns];
if (!grp) {
const existing = nsUsed[base];
if (!existing) return null; // should not happen
grp = collisionGroups[ns][base] = { members: [
{ name: existing.name, keys: existing.keys || [], priority: existing.priority || 1, abbr: base, step: 0 },
{ name, keys: [key], priority, abbr: base, step: 0 }
] };
// Remove the simplistic base entry; will be re-assigned after resolution
delete nsUsed[base];
resolveGroup(base, ns);
// return abbr for the new member
const last = grp.members[1];
return last.abbr;
}
// Add to existing group; reset steps so the entire set resolves fairly from the same baseline
grp.members.forEach(m => { m.step = 0; });
grp.members.push({ name, keys: [key], priority, abbr: '', step: 0 });
resolveGroup(base, ns);
const me = grp.members[grp.members.length - 1];
return me.abbr;
};
if (!parsedData) return { __flat_abbr: flat };
const processItem = (name, type) => {
if (!name) return;
const key = normalizeWargearName(name);
if (key === 'warlord') return;
if (flat[key]) return;
// Check if a singular or plural variation already has an abbreviation
const singularKey = key.endsWith('s') ? key.slice(0, -1) : key;
const pluralKey = key.endsWith('s') ? key : key + 's';
if (flat[singularKey]) {
flat[key] = flat[singularKey];
return;
}
if (flat[pluralKey]) {
flat[key] = flat[pluralKey];
return;
}
const ns = getNamespace(type);
const base = makeBaseAbbreviation(name);
if (base && base.toUpperCase() !== 'NULL') {
const priority = getPriority({ type });
const nsUsed = used[ns];
const nsCollisionGroups = collisionGroups[ns];
if (nsUsed[base]) {
const assigned = registerInGroup(name, key, priority, base, ns);
if (assigned) flat[key] = assigned;
} else if (nsCollisionGroups[base]) {
const assigned = registerInGroup(name, key, priority, base, ns);
if (assigned) flat[key] = assigned;
} else {
flat[key] = base;
nsUsed[base] = { name, keys: [key], priority };
}
}
};
if (Array.isArray(parsedData.units)) {
for (const unit of parsedData.units) {
if (unit.isAttached && Array.isArray(unit.attachedParts)) {
for (const part of unit.attachedParts) {
processItem(part.name, 'unit');
if (Array.isArray(part.wargear)) {
for (const wg of part.wargear) {
processItem(wg.name, 'wargear');
}
}
if (Array.isArray(part.enhancements)) {
for (const enh of part.enhancements) {
processItem(enh.name, 'special');
}
}
if (Array.isArray(part.subunits)) {
for (const sub of part.subunits) {
processItem(sub.name, 'subunit');
if (Array.isArray(sub.wargear)) {
for (const wg of sub.wargear) {
processItem(wg.name, 'wargear');
}
}
}
}
}
} else {
processItem(unit.name, 'unit');
}
if (Array.isArray(unit.wargear)) {
for (const wg of unit.wargear) {
processItem(wg.name, 'wargear');
}
}
if (Array.isArray(unit.enhancements)) {
for (const enh of unit.enhancements) {
processItem(enh.name, 'special');
}
}
if (Array.isArray(unit.subunits)) {
for (const sub of unit.subunits) {
processItem(sub.name, 'subunit');
if (Array.isArray(sub.wargear)) {
for (const wg of sub.wargear) {
processItem(wg.name, 'wargear');
}
}
}
}
}
}
return { __flat_abbr: flat };
}
@@ -0,0 +1,51 @@
// Faction color mapping for Discord/ANSI output.
// Only colors from the project's ansiPalette are used here.
// Keys should match the top-level keys from `skippable_wargear.json` or the
// `DISPLAY_FACTION`/`FACTION_KEYWORD` values produced by parsers.
//
// Rules applied when choosing colors:
// - Allowed hexes: '#000000','#FF0000','#00FF00','#FFFF00','#0000FF','#FF00FF','#00FFFF','#FFFFFF','#808080'
// - Unit != Subunit
// - Subunit != Wargear
// - Wargear != Points
// - Treat '#000000' and '#808080' as equivalent for uniqueness purposes
export default {
// Use simple color names (allowed: black, red, green, yellow, blue, magenta, cyan, white, grey)
// Each entry now includes an explicit `header` color so the page header can be colored per-faction.
// Each entry also includes an `attached` color for the attached unit role tags ([L1], [BG1], etc.)
"World Eaters": { unit: 'red', subunit: 'grey', wargear: 'yellow', points: 'green', header: 'red', attached: 'cyan' },
"Adepta Sororitas": { unit: 'white', subunit: 'grey', wargear: 'red', points: 'yellow', header: 'white', attached: 'cyan' },
"Adeptus Custodes": { unit: 'yellow', subunit: 'red', wargear: 'white', points: 'yellow', header: 'yellow', attached: 'cyan' },
"Adeptus Mechanicus": { unit: 'red', subunit: 'grey', wargear: 'white', points: 'green', header: 'red', attached: 'cyan' },
"Adeptus Titanicus": { unit: 'white', subunit: 'grey', wargear: 'blue', points: 'yellow', header: 'yellow', attached: 'cyan' },
"Aeldari": { unit: 'green', subunit: 'blue', wargear: 'white', points: 'grey', header: 'green', attached: 'yellow' },
"Astra Militarum": { unit: 'grey', subunit: 'green', wargear: 'cyan', points: 'yellow', header: 'grey', attached: 'magenta' },
"Black Templars": { unit: 'white', subunit: 'grey', wargear: 'black', points: 'yellow', header: 'white', attached: 'cyan' },
"Blood Angels": { unit: 'red', subunit: 'grey', wargear: 'white', points: 'yellow', header: 'red', attached: 'cyan' },
"Chaos Daemons": { unit: 'red', subunit: 'grey', wargear: 'green', points: 'yellow', header: 'red', attached: 'cyan' },
"Chaos Knights": { unit: 'red', subunit: 'grey', wargear: 'blue', points: 'yellow', header: 'red', attached: 'cyan' },
"Chaos Space Marines": { unit: 'red', subunit: 'grey', wargear: 'green', points: 'yellow', header: 'red', attached: 'cyan' },
"Dark Angels": { unit: 'green', subunit: 'grey', wargear: 'white', points: 'yellow', header: 'green', attached: 'cyan' },
"Death Guard": { unit: 'green', subunit: 'grey', wargear: 'blue', points: 'yellow', header: 'green', attached: 'cyan' },
"Deathwatch": { unit: 'black', subunit: 'grey', wargear: 'blue', points: 'yellow', header: 'black', attached: 'cyan' },
"Drukhari": { unit: 'magenta',subunit: 'grey', wargear: 'cyan', points: 'yellow', header: 'magenta', attached: 'white' },
"Emperor's Children": { unit: 'magenta',subunit: 'grey', wargear: 'white', points: 'yellow', header: 'magenta', attached: 'cyan' },
"Genestealer Cults": { unit: 'cyan', subunit: 'grey', wargear: 'green', points: 'yellow', header: 'cyan', attached: 'magenta' },
"Grey Knights": { unit: 'white', subunit: 'grey', wargear: 'blue', points: 'yellow', header: 'white', attached: 'cyan' },
"Imperial Fists": { unit: 'yellow', subunit: 'grey', wargear: 'blue', points: 'yellow', header: 'yellow', attached: 'cyan' },
"Imperial Knights": { unit: 'white', subunit: 'grey', wargear: 'blue', points: 'yellow', header: 'white', attached: 'cyan' },
"Iron Hands": { unit: 'grey', subunit: 'white', wargear: 'grey', points: 'white', header: 'grey', attached: 'cyan' },
"Leagues of Votann": { unit: 'white', subunit: 'grey', wargear: 'blue', points: 'yellow', header: 'white', attached: 'cyan' },
"Necrons": { unit: 'white', subunit: 'grey', wargear: 'white', points: 'green', header: 'green', attached: 'cyan' },
"Orks": { unit: 'green', subunit: 'grey', wargear: 'blue', points: 'yellow', header: 'green', attached: 'cyan' },
"Raven Guard": { unit: 'black', subunit: 'grey', wargear: 'blue', points: 'yellow', header: 'black', attached: 'cyan' },
"Salamanders": { unit: 'green', subunit: 'grey', wargear: 'white', points: 'yellow', header: 'green', attached: 'cyan' },
"Space Marines": { unit: 'blue', subunit: 'grey', wargear: 'white', points: 'yellow', header: 'blue', attached: 'cyan' },
"Space Wolves": { unit: 'blue', subunit: 'grey', wargear: 'white', points: 'yellow', header: 'blue', attached: 'cyan' },
"T'au Empire": { unit: 'yellow', subunit: 'grey', wargear: 'white', points: 'Cyan', header: 'cyan', attached: 'magenta' },
"Thousand Sons": { unit: 'magenta',subunit: 'grey', wargear: 'blue', points: 'yellow', header: 'magenta', attached: 'cyan' },
"Tyranids": { unit: 'cyan', subunit: 'grey', wargear: 'green', points: 'yellow', header: 'cyan', attached: 'magenta' },
"Ultramarines": { unit: 'blue', subunit: 'grey', wargear: 'white', points: 'yellow', header: 'blue', attached: 'cyan' },
"White Scars": { unit: 'white', subunit: 'grey', wargear: 'red', points: 'yellow', header: 'white', attached: 'cyan' },
"Agents of the Imperium": { unit: 'white', subunit: 'grey', wargear: 'blue', points: 'yellow', header: 'white', attached: 'cyan' }
};
@@ -0,0 +1,12 @@
import { detectV11Format } from './parsers/v11_detector.js';
import { parseV11List } from './parsers/v11_parser.js';
import { parseGwAppV11 } from './parsers/gwapp_v11.js';
import { parseWarOrganV11 } from './parsers/war_organ_parser.js';
import { parseNRTournament } from './parsers/nr_tournament_parser.js';
import { parseNRGW } from './parsers/nr_gw_parser.js';
export { parseV11List, parseGwAppV11, parseWarOrganV11, parseNRTournament, parseNRGW };
export function detectFormat(lines) {
return detectV11Format(lines);
}
@@ -0,0 +1,468 @@
import { isWargearSkippable } from '../utils.js';
export function parseGwAppV11(lines, skippableWargearMap = {}) {
const result = {
edition: '11th',
metadata: {
armyName: '',
totalPoints: 0,
faction: '',
battleSize: '',
pointsLimit: 0,
detachments: [],
detachmentPoints: 0,
forceDispositions: []
},
units: []
};
if (!Array.isArray(lines) || lines.length === 0) return result;
// Filter out empty lines for metadata parsing
const cleanLines = lines.map(l => l.trimEnd());
const nonEmptyLines = cleanLines.filter(l => l.trim().length > 0);
if (nonEmptyLines.length === 0) return result;
// Helper to map translated section headers to standard English versions
const getCanonicalSectionHeader = (line) => {
if (!line) return null;
let norm = line.toLowerCase().trim();
try {
norm = norm.normalize('NFD').replace(/\p{M}/gu, '');
} catch (e) {}
const mapping = {
// Attached units
"attached units": "Attached Units",
"unites attachees": "Attached Units",
"unidades acopladas": "Attached Units",
"angegliederte einheiten": "Attached Units",
"unita associate": "Attached Units",
// Characters
"characters": "Characters",
"personnages": "Characters",
"personajes": "Characters",
"charaktermodelle": "Characters",
"personaggi": "Characters",
// Battleline
// TODO: confirm Spanish/German/Italian equivalents against real exports
// before adding — "ligne" (French) is confirmed against a live export.
"battleline": "Battleline",
"ligne": "Battleline",
// Dedicated transports
"dedicated transports": "Dedicated Transports",
"transports assignes": "Dedicated Transports",
"transport assigne": "Dedicated Transports",
"transportes asignados": "Dedicated Transports",
"angeschlossene transportfahrzeuge": "Dedicated Transports",
"trasporti dedicati": "Dedicated Transports",
// Other datasheets
"other datasheets": "Other Datasheets",
"autres fiches techniques": "Other Datasheets",
"otras hojas de datos": "Other Datasheets",
"andere datenblatter": "Other Datasheets",
"altre schede tecniche": "Other Datasheets"
};
return mapping[norm] || null;
};
const forceDispPrefixRegex = /^(Force\s+Dispositions|Dispositions\s+des\s+Forces|Disposiciones\s+de\s+la?\s+fuerza|Streitkräfteaufstellungen?)\s*:\s*/i;
const attachedUnitHeaderRegex = /^(Attached Unit|Unité|Unidad acoplada|Angegliederte Einheit|Unità associata|Unita associata)\s+(\d+)(?:\s+Attachée)?$/i;
const detachmentPointsRegex = /^(.*?)\s*\((\d+[\d,]*)\s*(?:Detachment\s*Points|Points?\s*de\s*D[eé]tachement|Puntos\s*de\s*Destacamento|Detachement[- ]*Punkte|Detachementpunkte|Punti\s*(?:di\s*)?Distaccamento)\)$/i;
const isUnitHeader = (trimmed) => {
const unitMatch = trimmed.match(/^(.*?)\s*\((\d+[\d,]*)\s*(?:pts|points|punkte|puntos|punti)\)$/i);
if (!unitMatch) return false;
const name = unitMatch[1].toLowerCase().trim();
const battleSizes = [
'strike force', 'force de frappe', 'fuerza de choque', 'fuerza de incursión', 'angriffstrupp', 'forza d\'attacco',
'incursion', 'incursión', 'scharmützel', 'incursione',
'combat patrol', 'patrouille de combat', 'patrulla de combate', 'kampfpatrouille', 'pattuglia da combattimento',
'onslaught', 'aufmarsch', 'embestida', 'guerra aperta',
];
if (battleSizes.some(bs => name.includes(bs))) {
return false;
}
if (name.includes('detachment') || name.includes('détachement') || name.includes('destacamento') || name.includes('detachement') || name.includes('distaccamento')) {
return false;
}
return true;
};
// 1. Parse Metadata Headers
let cleanLinesIndex = 0;
const getNextNonEmptyLine = () => {
while (cleanLinesIndex < cleanLines.length) {
const trimmed = cleanLines[cleanLinesIndex].trim();
if (trimmed.length > 0) {
return { line: trimmed, index: cleanLinesIndex++ };
}
cleanLinesIndex++;
}
return null;
};
const first = getNextNonEmptyLine();
if (!first) return result;
const line1 = first.line;
const line1Match = line1.match(/^(.*?)\s*\((\d+[\d,]*)\s*(?:pts|points|punkte|puntos|punti)\)$/i);
if (line1Match) {
result.metadata.armyName = line1Match[1].trim();
result.metadata.totalPoints = parseInt(line1Match[2].replace(/,/g, ''), 10) || 0;
} else {
result.metadata.armyName = line1;
}
const second = getNextNonEmptyLine();
if (second) {
result.metadata.faction = second.line.trim();
// Some lists express a subfaction as its own header line directly below the
// main faction, e.g.:
// Space Marines
// Dark Angels
// Strike Force (2000 points)
// Detect this by peeking at the next line: if it's a plain line (not a battle
// size, force disposition, section header, or unit header), treat it as a
// subfaction and fold it into the faction name as "Space Marines - Dark Angels".
const savedIndex = cleanLinesIndex;
const peek = getNextNonEmptyLine();
if (peek) {
const peekLine = peek.line;
const looksLikeBattleSize = /^(.*?)\s*\((\d+[\d,]*)\s*(?:pts|points|punkte|puntos|punti)\)$/i.test(peekLine);
const looksLikeMetadataMarker = looksLikeBattleSize ||
forceDispPrefixRegex.test(peekLine) ||
detachmentPointsRegex.test(peekLine) ||
getCanonicalSectionHeader(peekLine) ||
attachedUnitHeaderRegex.test(peekLine) ||
isUnitHeader(peekLine);
if (!looksLikeMetadataMarker) {
result.metadata.faction = `${result.metadata.faction} - ${peekLine}`;
} else {
cleanLinesIndex = savedIndex;
}
}
}
const metadataLines = [];
let bodyStartIndex = cleanLines.length;
while (cleanLinesIndex < cleanLines.length) {
let nextIndex = cleanLinesIndex;
while (nextIndex < cleanLines.length && cleanLines[nextIndex].trim().length === 0) {
nextIndex++;
}
if (nextIndex >= cleanLines.length) {
bodyStartIndex = cleanLines.length;
break;
}
const lineVal = cleanLines[nextIndex].trim();
if (getCanonicalSectionHeader(lineVal) || attachedUnitHeaderRegex.test(lineVal) || isUnitHeader(lineVal)) {
bodyStartIndex = nextIndex;
break;
}
metadataLines.push(lineVal);
cleanLinesIndex = nextIndex + 1;
}
let detachmentsSet = false;
let battleSizeSet = false;
let forceDispositionsSet = false;
metadataLines.forEach(line => {
const trimmedLine = line.trim();
if (trimmedLine.length === 0) return;
if (forceDispPrefixRegex.test(trimmedLine)) {
const dispStr = trimmedLine.replace(forceDispPrefixRegex, '').trim();
const cleanedDisp = dispStr.replace(/,$/, '');
result.metadata.forceDispositions = cleanedDisp.split(',').map(d => d.trim()).filter(Boolean);
forceDispositionsSet = true;
return;
}
const detachmentMatch = trimmedLine.match(detachmentPointsRegex);
if (detachmentMatch) {
const detStr = detachmentMatch[1].trim();
result.metadata.detachmentPoints = parseInt(detachmentMatch[2].replace(/,/g, ''), 10) || 0;
result.metadata.detachments = detStr.split(/\s+(?:and|et|y|und|e)\s+/i).map(d => d.trim()).filter(Boolean);
detachmentsSet = true;
return;
}
const battleSizeMatch = trimmedLine.match(/^(.*?)\s*\((\d+[\d,]*)\s*(?:pts|points|punkte|puntos|punti)\)$/i);
if (battleSizeMatch) {
result.metadata.battleSize = battleSizeMatch[1].trim();
result.metadata.pointsLimit = parseInt(battleSizeMatch[2].replace(/,/g, ''), 10) || 0;
battleSizeSet = true;
return;
}
// Bare line with no distinguishing marker. GW App's newer export format lists
// Force Dispositions as a plain line (no "Force Dispositions:" prefix) once the
// detachment has already been parsed, so prefer that reading over the legacy
// bare detachment/battle-size fallback below.
if (!forceDispositionsSet && detachmentsSet) {
const cleanedDisp = trimmedLine.replace(/,$/, '');
result.metadata.forceDispositions = cleanedDisp.split(',').map(d => d.trim()).filter(Boolean);
forceDispositionsSet = true;
} else if (!detachmentsSet) {
result.metadata.detachments = [trimmedLine];
detachmentsSet = true;
} else if (!battleSizeSet) {
result.metadata.battleSize = trimmedLine;
battleSizeSet = true;
} else if (result.metadata.forceDispositions.length === 0) {
// Newer app versions drop the "Force Dispositions:" label and just list
// the disposition(s) as a bare line, e.g. "Purge the Foe".
result.metadata.forceDispositions = trimmedLine.split(',').map(d => d.trim()).filter(Boolean);
}
});
// 2. Group Remaining Lines into Sections and Unit Blocks
let currentSection = 'Characters';
let currentAttachedGroup = null;
// Helper to parse quantity and name: e.g. "2x Storm Bolter" -> { name: "Storm Bolter", quantity: 2 }
const parseQtyAndName = (str, unitName) => {
const cleaned = str.trim();
let name = cleaned;
let quantity = 1;
const match = cleaned.match(/^(\d+)x?\s+(.*)$/i);
if (match) {
name = match[2].trim();
quantity = parseInt(match[1], 10);
}
const skippable = isWargearSkippable(skippableWargearMap, result.metadata.faction, unitName, name);
return {
name,
quantity,
skippable
};
};
// Parse a single line's indent and bullet properties
const parseLineDetails = (line) => {
const leadingSpaces = line.length - line.trimStart().length;
const trimmed = line.trim();
const bulletMatch = trimmed.match(/^([•◦\u25e6\u2022])\s*(.*)$/);
if (bulletMatch) {
return {
leadingSpaces,
hasBullet: true,
content: bulletMatch[2].trim()
};
}
return {
leadingSpaces,
hasBullet: false,
content: trimmed
};
};
// Helper to compile a tree of indented lines into a structured unit object
const parseUnitBlock = (unitName, unitPoints, blockLines) => {
const unit = {
name: unitName,
points: unitPoints,
category: currentSection,
isAttached: false,
isWarlord: false,
wargear: [],
enhancements: [],
subunits: []
};
if (blockLines.length === 0) return unit;
// Build tree using indentation
const root = { content: 'Root', indent: -1, children: [] };
const stack = [root];
blockLines.forEach(line => {
const details = parseLineDetails(line);
const node = {
content: details.content,
indent: details.leadingSpaces,
hasBullet: details.hasBullet,
children: []
};
while (stack.length > 1 && stack[stack.length - 1].indent >= node.indent) {
stack.pop();
}
stack[stack.length - 1].children.push(node);
stack.push(node);
});
// Helper to collect all wargear items recursively from a node's descendants
const collectWargearRecursive = (node, targetArray) => {
const parsed = parseQtyAndName(node.content, unitName);
targetArray.push(parsed);
node.children.forEach(child => {
collectWargearRecursive(child, targetArray);
});
};
// Traverse the tree recursively to find properties at indent level 2 and potential 'Attached as' headers at level 0
const elements = [];
let attachedAsNode = null;
let attachedAsMatchResult = null;
const findNodes = (nodes) => {
nodes.forEach(node => {
const match = node.content.match(/^(attached as|attach[eé]e en tant que|acoplado como|angegliedert als|associato come)\s*:\s*(.*)$/i);
if (match) {
attachedAsNode = node;
attachedAsMatchResult = match;
}
if (node.indent === 2) {
elements.push(node);
}
if (node.children && node.children.length > 0) {
findNodes(node.children);
}
});
};
findNodes(root.children);
if (attachedAsNode && attachedAsMatchResult) {
unit.attachedAs = attachedAsMatchResult[2].trim();
const roleStr = unit.attachedAs.toLowerCase();
if (/(leader|meneur|l[ií]der|anfuehrer|anführer|capo|comandante)/i.test(roleStr)) {
unit.role = 'Leader';
} else if (/(bodyguard|gardes?\s+du\s+corps|escolta|leibwaechter|leibwächter|guardia\s+del\s+corpo)/i.test(roleStr)) {
unit.role = 'Bodyguard';
} else {
unit.role = 'Bodyguard';
}
}
elements.forEach(node => {
const content = node.content;
const warlordRegex = /^(warlord|seigneur de guerre|se[nñ]or de la guerra|kriegsherr|signore della guerra)$/i;
const enhancementRegex = /^(enhancement|optimisation|mejora|aufwertung|verbesserung|potenziamento)\s*:\s*(.*)$/i;
if (warlordRegex.test(content)) {
unit.isWarlord = true;
} else {
const enhancementMatch = content.match(enhancementRegex);
if (enhancementMatch) {
const enhName = enhancementMatch[2].trim();
unit.enhancements.push({ name: enhName });
} else {
// A unit item: could be a subunit (if it has bulleted children) or unit wargear
const hasBulletedChildren = node.children.some(c => c.hasBullet);
if (hasBulletedChildren) {
// It is a subunit!
const parsedSub = parseQtyAndName(content, unitName);
const subunit = {
name: parsedSub.name,
quantity: parsedSub.quantity,
wargear: []
};
// Collect wargear from all of its descendants
node.children.forEach(child => {
collectWargearRecursive(child, subunit.wargear);
});
unit.subunits.push(subunit);
} else {
// It is a top-level wargear item
collectWargearRecursive(node, unit.wargear);
}
}
}
});
return unit;
};
// Start parsing the body lines
let i = bodyStartIndex;
while (i < cleanLines.length) {
const line = cleanLines[i];
const trimmed = line.trim();
if (!trimmed) {
i++;
continue;
}
const canonicalHeader = getCanonicalSectionHeader(trimmed);
// Check for section transitions
if (canonicalHeader) {
currentSection = canonicalHeader;
currentAttachedGroup = null;
i++;
continue;
}
// Check for attached unit group headers
const attachedMatch = trimmed.match(attachedUnitHeaderRegex);
if (currentSection === 'Attached Units' && attachedMatch) {
currentAttachedGroup = {
name: `Attached Unit ${attachedMatch[2]}`,
points: 0,
category: 'Attached Units',
isAttached: true,
attachedParts: []
};
result.units.push(currentAttachedGroup);
i++;
continue;
}
// Check for unit header line, e.g. "Commander Farsight (80 points)"
const unitMatch = trimmed.match(/^(.*?)\s*\((\d+[\d,]*)\s*(?:pts|points|punkte|puntos|punti)\)$/i);
if (unitMatch) {
const unitName = unitMatch[1].trim();
const unitPoints = parseInt(unitMatch[2].replace(/,/g, ''), 10) || 0;
const blockLines = [];
i++;
// Collect all subsequent lines belonging to this unit block
while (i < cleanLines.length) {
const nextLine = cleanLines[i];
const nextTrimmed = nextLine.trim();
if (!nextTrimmed) {
i++;
continue;
}
// If next line starts a new section, attached group, or unit header, stop
if (getCanonicalSectionHeader(nextTrimmed) ||
(currentSection === 'Attached Units' && attachedUnitHeaderRegex.test(nextTrimmed)) ||
nextTrimmed.match(/^(.*?)\s*\((\d+[\d,]*)\s*(?:pts|points|punkte|puntos|punti)\)$/i) ||
/^(?:exported with app version|exporte avec la version|export|esport)/i.test(nextTrimmed)) {
break;
}
blockLines.push(nextLine);
i++;
}
const unit = parseUnitBlock(unitName, unitPoints, blockLines);
if (currentSection === 'Attached Units' && currentAttachedGroup) {
unit.category = 'Attached Units';
currentAttachedGroup.attachedParts.push(unit);
currentAttachedGroup.points += unit.points;
} else {
result.units.push(unit);
}
continue;
}
i++;
}
return result;
}
@@ -0,0 +1,255 @@
import { isWargearSkippable, parseNewRecruitHeader } from '../utils.js';
export function parseNRGW(lines, skippableWargearMap = {}) {
if (!Array.isArray(lines) || lines.length === 0) {
return {
edition: '11th',
metadata: {
title: '', armyName: '', faction: '', detachment: '', detachments: [],
pointsTotal: 0, totalPoints: 0, pointsLimit: 0, forceDispositions: [],
warlordName: '', warlordId: '', enhancements: []
},
units: []
};
}
const cleanLines = lines.map(l => l ? l.replace(/\u00a0/g, ' ') : '');
const { metadata, nextIndex } = parseNewRecruitHeader(cleanLines);
const result = {
edition: '11th',
metadata,
units: []
};
let currentUnit = null;
let currentSubunit = null;
let currentCategory = 'Other Datasheets';
let inAttachedSection = false;
let currentAttachedGroup = null;
let warlordFoundExplicitly = false;
const parseQtyAndName = (str, unitName) => {
const cleaned = str.trim();
let name = cleaned;
let quantity = 1;
const match = cleaned.match(/^(\d+)x?\s+(.*)$/i);
if (match) {
name = match[2].trim();
quantity = parseInt(match[1], 10);
}
name = name.replace(/^with\s+/i, '').trim();
const skippable = isWargearSkippable(skippableWargearMap, result.metadata.faction, unitName, name);
return {
name,
quantity,
skippable
};
};
// Add a parsed wargear item to a target list, summing into an existing entry of
// the same name instead of pushing a duplicate. The same wargear name can appear
// across multiple separate bullet lines within one unit/subunit (e.g. two "1x
// Drone burst cannon" bullets for the same model).
const addWargear = (targetArray, parsedWg) => {
const existing = targetArray.find(w => w.name === parsedWg.name);
if (existing) {
existing.quantity += parsedWg.quantity;
} else {
targetArray.push(parsedWg);
}
};
const getCategory = (str) => {
const lower = str.trim().toLowerCase();
if (lower.startsWith('character')) return 'Characters';
if (lower.startsWith('battleline')) return 'Battleline';
if (lower.startsWith('dedicated transport')) return 'Dedicated Transports';
if (lower.startsWith('other datasheet')) return 'Other Datasheets';
return null;
};
const getNextNonEmptyLineIndentAndContent = (startIndex) => {
let idx = startIndex;
while (idx < cleanLines.length) {
const line = cleanLines[idx];
const trimmed = line.trim();
if (trimmed.length > 0) {
const leadingSpaces = line.length - line.trimStart().length;
return { indent: leadingSpaces, content: trimmed };
}
idx++;
}
return null;
};
for (let i = nextIndex; i < cleanLines.length; i++) {
const line = cleanLines[i];
const trimmed = line.trim();
if (!trimmed) continue;
const leadingSpaces = line.length - line.trimStart().length;
// 1. Check for the "Attached Units" section header
if (/^attached units$/i.test(trimmed)) {
inAttachedSection = true;
currentCategory = 'Attached Units';
currentUnit = null;
currentSubunit = null;
currentAttachedGroup = null;
continue;
}
// 2. Check for Category header (also ends an "Attached Units" section, since
// a list can mix attached groups with standalone Characters/Battleline units)
const cat = getCategory(trimmed);
if (cat) {
currentCategory = cat;
currentUnit = null;
currentSubunit = null;
inAttachedSection = false;
currentAttachedGroup = null;
continue;
}
// 3. Check for an "Attached Unit N" group header within the Attached Units section
const attachedGroupMatch = inAttachedSection && trimmed.match(/^attached unit\s+(\d+)$/i);
if (attachedGroupMatch) {
currentAttachedGroup = {
name: `Attached Unit ${attachedGroupMatch[1]}`,
points: 0,
category: 'Attached Units',
isAttached: true,
attachedParts: []
};
result.units.push(currentAttachedGroup);
currentUnit = null;
currentSubunit = null;
continue;
}
// 4. Check for bulleted lines (starts with • or * or -)
const bulletMatch = trimmed.match(/^([•\*\-◦\u25e6\u2022])\s*(.*)$/);
if (bulletMatch) {
const content = bulletMatch[2].trim();
// Check for "Attached as: Leader/Bodyguard" role marker
const attachedAsMatch = content.match(/^attached as\s*:\s*(.*)$/i);
if (attachedAsMatch) {
if (currentUnit) {
currentUnit.attachedAs = attachedAsMatch[1].trim();
const roleStr = currentUnit.attachedAs.toLowerCase();
if (/leader/i.test(roleStr)) {
currentUnit.role = 'Leader';
} else if (/bodyguard/i.test(roleStr)) {
currentUnit.role = 'Bodyguard';
}
}
continue;
}
if (content.toLowerCase() === 'warlord') {
if (currentUnit) {
currentUnit.isWarlord = true;
warlordFoundExplicitly = true;
}
continue;
}
// Check for enhancement:
const enhMatch = content.match(/^(.*?)\s*\(\+(\d+)\s*(?:pts|points|pt)\)/i);
if (enhMatch) {
if (currentUnit) {
currentUnit.enhancements.push({
name: enhMatch[1].trim(),
points: parseInt(enhMatch[2], 10) || 0
});
}
continue;
}
// Subunit detection based on next line indentation
const next = getNextNonEmptyLineIndentAndContent(i + 1);
const isSubunitHeader = next && next.indent > leadingSpaces;
if (isSubunitHeader && leadingSpaces <= 2) {
// It is a subunit header
const match = content.match(/^(?:(\d+)x?\s+)?(.*)$/);
const quantity = match && match[1] ? parseInt(match[1], 10) : 1;
const name = match ? match[2].trim() : content;
currentSubunit = {
name,
quantity,
wargear: []
};
if (currentUnit) {
currentUnit.subunits.push(currentSubunit);
}
} else {
// It is wargear (unit-level or subunit-level)
const items = content.split(',').map(s => s.trim()).filter(Boolean);
items.forEach(it => {
const parsedWg = parseQtyAndName(it, currentUnit ? currentUnit.name : '');
if (currentSubunit && leadingSpaces > 2) {
addWargear(currentSubunit.wargear, parsedWg);
} else if (currentUnit) {
addWargear(currentUnit.wargear, parsedWg);
}
});
}
continue;
}
// 5. Unit Header line
const unitMatch = trimmed.match(/^(?![•\*\-\s])(.*?)\s*\((\d+)\s*(?:pts|points|pt)\)$/i);
if (unitMatch) {
const name = unitMatch[1].trim();
const points = parseInt(unitMatch[2], 10) || 0;
currentUnit = {
name,
points,
quantity: 1,
category: inAttachedSection ? 'Attached Units' : currentCategory,
wargear: [],
enhancements: [],
subunits: []
};
if (inAttachedSection && currentAttachedGroup) {
currentAttachedGroup.attachedParts.push(currentUnit);
currentAttachedGroup.points += points;
} else {
result.units.push(currentUnit);
}
currentSubunit = null; // Reset subunit context
continue;
}
}
// This format has no per-unit ID, so an explicit inline "Warlord" bullet (handled
// above) is the authoritative signal. Only fall back to matching the header's
// WARLORD name when no unit carried that marker, and flag just the first match —
// duplicate-named units (e.g. two identical Knight Castellans) must not all be
// flagged just because they share a name with the true warlord.
if (!warlordFoundExplicitly && result.metadata.warlordName) {
const wantName = result.metadata.warlordName.toLowerCase();
outer:
for (const u of result.units) {
if (Array.isArray(u.attachedParts)) {
for (const part of u.attachedParts) {
if ((part.name || '').toLowerCase() === wantName) {
part.isWarlord = true;
break outer;
}
}
} else if ((u.name || '').toLowerCase() === wantName) {
u.isWarlord = true;
break;
}
}
}
return result;
}
@@ -0,0 +1,290 @@
import { isWargearSkippable, parseNewRecruitHeader } from '../utils.js';
export function parseNRTournament(lines, skippableWargearMap = {}) {
if (!Array.isArray(lines) || lines.length === 0) {
return {
edition: '11th',
metadata: {
title: '', armyName: '', faction: '', detachment: '', detachments: [],
pointsTotal: 0, totalPoints: 0, pointsLimit: 0, forceDispositions: [],
warlordName: '', warlordId: '', enhancements: []
},
units: []
};
}
const cleanLines = lines.map(l => l ? l.replace(/\u00a0/g, ' ') : '');
const { metadata, nextIndex } = parseNewRecruitHeader(cleanLines);
const result = {
edition: '11th',
metadata,
units: []
};
let currentUnit = null;
let currentSubunit = null;
const parseQtyAndName = (str, unitName) => {
const cleaned = str.trim();
let name = cleaned;
let quantity = 1;
const match = cleaned.match(/^(\d+)x?\s+(.*)$/i);
if (match) {
name = match[2].trim();
quantity = parseInt(match[1], 10);
}
name = name.replace(/^with\s+/i, '').trim();
const skippable = isWargearSkippable(skippableWargearMap, result.metadata.faction, unitName, name);
return {
name,
quantity,
skippable
};
};
// Add a parsed wargear item to a subunit's wargear list, summing into an existing
// entry of the same name instead of pushing a duplicate. A subunit's loadout is
// often split across several "N with ..." lines/segments (e.g. rank-and-file vs.
// an icon bearer), so the same wargear name can legitimately appear more than once.
const addWargear = (targetArray, parsedWg) => {
const existing = targetArray.find(w => w.name === parsedWg.name);
if (existing) {
existing.quantity += parsedWg.quantity;
} else {
targetArray.push(parsedWg);
}
};
for (let i = nextIndex; i < cleanLines.length; i++) {
const line = cleanLines[i];
const trimmed = line.trim();
if (!trimmed) continue;
// 1. Enhancement line
const enhMatch = trimmed.match(/^(?:•?\s*)?Enhancement\s*:\s*(.*?)\s*\(\+(\d+)\s*(?:pts|points|pt)\)/i);
if (enhMatch) {
if (currentUnit) {
currentUnit.enhancements.push({
name: enhMatch[1].trim(),
points: parseInt(enhMatch[2], 10) || 0
});
}
continue;
}
// 2. Subunit line (starts with • or *)
if (trimmed.startsWith('•') || trimmed.startsWith('*')) {
const subContent = trimmed.substring(1).trim();
// Check if it has inline wargear via colon:
const colonIdx = subContent.indexOf(':');
if (colonIdx !== -1) {
const subHeader = subContent.substring(0, colonIdx).trim();
const itemsStr = subContent.substring(colonIdx + 1).trim();
const match = subHeader.match(/^(?:(\d+)x?\s+)?(.*)$/);
const quantity = match && match[1] ? parseInt(match[1], 10) : 1;
const name = match ? match[2].trim() : subHeader;
currentSubunit = {
name,
quantity,
wargear: []
};
// An inline subunit's item text can itself lead with a "N with ..."
// model-count multiplier (e.g. "2x Shas'ui: 2 with Gun Drone, Plasma rifle")
// when all N models in the subunit share one loadout. Distinguish that
// from a per-item "Nx Item" quantity on the first item (no "with").
const modelPrefixMatch = itemsStr.match(/^(\d+)\s+with\s+(.*)$/i);
const modelMultiplier = modelPrefixMatch ? (parseInt(modelPrefixMatch[1], 10) || 1) : 1;
const rawItemsStr = modelPrefixMatch ? modelPrefixMatch[2] : itemsStr;
const items = rawItemsStr.split(',').map(s => s.trim()).filter(Boolean);
items.forEach(it => {
const parsedWg = parseQtyAndName(it, currentUnit ? currentUnit.name : '');
parsedWg.quantity = parsedWg.quantity * modelMultiplier;
addWargear(currentSubunit.wargear, parsedWg);
});
if (currentUnit) {
currentUnit.subunits.push(currentSubunit);
}
// Inline subunit is done
currentSubunit = null;
} else {
// Multi-line subunit header
const match = subContent.match(/^(?:(\d+)x?\s+)?(.*)$/);
const quantity = match && match[1] ? parseInt(match[1], 10) : 1;
const name = match ? match[2].trim() : subContent;
currentSubunit = {
name,
quantity,
wargear: []
};
if (currentUnit) {
currentUnit.subunits.push(currentSubunit);
}
}
continue;
}
// 3. Model detail line (indented under a subunit), or an "Attached to <Character>"
// backlink identifying which character leads this unit
if (line.startsWith(' ') || line.startsWith('\t')) {
// The target name may carry a "[N]" disambiguation index (e.g.
// "Attached to Slaughterbound[3]") when multiple characters in the
// list share the same name — New Recruit numbers them by order of
// appearance so the backlink can point at the right one.
const attachedToMatch = trimmed.match(/^Attached to\s+(.+?)(?:\[(\d+)\])?$/i);
if (attachedToMatch) {
if (currentUnit) {
currentUnit._attachedToName = attachedToMatch[1].trim();
currentUnit._attachedToOrdinal = attachedToMatch[2] ? parseInt(attachedToMatch[2], 10) : null;
}
continue;
}
const detailMatch = trimmed.match(/^(\d+)(?:\s+with\s+)?(.*)$/i);
if (detailMatch && currentSubunit) {
const modelQty = parseInt(detailMatch[1], 10) || 1;
const itemsStr = detailMatch[2].trim();
const items = itemsStr.split(',').map(s => s.trim()).filter(Boolean);
items.forEach(it => {
const parsedWg = parseQtyAndName(it, currentUnit ? currentUnit.name : '');
parsedWg.quantity = parsedWg.quantity * modelQty;
addWargear(currentSubunit.wargear, parsedWg);
});
}
continue;
}
// 4. Unit Header line
const unitMatch = trimmed.match(/^(?:([a-zA-Z0-9]+):\s*)?(?:(\d+)x?\s+)?(.*?)\s*\((\d+)\s*(?:pts|points|pt)\)(?:\s*:\s*(.*))?$/i);
if (unitMatch) {
const idPrefix = unitMatch[1] ? unitMatch[1].trim() : '';
const quantity = unitMatch[2] ? parseInt(unitMatch[2], 10) : 1;
const name = unitMatch[3].trim();
const points = parseInt(unitMatch[4], 10) || 0;
const inlineDetails = unitMatch[5] ? unitMatch[5].trim() : '';
let category = 'Other Datasheets';
if (idPrefix.toLowerCase().startsWith('char')) {
category = 'Characters';
}
currentUnit = {
name,
points,
quantity,
category,
wargear: [],
enhancements: [],
subunits: []
};
// Check if is Warlord via header metadata. When the header gave a Char/Infa
// ID for the warlord, that ID is authoritative — matching by name alone
// would also flag other units sharing that name (e.g. two identically
// named characters where only one is the warlord). Only fall back to a
// name match when the header didn't supply an ID at all.
if (result.metadata.warlordId) {
if (idPrefix === result.metadata.warlordId) {
currentUnit.isWarlord = true;
}
} else if (result.metadata.warlordName && name.toLowerCase() === result.metadata.warlordName.toLowerCase()) {
currentUnit.isWarlord = true;
}
if (inlineDetails) {
const parts = inlineDetails.split(',').map(s => s.trim()).filter(Boolean);
parts.forEach(p => {
if (p.toLowerCase() === 'warlord') {
currentUnit.isWarlord = true;
} else {
addWargear(currentUnit.wargear, parseQtyAndName(p, name));
}
});
}
result.units.push(currentUnit);
currentSubunit = null; // Reset subunit context
continue;
}
}
// Merge each unit carrying an "Attached to <Character>" backlink into its
// leading character, replacing the character's slot with a combined
// { isAttached, attachedParts: [leader, bodyguard] } wrapper (the shape the
// renderers already understand from the GW App parser). This format has no
// explicit attachment-group section in the source, so the backlink is the
// only signal available; matching is done purely by character name, since
// it's unambiguous on its own (unlike the forward "Leading X[N]" hint on the
// character, which is redundant for merging and is intentionally left unparsed).
const consumedCharIndices = new Set();
const consumedSquadIndices = new Set();
const mergedByCharIndex = new Map();
result.units.forEach((squadUnit, squadIndex) => {
if (!squadUnit._attachedToName) return;
const targetName = squadUnit._attachedToName.toLowerCase();
const ordinal = squadUnit._attachedToOrdinal;
let charIndex = -1;
if (ordinal) {
// The ordinal counts the Nth same-named unit across the whole list
// (New Recruit's own numbering), so it must be resolved against
// every matching unit in original order, not just unconsumed ones.
let occurrence = 0;
for (let idx = 0; idx < result.units.length; idx++) {
const u = result.units[idx];
if (idx !== squadIndex && !u._attachedToName && u.name.toLowerCase() === targetName) {
occurrence++;
if (occurrence === ordinal) {
charIndex = idx;
break;
}
}
}
if (charIndex !== -1 && consumedCharIndices.has(charIndex)) charIndex = -1;
} else {
charIndex = result.units.findIndex((u, idx) =>
idx !== squadIndex &&
!consumedCharIndices.has(idx) &&
!u._attachedToName &&
u.name.toLowerCase() === targetName
);
}
if (charIndex === -1) return;
const charUnit = result.units[charIndex];
delete squadUnit._attachedToName;
delete squadUnit._attachedToOrdinal;
mergedByCharIndex.set(charIndex, {
name: charUnit.name,
points: (charUnit.points || 0) + (squadUnit.points || 0),
category: 'Attached Units',
isAttached: true,
attachedParts: [
{ ...charUnit, role: 'Leader' },
{ ...squadUnit, role: 'Bodyguard' }
]
});
consumedCharIndices.add(charIndex);
consumedSquadIndices.add(squadIndex);
});
if (mergedByCharIndex.size > 0) {
result.units = result.units
.map((u, idx) => mergedByCharIndex.get(idx) || u)
.filter((u, idx) => !consumedSquadIndices.has(idx));
}
// Clean up the scratch fields from any unmatched (orphaned) backlink
result.units.forEach(u => { delete u._attachedToName; delete u._attachedToOrdinal; });
return result;
}
@@ -0,0 +1,111 @@
/**
* 11th Edition List Format Detector
*/
export function detectV11Format(lines) {
if (!Array.isArray(lines) || lines.length === 0) return 'UNKNOWN';
// Check for New Recruit format header (starts with a block of pluses)
const firstNonEmptyIndex = lines.findIndex(l => l.trim().length > 0);
const hasNrHeader = firstNonEmptyIndex !== -1 && lines[firstNonEmptyIndex].trim().startsWith('+++');
if (hasNrHeader) {
let headerEndIndex = 0;
for (let i = firstNonEmptyIndex + 1; i < lines.length; i++) {
if (lines[i].trim().startsWith('+++')) {
headerEndIndex = i;
break;
}
}
const bodyLines = lines.slice(headerEndIndex + 1).map(l => l.trim()).filter(Boolean);
// Check for the Tournament export style (New Recruit's current name for
// what this codebase used to call "WTC-Compact"; the standalone "WTC"
// format this used to distinguish from ("NR_WTC") no longer exists as a
// New Recruit export option and has been removed).
const hasCompactColon = bodyLines.some(l =>
/^[A-Za-z0-9:\s\*-]+?\(\d+\s*(?:pts|points|pt)\):\s*\S+/i.test(l)
);
if (hasCompactColon) {
return 'NR_TOURNAMENT';
}
return 'NR_GW';
}
// Check if any line in the file indicates a GW App export
const hasGwAppMarker = lines.some(l =>
/^Export.*(?:App.*Version|Version.*App)/i.test(l) ||
/Version.*D(?:onné|ata|aten|ato)/i.test(l)
);
if (hasGwAppMarker) {
return 'GW_APP_V11';
}
// Check if any line indicates a War Organ export
const hasWarOrganMarker = lines.some(l =>
/warorgan/i.test(l)
);
if (hasWarOrganMarker) {
return 'WAR_ORGAN_V11';
}
const first10NonEmpty = lines
.map(l => l.trim())
.filter(Boolean)
.slice(0, 10);
if (first10NonEmpty.length >= 3) {
const line0 = first10NonEmpty[0];
const hasPointsHeader = /^.+?\s*[\[\(]\d+\s*(?:points|pts)[\]\)]$/i.test(line0);
const hasBattleSize = first10NonEmpty.some(l =>
/^(?:Battle Size:\s*)?(?:Strike Force|Incursion|Onslaught|Combat Patrol|StrikeForce)\s*\(\d+\s*(?:point limit|point|points|pts)\)$/i.test(l)
);
if (hasPointsHeader && hasBattleSize) {
return 'WAR_ORGAN_V11';
}
}
// Look at first 15 lines for generic V11 headers
const first15 = lines
.slice(0, 15)
.map(l => l.trim().toLowerCase())
.filter(Boolean);
// If it explicitly says 11th Edition or has generic V11 tags
const hasGenericV11 = first15.some(l =>
l.includes('11th edition') ||
l.includes('v11') ||
/^\[[^\]]+\]\s+.*?\(\d+\s*(?:pts|points|punkte|puntos|punti)\)$/i.test(l)
);
if (hasGenericV11) {
return 'V11_GENERIC';
}
// Fallback detection for GW App if the app version string was omitted
const hasFaction = first15.some(l => l.startsWith('faction:'));
const hasDetachment = first15.some(l => l.startsWith('detachment:'));
if (hasFaction || hasDetachment) {
return 'V11_GENERIC';
}
// Heuristics for GW App headers:
// e.g. third line is faction (T'au Empire, World Eaters, etc.)
// and fourth line is battle size like "Strike Force (2000 points)"
const hasBattleSize = first15.some(l =>
l.includes('strike force (') ||
l.includes('incursion (') ||
l.includes('onslaught (') ||
/^(?:strike force|incursion|onslaught|force de frappe|fuerza de combate|einsatzverband|forza d'attacco|incursione|scharmützel|assalto|embate|offensive|ansturm)\s*\(\d+\s*(?:pts|points|punkte|puntos|punti)\)/i.test(l) ||
(!l.includes('v11') && !l.includes('edition') && !l.includes('faction') && !l.includes('detachment') && /\(\d+\s*(?:pts|points|punkte|puntos|punti)\)/i.test(l))
);
if (hasBattleSize) {
return 'GW_APP_V11';
}
return 'UNKNOWN';
}
@@ -0,0 +1,160 @@
import { isWargearSkippable } from '../utils.js';
export function parseV11List(lines, skippableWargearMap = {}) {
const result = {
edition: '11th',
metadata: {
title: '',
faction: '',
detachment: '',
pointsLimit: 0,
pointsTotal: 0
},
units: []
};
if (!Array.isArray(lines)) return result;
let currentUnit = null;
// Helper to parse quantity and name: e.g. "2x Storm Bolter" -> { name: "Storm Bolter", quantity: 2 }
const parseQtyAndName = (str, unitName) => {
const cleaned = str.trim();
let name = cleaned;
let quantity = 1;
const match = cleaned.match(/^(\d+)x?\s+(.*)$/i);
if (match) {
name = match[2].trim();
quantity = parseInt(match[1], 10);
}
const skippable = isWargearSkippable(skippableWargearMap, result.metadata.faction, unitName, name);
return {
name,
quantity,
skippable
};
};
for (let line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// 1. Parse Metadata / Headers
if (trimmed.startsWith('===') && trimmed.endsWith('===')) {
result.metadata.title = trimmed.replace(/===/g, '').trim();
continue;
}
const metaMatch = trimmed.match(/^(Faction|Facci[oó]n|Fraktion|Fazione|Detachment|D[eé]tachement|Destacamento|Kontingent|Distaccamento|Points|Puntos|Punkte|Punti):\s*(.*)$/i);
if (metaMatch) {
const rawKey = metaMatch[1].toLowerCase();
const val = metaMatch[2].trim();
let key = '';
if (/^(faction|facci[oó]n|fraktion|fazione)$/i.test(rawKey)) {
key = 'faction';
} else if (/^(detachment|d[eé]tachement|destacamento|kontingent|distaccamento)$/i.test(rawKey)) {
key = 'detachment';
} else if (/^(points|puntos|punkte|punti)$/i.test(rawKey)) {
key = 'points';
}
if (key === 'faction') {
result.metadata.faction = val;
} else if (key === 'detachment') {
result.metadata.detachment = val;
} else if (key === 'points') {
// e.g. "1990 / 2000" or "1990"
const parts = val.split('/');
result.metadata.pointsTotal = parseInt(parts[0].trim(), 10) || 0;
if (parts[1]) {
result.metadata.pointsLimit = parseInt(parts[1].trim(), 10) || 0;
}
}
continue;
}
// 2. Parse Unit Header
// e.g. "[Leader] Captain in Terminator Armour (95 pts)" or "[Line] 5x Terminator Squad (185 pts)"
const unitMatch = trimmed.match(/^\[([^\]]+)\]\s+(?:(\d+)x?\s+)?(.*?)\s*\((\d+)\s*(?:pts|points|punkte|puntos|punti)\)$/i);
if (unitMatch) {
const category = unitMatch[1].trim();
const quantity = unitMatch[2] ? parseInt(unitMatch[2], 10) : 1;
const name = unitMatch[3].trim();
const points = parseInt(unitMatch[4], 10) || 0;
currentUnit = {
name,
points,
quantity,
category,
wargear: [],
enhancements: [],
subunits: []
};
result.units.push(currentUnit);
continue;
}
// If we don't have a unit context yet, skip item parsing
if (!currentUnit) continue;
// 3. Parse Unit Wargear
// e.g. "- Wargear: Relic Weapon, Storm Bolter"
const wargearPrefixRegex = /^-\s*(wargear|equipement|[eé]quipement|equipo|equipamiento|ausrustung|ausr[uü]stung|equipaggiamento)\s*:/i;
if (wargearPrefixRegex.test(trimmed)) {
const itemsStr = trimmed.substring(trimmed.indexOf(':') + 1).trim();
const items = itemsStr.split(',').map(s => s.trim()).filter(Boolean);
items.forEach(it => {
currentUnit.wargear.push(parseQtyAndName(it, currentUnit.name));
});
continue;
}
// 4. Parse Enhancement
// e.g. "- Enhancement: Artificer Armour (10 pts)"
const enhancementPrefixRegex = /^-\s*(enhancement|optimisation|mejora|aufwertung|verbesserung|potenziamento)\s*:/i;
if (enhancementPrefixRegex.test(trimmed)) {
const enhContent = trimmed.substring(trimmed.indexOf(':') + 1).trim();
const enhMatch = enhContent.match(/^(.*?)\s*\((\d+)\s*(?:pts|points|punkte|puntos|punti)\)$/i);
if (enhMatch) {
currentUnit.enhancements.push({
name: enhMatch[1].trim(),
points: parseInt(enhMatch[2], 10) || 0
});
} else {
currentUnit.enhancements.push({
name: enhContent,
points: 0
});
}
continue;
}
// 5. Parse Subunits / Models
// e.g. "* 1x Terminator Sergeant: Power Weapon, Storm Bolter"
// or "* 4x Terminator: 4x Power Fist, 4x Storm Bolter"
const subunitMatch = trimmed.match(/^\*\s+(\d+)x?\s+([^:]+):\s*(.*)$/i);
if (subunitMatch) {
const qty = parseInt(subunitMatch[1], 10) || 1;
const subName = subunitMatch[2].trim();
const itemsStr = subunitMatch[3].trim();
const items = itemsStr.split(',').map(s => s.trim()).filter(Boolean);
const subunit = {
name: subName,
quantity: qty,
wargear: []
};
items.forEach(it => {
subunit.wargear.push(parseQtyAndName(it, currentUnit.name));
});
currentUnit.subunits.push(subunit);
continue;
}
}
return result;
}
@@ -0,0 +1,395 @@
import { isWargearSkippable } from '../utils.js';
export function parseWarOrganV11(lines, skippableWargearMap = {}) {
const result = {
edition: '11th',
metadata: {
title: '',
armyName: '',
pointsTotal: 0,
totalPoints: 0,
faction: '',
battleSize: '',
pointsLimit: 0,
detachment: '',
detachments: [],
forceDispositions: []
},
units: []
};
if (!Array.isArray(lines) || lines.length === 0) return result;
const cleanLines = lines.map(l => l.trimEnd());
const nonEmptyLines = cleanLines.filter(l => l.trim().length > 0);
if (nonEmptyLines.length < 2) return result;
// Helper to parse quantity and name: e.g. "2x Storm Bolter" -> { name: "Storm Bolter", quantity: 2 }
const parseQtyAndName = (str, unitName) => {
const cleaned = str.trim();
let name = cleaned;
let quantity = 1;
const match = cleaned.match(/^(\d+)x?\s+(.*)$/i);
if (match) {
name = match[2].trim();
quantity = parseInt(match[1], 10);
}
const skippable = isWargearSkippable(skippableWargearMap, result.metadata.faction, unitName, name);
return {
name,
quantity,
skippable
};
};
// Helper to split wargear items by comma and 'and'
const splitWargearItems = (str) => {
const normalized = str.replace(/\s+and\s+/ig, ', ');
return normalized.split(',').map(s => s.trim()).filter(Boolean).map(item => {
// War Organ names some drones after their built-in weapon (e.g. "Gun
// drone with twin pulse carbine"); other formats just use the base name
// ("Gun Drone"). Keep only the part before "with" so names match across formats.
const withMatch = item.match(/^(.*?)\s+with\s+(.*)$/i);
return withMatch ? withMatch[1].trim() : item;
});
};
// 1. Parse Metadata Headers
// Title and Total Points (1st non-empty line)
const firstLine = nonEmptyLines[0].trim();
const titleMatch = firstLine.match(/^(.+?)\s*[\[\(](\d+)\s*(?:points|pts)[\]\)]$/i);
if (titleMatch) {
result.metadata.title = titleMatch[1].trim();
result.metadata.armyName = titleMatch[1].trim();
result.metadata.pointsTotal = parseInt(titleMatch[2], 10) || 0;
result.metadata.totalPoints = parseInt(titleMatch[2], 10) || 0;
} else {
result.metadata.title = firstLine;
result.metadata.armyName = firstLine;
}
// Faction (2nd non-empty line)
result.metadata.faction = nonEmptyLines[1].trim();
// Battle Size & Limit (3rd non-empty line)
if (nonEmptyLines[2]) {
const battleSizeLine = nonEmptyLines[2].trim();
const battleMatch = battleSizeLine.match(/^(?:Battle Size:\s*)?(.+?)\s*\((\d+)\s*(?:point limit|point|points|pts)\)$/i);
if (battleMatch) {
result.metadata.battleSize = battleMatch[1].trim();
result.metadata.pointsLimit = parseInt(battleMatch[2], 10) || 0;
} else {
result.metadata.battleSize = battleSizeLine;
}
}
// Detachments (4th non-empty line)
if (nonEmptyLines[3]) {
const detachmentLine = nonEmptyLines[3].trim();
const detMatch = detachmentLine.match(/^(?:Detachments:\s*)(.*)$/i);
let detStr = detMatch ? detMatch[1].trim() : detachmentLine;
// Strip a trailing "(N Detachment Points)" or "(N/N Detachment Points)" suffix
detStr = detStr.replace(/\s*\(\d+(?:\/\d+)?\s*Detachment\s*Points\)\s*$/i, '').trim();
result.metadata.detachment = detStr;
result.metadata.detachments = detStr.split(',').map(d => d.trim()).filter(Boolean);
}
// Determine scan start index in the original lines array
let nonEmptyCount = 0;
let scanStartIndex = 0;
for (let idx = 0; idx < lines.length; idx++) {
if (lines[idx].trim().length > 0) {
nonEmptyCount++;
if (nonEmptyCount === 4) {
scanStartIndex = idx + 1;
break;
}
}
}
// Force Disposition is an optional 5th metadata line (not all exports include it).
// Peek at the next non-empty line after Detachments; only consume it if it actually
// looks like a Force Disposition line, so files without one fall through to body scanning.
for (let idx = scanStartIndex; idx < cleanLines.length; idx++) {
const peekTrimmed = cleanLines[idx].trim();
if (peekTrimmed.length === 0) continue;
const fdMatch = peekTrimmed.match(/^Force\s*Disposition:\s*(.*)$/i);
if (fdMatch) {
result.metadata.forceDispositions = fdMatch[1].trim().split(',').map(d => d.trim()).filter(Boolean);
scanStartIndex = idx + 1;
}
break;
}
// Category mappings for Format 2
const categoryMap = {
'CHARACTER': 'Characters',
'BATTLELINE': 'Battleline',
'DEDICATED TRANSPORTS': 'Dedicated Transports',
'OTHER DATASHEETS': 'Other Datasheets',
'ALLY CHARACTERS': 'Characters',
'ALLY OTHER DATASHEETS': 'Other Datasheets'
};
function guessCategory(unitName) {
const name = unitName.toLowerCase();
const charKeywords = ['captain', 'canoness', 'hospitaller', 'palatine', 'commander', 'castellan', 'prime', 'lord', 'champion', 'priest', 'inquisitor', 'celestine', 'vahl'];
if (charKeywords.some(kw => name.includes(kw))) {
return 'Characters';
}
const transportKeywords = ['rhino', 'chimera', 'imprulsor', 'repulsor', 'land raider', 'drop pod', 'transport', 'devilfish', 'taurox'];
if (transportKeywords.some(kw => name.includes(kw))) {
return 'Dedicated Transports';
}
return 'Other Datasheets';
}
// Detect format type (Format 2 has deeper indentation or "Enhancement:" lines).
// The category-header check is intentionally case-sensitive (matching categoryMap's
// own case-sensitive lookup below): newer War Organ exports generate randomized,
// mixed-case "group name" headers for attached units (e.g. "Character", "Vehicle",
// "Khorne Trouble Team") that must not be confused with the real ALL-CAPS Format 2
// category headers ("CHARACTER", "BATTLELINE", "DEDICATED TRANSPORTS").
const isFormat2 = lines.some(l => /^\s*(?:CHARACTER|BATTLELINE|DEDICATED TRANSPORTS|OTHER DATASHEETS)\s*$/.test(l))
|| lines.some(l => /^\s*\s*Enhancement:/i.test(l))
|| !lines.some(l => /^\s*Battle Size:/i.test(l));
let currentSection = 'Other Datasheets';
let currentUnit = null;
if (isFormat2) {
// --- Format 2 (Indented Tree structure) ---
let i = scanStartIndex;
while (i < cleanLines.length) {
const line = cleanLines[i];
const trimmed = line.trim();
if (!trimmed) {
i++;
continue;
}
// Check if it's a category header
if (categoryMap[trimmed]) {
currentSection = categoryMap[trimmed];
i++;
continue;
}
// Check for unit header, e.g. "Canoness With Jump Pack (85 points)"
const unitMatch = trimmed.match(/^(?:(\d+)x?\s+)?(.*?)\s*[\[\(](\d+)\s*(?:points|pts)[\]\)]$/i);
if (unitMatch && !trimmed.startsWith('•')) {
const qty = unitMatch[1] ? parseInt(unitMatch[1], 10) : 1;
const unitName = unitMatch[2].trim();
const unitPoints = parseInt(unitMatch[3], 10) || 0;
currentUnit = {
name: unitName,
points: unitPoints,
quantity: qty,
category: currentSection,
wargear: [],
enhancements: [],
subunits: []
};
result.units.push(currentUnit);
i++;
// Collect block lines for this unit
const blockLines = [];
while (i < cleanLines.length) {
const nextLine = cleanLines[i];
const nextTrimmed = nextLine.trim();
if (!nextTrimmed) {
i++;
continue;
}
// Stop if we hit another unit header or category header
if (categoryMap[nextTrimmed] ||
(!nextTrimmed.startsWith('•') && nextTrimmed.match(/^(?:(\d+)x?\s+)?(.*?)\s*[\[\(](\d+)\s*(?:points|pts)[\]\)]$/i))) {
break;
}
blockLines.push(nextLine);
i++;
}
// Process tree
if (blockLines.length > 0) {
const root = { content: 'Root', indent: -1, children: [] };
const stack = [root];
blockLines.forEach(bl => {
const leadingSpaces = bl.length - bl.trimStart().length;
const blTrimmed = bl.trim();
const bulletMatch = blTrimmed.match(/^([•◦\u25e6\u2022])\s*(.*)$/);
const hasBullet = !!bulletMatch;
const content = bulletMatch ? bulletMatch[2].trim() : blTrimmed;
const node = {
content,
indent: leadingSpaces,
hasBullet,
children: []
};
while (stack.length > 1 && stack[stack.length - 1].indent >= node.indent) {
stack.pop();
}
stack[stack.length - 1].children.push(node);
stack.push(node);
});
// Helper to collect wargear recursively
const collectWargearRecursive = (node, targetArray) => {
const parsed = parseQtyAndName(node.content, currentUnit.name);
targetArray.push(parsed);
node.children.forEach(child => {
collectWargearRecursive(child, targetArray);
});
};
// Process Root children (level 1 indentation)
root.children.forEach(node => {
const content = node.content;
const contentLower = content.toLowerCase();
if (contentLower === 'warlord') {
currentUnit.isWarlord = true;
} else if (contentLower.startsWith('enhancement:')) {
const enhName = content.substring(content.indexOf(':') + 1).trim();
currentUnit.enhancements.push({
name: enhName,
points: 0
});
} else {
// Subunit if it has bulleted children, otherwise unit-level wargear
const hasChildren = node.children.length > 0;
if (hasChildren) {
const parsedSub = parseQtyAndName(content, currentUnit.name);
const subunit = {
name: parsedSub.name,
quantity: parsedSub.quantity,
wargear: []
};
node.children.forEach(child => {
collectWargearRecursive(child, subunit.wargear);
});
currentUnit.subunits.push(subunit);
} else {
collectWargearRecursive(node, currentUnit.wargear);
}
}
});
}
continue;
}
i++;
}
} else {
// --- Format 1 (Flat lists using "with" and "[+XX points]") ---
let i = scanStartIndex;
while (i < cleanLines.length) {
const line = cleanLines[i];
const trimmed = line.trim();
if (!trimmed) {
i++;
continue;
}
// Check for unit header
const unitMatch = trimmed.match(/^(?:(\d+)x?\s+)?(.*?)\s*[\[\(](\d+)\s*(?:points|pts)[\]\)]$/i);
if (unitMatch && !trimmed.startsWith('•')) {
const qty = unitMatch[1] ? parseInt(unitMatch[1], 10) : 1;
const unitName = unitMatch[2].trim();
const unitPoints = parseInt(unitMatch[3], 10) || 0;
currentUnit = {
name: unitName,
points: unitPoints,
quantity: qty,
category: guessCategory(unitName),
wargear: [],
enhancements: [],
subunits: []
};
result.units.push(currentUnit);
i++;
while (i < cleanLines.length) {
const nextLine = cleanLines[i];
const nextTrimmed = nextLine.trim();
if (!nextTrimmed) {
i++;
continue;
}
// Stop if we hit another unit header
if (!nextTrimmed.startsWith('•') && nextTrimmed.match(/^(?:(\d+)x?\s+)?(.*?)\s*[\[\(](\d+)\s*(?:points|pts)[\]\)]$/i)) {
break;
}
const bulletMatch = nextTrimmed.match(/^•\s*(.*)$/);
if (bulletMatch) {
const content = bulletMatch[1].trim();
const contentLower = content.toLowerCase();
if (contentLower === 'warlord') {
currentUnit.isWarlord = true;
} else {
// Check if it's a subunit line: e.g. "1 Sacresant Superior with Plasma pistol and Spear of the faithful"
const subunitMatch = content.match(/^(\d+)\s+(.+?)\s+with\s+(.+)$/i);
if (subunitMatch) {
const subQty = parseInt(subunitMatch[1], 10);
const subName = subunitMatch[2].trim();
const subWargearStr = subunitMatch[3].trim();
const subunit = {
name: subName,
quantity: subQty,
wargear: []
};
const items = splitWargearItems(subWargearStr);
items.forEach(it => {
// Format 1 wargear items carry a per-model quantity (e.g. "Bolt
// pistol" implicitly means 1 each); multiply by the subunit's
// own model count so totals match Format 2's pre-multiplied convention.
const parsedWg = parseQtyAndName(it, currentUnit.name);
parsedWg.quantity = parsedWg.quantity * subQty;
subunit.wargear.push(parsedWg);
});
currentUnit.subunits.push(subunit);
} else {
// Unit-level wargear and/or enhancements
const items = splitWargearItems(content);
items.forEach(it => {
// Check for enhancement tag [+XX points]
const enhMatch = it.match(/^(.+?)\s*[\[\(]\+(\d+)\s*(?:points|pts)[\]\)]$/i);
if (enhMatch) {
currentUnit.enhancements.push({
name: enhMatch[1].trim(),
points: parseInt(enhMatch[2], 10) || 0
});
} else {
currentUnit.wargear.push(parseQtyAndName(it, currentUnit.name));
}
});
}
}
}
i++;
}
continue;
}
i++;
}
}
return result;
}
@@ -0,0 +1,784 @@
// Renderers for 11th Edition JSON structure.
import { makeAbbrevForName } from './abbreviations.js';
import factionColors from './faction_colors.js';
import { sortItemsByQuantityThenName, getModelsCount, getCanonicalFactionName, normalizeWargearName, shouldHideSubunitsForUnit } from './utils.js';
export function abbreviateWords(str) {
if (!str) return '';
const words = str.split(/\s+/);
const lowercaseWords = ['the', 'of', 'in', 'on', 'at', 'for', 'a', 'an', 'to', 'by', 'with', 'and'];
const abbr = words.map(w => {
const cleaned = w.replace(/[^\w]/g, '');
if (!cleaned) return '';
const first = cleaned[0];
if (lowercaseWords.includes(cleaned.toLowerCase())) {
return first.toLowerCase();
}
return first.toUpperCase();
}).join('');
return abbr;
}
export function abbreviateDetachment(detStr) {
if (!detStr) return '';
const parts = detStr.split(/\s+and\s+/i);
return parts.map(p => abbreviateWords(p.trim())).join(' & ');
}
export function abbreviateForceDisposition(dispStr) {
if (!dispStr) return '';
const parts = dispStr.split(',');
return parts.map(p => abbreviateWords(p.trim())).join(', ');
}
export const ansiPalette = [
{ hex: '#000000', code: 30 }, { hex: '#FF0000', code: 31 }, { hex: '#00FF00', code: 32 },
{ hex: '#FFFF00', code: 33 }, { hex: '#0000FF', code: 34 }, { hex: '#FF00FF', code: 35 },
{ hex: '#00FFFF', code: 36 }, { hex: '#FFFFFF', code: 37 }, { hex: '#808080', code: 90 }
];
export const colorNameToHex = {
black: '#000000', red: '#FF0000', green: '#00FF00', yellow: '#FFFF00', blue: '#0000FF',
magenta: '#FF00FF', cyan: '#00FFFF', white: '#FFFFFF', grey: '#808080'
};
const hexToRgb = (hex) => {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return m ? { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) } : null;
};
const findClosestAnsi = (hex) => {
const rgb = hexToRgb(hex);
if (!rgb) return 37;
let best = 37;
let bestD = Infinity;
for (const c of ansiPalette) {
const cr = hexToRgb(c.hex);
const d = Math.pow(rgb.r - cr.r, 2) + Math.pow(rgb.g - cr.g, 2) + Math.pow(rgb.b - cr.b, 2);
if (d < bestD) {
bestD = d;
best = c.code;
}
}
return best;
};
export function buildFactionColorMap(skippableMap) {
const fallback = (map) => {
const codes = [...new Set(ansiPalette.map(p => p.code))];
const out = {};
for (const k of Object.keys(map || {})) {
const fk = k || '';
const idx = Math.abs(Array.from(fk).reduce((a, c) => a * 31 + c.charCodeAt(0), 0)) % codes.length;
const unitCode = codes[idx];
const pickDifferent = (forbidden) => codes.find(c => !forbidden.includes(c)) || codes[0];
const subunitCode = pickDifferent([unitCode]);
const wargearCode = pickDifferent([subunitCode]);
const pointsCode = pickDifferent([wargearCode]);
const attachedCode = pickDifferent([pointsCode]);
const codeToHex = (code) => {
const e = ansiPalette.find(p => p.code === code);
return e ? e.hex : '#FFFFFF';
};
out[fk] = { unit: codeToHex(unitCode), subunit: codeToHex(subunitCode), wargear: codeToHex(wargearCode), points: codeToHex(pointsCode), attached: codeToHex(attachedCode) };
}
return out;
};
const explicit = factionColors || {};
const fromSkippable = fallback(skippableMap || {});
const merged = { ...fromSkippable, ...explicit };
const normalized = {};
const normalizeKey = (s) => {
if (!s) return '';
try {
return s.toString().normalize('NFD')
.replace(/\p{M}/gu, '')
.replace(/[\u2018\u2019\u201B\u2032]/g, "'")
.replace(/[^\w\s'\-]/g, '')
.toLowerCase().trim();
} catch (e) {
return s.toString().toLowerCase().trim();
}
};
for (const [k, v] of Object.entries(merged)) {
const resolved = {};
['unit', 'subunit', 'wargear', 'points', 'header', 'attached'].forEach(prop => {
if (!v || v[prop] === undefined) return;
const raw = v[prop];
if (typeof raw === 'string' && raw.startsWith('#')) resolved[prop] = raw;
else if (typeof raw === 'string' && colorNameToHex[raw.toString().toLowerCase()]) resolved[prop] = colorNameToHex[raw.toString().toLowerCase()];
else resolved[prop] = raw;
});
normalized[k] = resolved;
try { normalized[k.toString().toLowerCase()] = resolved; } catch (e) {}
try { normalized[normalizeKey(k)] = resolved; } catch (e) {}
}
return normalized;
}
function aggregateWargear(unit, excludeSubunits = false) {
const aggregated = new Map();
const addItem = (wg) => {
const key = normalizeWargearName(wg.name);
const qty = parseInt(wg.quantity || 1, 10);
const prev = aggregated.get(key) || { name: wg.name, quantity: 0, skippable: !!wg.skippable };
aggregated.set(key, { name: prev.name, quantity: prev.quantity + qty, skippable: prev.skippable || !!wg.skippable });
};
// 1. Add unit's own wargear
if (Array.isArray(unit.wargear)) {
unit.wargear.forEach(addItem);
}
// 2. Add subunits' wargear
if (!excludeSubunits && Array.isArray(unit.subunits)) {
unit.subunits.forEach(sub => {
if (Array.isArray(sub.wargear)) {
sub.wargear.forEach(addItem);
}
});
}
const wargearList = Array.from(aggregated.values()).map(info => ({
name: info.name,
quantity: `${info.quantity}x`,
skippable: info.skippable,
type: 'wargear'
}));
sortItemsByQuantityThenName(wargearList);
return wargearList;
}
function findAbbreviationForItem(itemName, wargearAbbrMap, dataSummary) {
if (!wargearAbbrMap || !itemName) return null;
const key = normalizeWargearName(itemName);
const extractAbbr = (val) => {
if (!val) return null;
if (typeof val === 'string') return val;
if (typeof val === 'object') return val.abbr || val.ABBR || null;
return null;
};
try {
const flat = wargearAbbrMap.__flat_abbr;
if (flat && flat[key] !== undefined) return extractAbbr(flat[key]);
} catch (e) {}
return null;
}
function getInlineItemsString(unit, useAbbreviations, wargearAbbrMap, dataSummary, skippableWargearMap, showMandatoryWargear = false, hideSubunits = false, wargearShowMode = undefined, hideBrackets = false) {
const specials = [];
const wargear = [];
const showMode = wargearShowMode || (showMandatoryWargear ? 'show-all' : 'hide-mandatory');
// Process enhancements
if (Array.isArray(unit.enhancements)) {
unit.enhancements.forEach(enh => {
let abbr = null;
if (useAbbreviations) {
abbr = findAbbreviationForItem(enh.name, wargearAbbrMap, dataSummary);
if (!abbr) abbr = makeAbbrevForName(enh.name);
}
const pts = enh.points ? (hideBrackets ? ` +${enh.points}` : ` (+${enh.points})`) : '';
specials.push(`E: ${abbr || enh.name}${pts}`);
});
}
// Process wargear
const itemsToRender = aggregateWargear(unit, !hideSubunits);
const visible = itemsToRender.filter(i => {
if (showMode === 'show-all') return true;
if (showMode === 'hide-all') return false;
return !i.skippable;
});
visible.forEach(i => {
const qtyNum = parseInt((i.quantity || '1').toString().replace('x', ''), 10) || 1;
const qtyPrefix = qtyNum > 1 ? `${i.quantity} ` : '';
let abbr = null;
if (useAbbreviations) {
abbr = findAbbreviationForItem(i.name, wargearAbbrMap, dataSummary);
if (!abbr) abbr = makeAbbrevForName(i.name);
}
wargear.push(`${qtyPrefix}${abbr || i.name}`);
});
const all = [...specials, ...wargear].filter(Boolean);
return all.length ? (hideBrackets ? ` ${all.join(', ')}` : ` (${all.join(', ')})`) : '';
}
function canonicalUnitSignature(unit, hideSubunits) {
const normalize = (value) => {
if (value === null || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.map(normalize);
const out = {};
for (const [k, v] of Object.entries(value)) {
if (k === '_parent' || k.startsWith('__')) continue;
if (k === 'name' && value.isAttached) continue;
if (k === 'quantity') {
const q = parseInt((v ?? '1').toString().replace('x', ''), 10);
out[k] = isNaN(q) ? v : q;
continue;
}
out[k] = normalize(v);
}
return out;
};
try {
return JSON.stringify(normalize(unit));
} catch (e) {
return JSON.stringify({ name: unit?.name, points: unit?.points });
}
}
export function maybeCombineUnits(sectionUnits, hideSubunits, enable) {
if (!enable || !Array.isArray(sectionUnits)) return sectionUnits;
const groups = new Map();
for (const u of sectionUnits) {
const sig = canonicalUnitSignature(u, hideSubunits);
if (!groups.has(sig)) groups.set(sig, []);
groups.get(sig).push(u);
}
const combined = [];
for (const [, group] of groups.entries()) {
if (group.length === 1) {
const single = { ...group[0] };
single.__groupCount = 1;
single.__unitSize = getModelsCount(single);
if (single.isAttached && Array.isArray(single.attachedParts)) {
single.attachedParts = single.attachedParts.map(part => {
const p = { ...part };
p.__groupCount = 1;
p.__unitSize = getModelsCount(p);
return p;
});
}
combined.push(single);
continue;
}
const template = { ...group[0] };
const unitSize = getModelsCount(template);
template.__groupCount = group.length;
template.__unitSize = unitSize;
if (template.isAttached && Array.isArray(template.attachedParts)) {
template.attachedParts = template.attachedParts.map(part => {
const p = { ...part };
p.__groupCount = group.length;
p.__unitSize = getModelsCount(p);
return p;
});
}
combined.push(template);
}
return combined;
}
function getRoleTag(part, index, hideBrackets = false) {
if (!part) return '';
const roleLower = (part.role || '').toLowerCase();
const attachedLower = (part.attachedAs || '').toLowerCase();
const suffix = index !== undefined ? index : '';
const isLeader = (str) => /leader|meneur|l[]der|anfuehrer|anführer|capo|comandante/i.test(str);
const isBodyguard = (str) => /bodyguard|gardes?\s+du\s+corps|escolta|leibwaechter|leibwächter|guardia\s+del\s+corpo/i.test(str);
const isSupport = (str) => /support/i.test(str);
if (isLeader(roleLower) || isLeader(attachedLower)) return hideBrackets ? `L${suffix}` : `[L${suffix}]`;
if (isSupport(roleLower) || isSupport(attachedLower)) return hideBrackets ? `S${suffix}` : `[S${suffix}]`;
if (isBodyguard(roleLower) || isBodyguard(attachedLower)) return hideBrackets ? `B${suffix}` : `[B${suffix}]`;
return '';
}
export function getWarlordTag(unit, hideBrackets = false) {
return unit && unit.isWarlord ? (hideBrackets ? 'W' : '[W]') : '';
}
export function generateOutput(data, useAbbreviations, wargearAbbrMap, hideSubunits, skippableWargearMap, applyHeaderColor = true, combineIdenticalUnits = false, noBullets = false, hidePoints = false, abbreviateHeader = false, showMandatoryWargear = false, wargearShowMode = undefined, abbreviateUnitNames = false, hideBrackets = false) {
let html = '', plainText = '';
const showMode = wargearShowMode || (showMandatoryWargear ? 'show-all' : 'hide-mandatory');
const summary = data.metadata || {};
const headerParts = [];
const listName = summary.title || summary.armyName || '';
if (listName) headerParts.push(listName);
if (summary.faction) headerParts.push(summary.faction);
let dets = '';
if (Array.isArray(summary.detachments) && summary.detachments.length > 0) {
if (abbreviateHeader) {
dets = summary.detachments.map(d => abbreviateWords(d)).join(' & ');
} else {
dets = summary.detachments.join(' and ');
}
} else if (summary.detachment) {
if (abbreviateHeader) {
dets = abbreviateDetachment(summary.detachment);
} else {
dets = summary.detachment;
}
}
if (dets) headerParts.push(dets);
let disps = '';
if (Array.isArray(summary.forceDispositions) && summary.forceDispositions.length > 0) {
if (abbreviateHeader) {
disps = summary.forceDispositions.map(d => abbreviateWords(d)).join(', ');
} else {
disps = summary.forceDispositions.join(', ');
}
} else if (summary.forceDisposition) {
if (abbreviateHeader) {
disps = abbreviateForceDisposition(summary.forceDisposition);
} else {
disps = summary.forceDisposition;
}
}
if (disps) headerParts.push(disps);
const totalPts = summary.pointsTotal || summary.totalPoints || 0;
if (totalPts) {
const limit = summary.pointsLimit || 0;
const limitStr = limit ? ` / ${limit}pts` : 'pts';
headerParts.push(`${totalPts}${limitStr}`);
}
if (headerParts.length) {
const summaryText = headerParts.join(' | ');
let styleColor = 'color:var(--color-text-secondary);';
if (applyHeaderColor) {
let headerColor = null;
try {
const fm = buildFactionColorMap(skippableWargearMap || {});
const rawFaction = summary.faction || null;
const fk = getCanonicalFactionName(rawFaction);
const normalizeKeyLookup = (s) => {
if (!s) return null;
try { return s.toString().normalize('NFD').replace(/\p{M}/gu, '').replace(/[\u2018\u2019\u201B\u2032]/g, "'").replace(/[^\w\s'\-]/g, '').toLowerCase().trim(); } catch (e) { return s.toString().toLowerCase(); }
};
const fmEntry = fk ? (fm[fk] || fm[fk.toString().toLowerCase()] || fm[normalizeKeyLookup(fk)]) : null;
if (fmEntry && fmEntry.header) headerColor = fmEntry.header;
} catch (e) {}
styleColor = headerColor ? `color:${headerColor};` : 'color:var(--color-text-secondary);';
}
html += `<div style="padding-bottom:0.5rem;border-bottom:1px solid var(--color-border);"><p style="font-size:0.75rem;margin-bottom:0.25rem;${styleColor}font-weight:600;">${summaryText}</p></div>`;
plainText += summaryText + '\n\n';
}
html += `<div style="margin-top:0.5rem;">`;
const UNIT_BULLET = noBullets ? '' : '• ';
const rawUnits = Array.isArray(data.units) ? data.units : [];
const units = maybeCombineUnits(rawUnits, hideSubunits, combineIdenticalUnits);
const renderUnit = (unit, prefix = '') => {
let outHtml = '', outPlain = '';
const hideSubunitsForThisUnit = hideSubunits || shouldHideSubunitsForUnit(unit, showMode);
const G = (unit.__groupCount !== undefined) ? unit.__groupCount : 1;
const M = (unit.__unitSize !== undefined) ? unit.__unitSize : getModelsCount(unit);
let qtyDisplay = '';
if (G > 1) {
qtyDisplay = M > 1 ? `${G}x${M} ` : `${G}x `;
} else {
qtyDisplay = M > 1 ? `${M} ` : '';
}
const categorySuffix = '';
if (useAbbreviations) {
const itemsString = getInlineItemsString(unit, useAbbreviations, wargearAbbrMap, summary, skippableWargearMap, showMandatoryWargear, hideSubunitsForThisUnit, showMode, hideBrackets);
const pointsString = hidePoints ? '' : (hideBrackets ? ` ${unit.points}` : ` [${unit.points}]`);
const finalUnitName = abbreviateUnitNames ? (findAbbreviationForItem(unit.name, wargearAbbrMap, summary) || makeAbbrevForName(unit.name)) : unit.name;
const unitText = `${prefix}${qtyDisplay}${finalUnitName}${categorySuffix}${itemsString}${pointsString}`;
outHtml += `<div><p style="color:var(--color-text-primary);font-weight:600;font-size:0.875rem;margin-bottom:0.25rem;">${unitText}</p>`;
outPlain += `${UNIT_BULLET}${unitText}\n`;
if (!hideSubunitsForThisUnit && Array.isArray(unit.subunits) && unit.subunits.length > 0) {
outHtml += `<div style="padding-left:1rem;font-size:0.75rem;color:var(--color-text-secondary);font-weight:400;">`;
unit.subunits.forEach(sub => {
const subQty = parseInt((sub.quantity || '1').toString().replace('x', ''), 10) || 1;
const subQtyDisplay = subQty > 1 ? `${subQty} ` : '';
const filteredItems = (sub.wargear || []).filter(wg => {
if (showMode === 'show-all') return true;
if (showMode === 'hide-all') return false;
return !wg.skippable;
});
let subItemsText = '';
if (filteredItems.length > 0) {
const subItemsArr = filteredItems.map(wg => {
const wgQty = parseInt(wg.quantity || 1, 10);
const qtyStr = wgQty > 1 ? `${wgQty}x ` : '';
let abbr = null;
abbr = findAbbreviationForItem(wg.name, wargearAbbrMap, summary);
if (!abbr) abbr = makeAbbrevForName(wg.name);
return `${qtyStr}${abbr || wg.name}`;
});
subItemsText = hideBrackets ? ` ${subItemsArr.join(', ')}` : ` (${subItemsArr.join(', ')})`;
}
const finalSubName = abbreviateUnitNames ? (findAbbreviationForItem(sub.name, wargearAbbrMap, summary) || makeAbbrevForName(sub.name)) : sub.name;
outHtml += `<p style="font-weight:500;color:var(--color-text-primary);margin:0;">${subQtyDisplay}${finalSubName}${subItemsText}</p>`;
outPlain += ` * ${subQtyDisplay}${finalSubName}${subItemsText}\n`;
});
outHtml += `</div>`;
}
outHtml += `</div>`;
return { html: outHtml, plainText: outPlain };
}
const pointsString = hidePoints ? '' : (hideBrackets ? ` ${unit.points}` : ` [${unit.points}]`);
const finalUnitName = (useAbbreviations && abbreviateUnitNames) ? (findAbbreviationForItem(unit.name, wargearAbbrMap, summary) || makeAbbrevForName(unit.name)) : unit.name;
const unitText = `${prefix}${qtyDisplay}${finalUnitName}${categorySuffix}${pointsString}`;
outHtml += `<div><p style="color:var(--color-text-primary);font-weight:600;font-size:0.875rem;margin-bottom:0.25rem;">${unitText}</p>`;
outPlain += `${UNIT_BULLET}${unitText}\n`;
if (hideSubunitsForThisUnit) {
const aggregated = aggregateWargear(unit);
const visibleAggregated = aggregated.filter(it => {
if (showMode === 'show-all') return true;
if (showMode === 'hide-all') return false;
return !it.skippable;
});
if (visibleAggregated.length > 0) {
outHtml += `<div style="padding-left:1rem;font-size:0.75rem;color:var(--color-text-secondary);font-weight:400;">`;
visibleAggregated.forEach(it => {
outHtml += `<p style="margin:0;">${it.quantity} ${it.name}</p>`;
outPlain += ` - ${it.quantity} ${it.name}\n`;
});
outHtml += `</div>`;
}
} else {
// Render enhancements
if (Array.isArray(unit.enhancements) && unit.enhancements.length > 0) {
outHtml += `<div style="padding-left:1rem;font-size:0.75rem;color:var(--color-text-secondary);font-weight:400;">`;
unit.enhancements.forEach(enh => {
const ptsStr = enh.points ? ` (+${enh.points})` : '';
outHtml += `<p style="margin:0;">Enhancement: ${enh.name}${ptsStr}</p>`;
outPlain += ` - Enhancement: ${enh.name}${ptsStr}\n`;
});
outHtml += `</div>`;
}
// Render top-level wargear
if (Array.isArray(unit.wargear) && unit.wargear.length > 0) {
const visibleWargear = unit.wargear.filter(wg => {
if (showMode === 'show-all') return true;
if (showMode === 'hide-all') return false;
return !wg.skippable;
});
if (visibleWargear.length > 0) {
outHtml += `<div style="padding-left:1rem;font-size:0.75rem;color:var(--color-text-secondary);font-weight:400;">`;
visibleWargear.forEach(wg => {
const wgQty = parseInt(wg.quantity || 1, 10);
const qtyStr = wgQty > 1 ? `${wgQty}x ` : '';
outHtml += `<p style="margin:0;">${qtyStr}${wg.name}</p>`;
outPlain += ` - ${qtyStr}${wg.name}\n`;
});
outHtml += `</div>`;
}
}
// Render subunits
if (Array.isArray(unit.subunits) && unit.subunits.length > 0) {
outHtml += `<div style="padding-left:1rem;font-size:0.75rem;color:var(--color-text-secondary);font-weight:400;">`;
unit.subunits.forEach(sub => {
const subQty = parseInt(sub.quantity || 1, 10);
const qtyStr = subQty > 1 ? `${subQty}x ` : '';
const visibleWargear = (sub.wargear || []).filter(wg => {
if (showMode === 'show-all') return true;
if (showMode === 'hide-all') return false;
return !wg.skippable;
});
const finalSubName = (useAbbreviations && abbreviateUnitNames) ? (findAbbreviationForItem(sub.name, wargearAbbrMap, summary) || makeAbbrevForName(sub.name)) : sub.name;
outHtml += `<p style="font-weight:500;color:var(--color-text-primary);margin:0;">${qtyStr}${finalSubName}</p>`;
outPlain += ` * ${qtyStr}${finalSubName}\n`;
visibleWargear.forEach(wg => {
const wgQty = parseInt(wg.quantity || 1, 10);
const wqtyStr = wgQty > 1 ? `${wgQty}x ` : '';
outHtml += `<p style="margin:0 0 0.125rem 1rem;">${wqtyStr}${wg.name}</p>`;
outPlain += ` - ${wqtyStr}${wg.name}\n`;
});
});
outHtml += `</div>`;
}
}
outHtml += `</div>`;
return { html: outHtml, plainText: outPlain };
};
let attachedIndex = 0;
units.forEach(unit => {
if (unit.isAttached) {
attachedIndex++;
unit.attachedParts.forEach(part => {
const tag = getRoleTag(part, attachedIndex, hideBrackets);
const wTag = getWarlordTag(part, hideBrackets);
const tags = [tag, wTag].filter(Boolean).join('');
const prefix = tags ? `${tags} ` : '';
const rendered = renderUnit(part, prefix);
html += rendered.html;
plainText += rendered.plainText;
});
} else {
const wTag = getWarlordTag(unit, hideBrackets);
const prefix = wTag ? `${wTag} ` : '';
const rendered = renderUnit(unit, prefix);
html += rendered.html;
plainText += rendered.plainText;
}
});
html += `</div>`;
return { html, plainText };
}
export function generateDiscordText(data, plain, useAbbreviations = true, wargearAbbrMap, hideSubunits, skippableWargearMap, combineIdenticalUnits = false, options, noBullets = false, hidePoints = false) {
const hasDOM = (typeof document !== 'undefined' && document.querySelector);
let useColor = false;
const defaultColors = { unit: '#FFFFFF', subunit: '#808080', wargear: '#FFFFFF', points: '#FFFF00', header: '#FFFF00', attached: '#FFFF00' };
const colors = { ...defaultColors };
const summary = data.metadata || {};
const hideBrackets = !!(options && options.hideBrackets);
if (!plain) {
const mode = (options && options.colorMode) || (hasDOM ? ((document.querySelector('input[name="colorMode"]:checked') || {}).value || 'none') : 'none');
useColor = mode && mode !== 'none';
if (useColor && mode === 'custom') {
if (options && options.colors) {
const src = options.colors || {};
if (src.unit) colors.unit = src.unit;
if (src.subunit) colors.subunit = src.subunit;
if (src.wargear) colors.wargear = src.wargear;
if (src.points) colors.points = src.points;
if (src.header) colors.header = src.header;
if (src.attached) colors.attached = src.attached;
} else if (hasDOM) {
const u = document.getElementById('unitColor');
const s = document.getElementById('subunitColor');
const w = document.getElementById('wargearColor');
const p = document.getElementById('pointsColor');
const h = document.getElementById('headerColor');
const a = document.getElementById('attachedColor');
if (u && u.value) colors.unit = u.value;
if (s && s.value) colors.subunit = s.value;
if (w && w.value) colors.wargear = w.value;
if (p && p.value) colors.points = p.value;
if (h && h.value) colors.header = h.value;
if (a && a.value) colors.attached = a.value;
}
}
if (useColor && mode === 'faction') {
const factionMap = buildFactionColorMap(skippableWargearMap || {});
const rawFaction = summary.faction || null;
const factionKey = getCanonicalFactionName(rawFaction);
const normalizeKeyLookup = (s) => {
if (!s) return null;
try { return s.toString().normalize('NFD').replace(/\p{M}/gu, '').replace(/[\u2018\u2019\u201B\u2032]/g, "'").replace(/[^\w\s'\-]/g, '').toLowerCase().trim(); } catch (e) { return s.toString().toLowerCase(); }
};
const nfk = factionKey ? normalizeKeyLookup(factionKey) : null;
if (factionKey && (factionMap[factionKey] || factionMap[factionKey.toString().toLowerCase()] || (nfk && factionMap[nfk]))) {
const fm = factionMap[factionKey] || factionMap[factionKey.toString().toLowerCase()] || factionMap[nfk];
if (fm.unit) colors.unit = fm.unit;
if (fm.subunit) colors.subunit = fm.subunit;
if (fm.wargear) colors.wargear = fm.wargear;
if (fm.points) colors.points = fm.points;
if (fm.header) colors.header = fm.header;
if (fm.attached) colors.attached = fm.attached;
}
}
}
const toAnsi = (txt, hex, bold = false) => {
if (!useColor || !hex) return txt;
if (typeof hex === 'number' || (typeof hex === 'string' && /^\d+$/.test(hex))) {
const boldPart = bold ? '1;' : '';
return `\u001b[${boldPart}${hex}m${txt}\u001b[0m`;
}
const forcePalette = !!(options && options.forcePalette);
if (hasDOM && !forcePalette) {
const rgb = hexToRgb(hex);
if (!rgb) return txt;
const boldPart = bold ? '1;' : '';
return `\u001b[${boldPart}38;2;${rgb.r};${rgb.g};${rgb.b}m${txt}\u001b[0m`;
}
const code = findClosestAnsi(hex);
const boldPart = bold ? '1;' : '';
return `\u001b[${boldPart}${code}m${txt}\u001b[0m`;
};
const UNIT_BULLET = noBullets ? '' : (plain ? '• ' : '* ');
const SUB_BULLET = noBullets ? ' ' : (plain ? ' ◦ ' : ' + ');
const abbreviateHeader = !!(options && options.abbreviateHeader);
const showMandatoryWargear = !!(options && options.showMandatoryWargear);
const showMode = (options && options.wargearShowMode) || (showMandatoryWargear ? 'show-all' : 'hide-mandatory');
let out = '';
if (!plain) out += useColor ? '```ansi\n' : '```\n';
const headerParts = [];
const listName = summary.title || summary.armyName || '';
if (listName) headerParts.push(listName);
if (summary.faction) headerParts.push(summary.faction);
let dets = '';
if (Array.isArray(summary.detachments) && summary.detachments.length > 0) {
if (abbreviateHeader) {
dets = summary.detachments.map(d => abbreviateWords(d)).join(' & ');
} else {
dets = summary.detachments.join(' and ');
}
} else if (summary.detachment) {
if (abbreviateHeader) {
dets = abbreviateDetachment(summary.detachment);
} else {
dets = summary.detachment;
}
}
if (dets) headerParts.push(dets);
let disps = '';
if (Array.isArray(summary.forceDispositions) && summary.forceDispositions.length > 0) {
if (abbreviateHeader) {
disps = summary.forceDispositions.map(d => abbreviateWords(d)).join(', ');
} else {
disps = summary.forceDispositions.join(', ');
}
} else if (summary.forceDisposition) {
if (abbreviateHeader) {
disps = abbreviateForceDisposition(summary.forceDisposition);
} else {
disps = summary.forceDisposition;
}
}
if (disps) headerParts.push(disps);
const totalPts = summary.pointsTotal || summary.totalPoints || 0;
if (totalPts) {
const limit = summary.pointsLimit || 0;
const limitStr = limit ? ` / ${limit}pts` : 'pts';
headerParts.push(`${totalPts}${limitStr}`);
}
if (headerParts.length) {
const multiline = (options && options.multilineHeader !== undefined) ? options.multilineHeader : false;
const header = headerParts.join(multiline ? '\n' : ' | ');
out += useColor ? toAnsi(header, colors.header, true) + '\n\n' : header + '\n\n';
}
const rawUnits = Array.isArray(data.units) ? data.units : [];
const units = maybeCombineUnits(rawUnits, hideSubunits, combineIdenticalUnits);
const renderDiscordUnit = (unit, prefixText = '') => {
const hideSubunitsForThisUnit = hideSubunits || shouldHideSubunitsForUnit(unit, showMode);
const G = (unit.__groupCount !== undefined) ? unit.__groupCount : 1;
const M = (unit.__unitSize !== undefined) ? unit.__unitSize : getModelsCount(unit);
let qtyDisplay = '';
if (G > 1) {
qtyDisplay = M > 1 ? `${G}x${M} ` : `${G}x `;
} else {
qtyDisplay = M > 1 ? `${M} ` : '';
}
const categorySuffix = '';
const itemsString = getInlineItemsString(unit, useAbbreviations, wargearAbbrMap, summary, skippableWargearMap, showMandatoryWargear, hideSubunitsForThisUnit, showMode, hideBrackets);
const abbreviateUnitNames = !!(options && options.abbreviateUnitNames);
const finalUnitName = (useAbbreviations && abbreviateUnitNames) ? (findAbbreviationForItem(unit.name, wargearAbbrMap, summary) || makeAbbrevForName(unit.name)) : unit.name;
const unitNameText = useColor ? toAnsi(finalUnitName, colors.unit, true) : finalUnitName;
const unitText = `${prefixText}${qtyDisplay}${unitNameText}${categorySuffix}`;
const itemsText = (useColor && itemsString) ? toAnsi(itemsString, colors.wargear, false) : itemsString;
const pointsRaw = hideBrackets ? `${unit.points}` : `[${unit.points}]`;
const pointsText = useColor ? toAnsi(pointsRaw, colors.points, true) : pointsRaw;
let line = `${UNIT_BULLET}${unitText}${itemsText}`;
if (!hidePoints) {
line += ` ${pointsText}`;
}
out += `${line}\n`;
if (!hideSubunitsForThisUnit && Array.isArray(unit.subunits)) {
unit.subunits.forEach(sub => {
const subQty = parseInt((sub.quantity || '1').toString().replace('x', ''), 10) || 1;
const subQtyDisplay = subQty > 1 ? `${subQty} ` : '';
const finalSubName = (useAbbreviations && abbreviateUnitNames) ? (findAbbreviationForItem(sub.name, wargearAbbrMap, summary) || makeAbbrevForName(sub.name)) : sub.name;
const subRaw = `${subQtyDisplay}${finalSubName}`;
const subName = useColor ? toAnsi(subRaw, colors.subunit, false) : subRaw;
const filteredItems = (sub.wargear || []).filter(wg => {
if (showMode === 'show-all') return true;
if (showMode === 'hide-all') return false;
return !wg.skippable;
});
if (filteredItems.length === 0) {
out += `${SUB_BULLET}${subName}\n`;
return;
}
// Format subunit wargear
const subItemsArr = filteredItems.map(wg => {
const wgQty = parseInt(wg.quantity || 1, 10);
const qtyStr = wgQty > 1 ? `${wgQty}x ` : '';
let abbr = null;
if (useAbbreviations) {
abbr = findAbbreviationForItem(wg.name, wargearAbbrMap, summary);
if (!abbr) abbr = makeAbbrevForName(wg.name);
}
return `${qtyStr}${abbr || wg.name}`;
});
const subItems = hideBrackets ? ` ${subItemsArr.join(', ')}` : ` (${subItemsArr.join(', ')})`;
const subItemsText = (useColor && subItems) ? toAnsi(subItems, colors.wargear, false) : subItems;
out += `${SUB_BULLET}${subName}${subItemsText}\n`;
});
}
};
let attachedIndex = 0;
units.forEach(unit => {
if (unit.isAttached) {
attachedIndex++;
unit.attachedParts.forEach(part => {
const tag = getRoleTag(part, attachedIndex, hideBrackets);
const wTag = getWarlordTag(part, hideBrackets);
const tagText = useColor ? toAnsi(tag, colors.attached, true) : tag;
const wTagText = useColor && wTag ? toAnsi(wTag, colors.attached, true) : wTag;
const parts = [tagText, wTagText].filter(Boolean);
const prefixText = parts.length ? `${parts.join('')} ` : '';
renderDiscordUnit(part, prefixText);
});
} else {
const wTag = getWarlordTag(unit, hideBrackets);
const wTagText = useColor && wTag ? toAnsi(wTag, colors.attached, true) : wTag;
const prefixText = wTag ? `${wTagText} ` : '';
renderDiscordUnit(unit, prefixText);
}
});
if (!plain) out += '```';
return out;
}
export function resolveFactionColors(data, skippableWargearMap) {
const factionMap = buildFactionColorMap(skippableWargearMap || {});
const summary = data.metadata || {};
const rawFaction = summary.faction || null;
const factionKey = getCanonicalFactionName(rawFaction);
if (!factionKey) return null;
const normalizeKeyLookup = (s) => {
if (!s) return null;
try { return s.toString().normalize('NFD').replace(/\p{M}/gu, '').replace(/[\u2018\u2019\u201B\u2032]/g, "'").replace(/[^\w\s'\-]/g, '').toLowerCase().trim(); } catch (e) { return s.toString().toLowerCase(); }
};
const nfk = normalizeKeyLookup(factionKey);
const fm = factionMap[factionKey] || factionMap[factionKey.toString().toLowerCase()] || (nfk && factionMap[nfk]);
return fm || null;
}
+524
View File
@@ -0,0 +1,524 @@
// Normalize a wargear name for comparison/grouping purposes. Treats hyphens the same
// as spaces so inconsistent source formatting (e.g. "Close Combat Weapon" vs
// "Close-Combat Weapon") is recognized as the same item.
export function normalizeWargearName(name) {
if (!name) return '';
let normalized = name.toString().replace(/[\u2018\u2019\u201B\u2032]/g, "'");
try {
normalized = normalized.normalize('NFD').replace(/\p{M}/gu, '');
} catch (e) {}
return normalized
.replace(/-/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
// Sort wargear/special item arrays in-place: descending numeric quantity, then by name (A-Z)
export function sortItemsByQuantityThenName(items) {
if (!Array.isArray(items)) return items;
items.sort((a, b) => {
const aq = parseInt(String((a && a.quantity) || '1x').replace(/x/i, ''), 10) || 1;
const bq = parseInt(String((b && b.quantity) || '1x').replace(/x/i, ''), 10) || 1;
// descending quantity
if (aq !== bq) return bq - aq;
const an = (a && a.name) ? a.name.toString().toLowerCase() : '';
const bn = (b && b.name) ? b.name.toString().toLowerCase() : '';
if (an < bn) return -1;
if (an > bn) return 1;
return 0;
});
return items;
}
export function getCanonicalFactionName(faction) {
if (!faction) return faction;
// Combined "Parent - Subfaction" strings (e.g. "Space Marines - Dark Angels")
// should resolve off the subfaction segment, since that's the more specific
// identity for coloring/skippable-wargear lookups.
const combinedMatch = faction.toString().match(/^(.+?)\s+-\s+(.+)$/);
if (combinedMatch) {
return getCanonicalFactionName(combinedMatch[2].trim());
}
// Normalize curly apostrophes to straight ones
let normalized = faction.toString()
.replace(/[\u2018\u2019\u201B\u2032]/g, "'");
// Normalize text (remove accents/diacritics)
try {
normalized = normalized.normalize('NFD').replace(/\p{M}/gu, '');
} catch (e) {
// Fallback
}
normalized = normalized.toLowerCase().trim();
const mapping = {
// T'au Empire
"empire t'au": "T'au Empire",
"imperio t'au": "T'au Empire",
"t'au-imperium": "T'au Empire",
"tau-imperium": "T'au Empire",
"tau imperium": "T'au Empire",
"impero t'au": "T'au Empire",
"sternenreich der t'au": "T'au Empire",
"sternenreich der tau": "T'au Empire",
"t'au empire": "T'au Empire",
"tau empire": "T'au Empire",
"imperio tau": "T'au Empire",
"impero tau": "T'au Empire",
// Chaos Daemons
"demons du chaos": "Chaos Daemons",
"demonios del caos": "Chaos Daemons",
"chaosdaemonen": "Chaos Daemons",
"chaos-daemonen": "Chaos Daemons",
"chaosdämonen": "Chaos Daemons",
"chaos-dämonen": "Chaos Daemons",
"demoni del caos": "Chaos Daemons",
"chaos daemons": "Chaos Daemons",
// Genestealer Cults
"cultes genestealers": "Genestealer Cults",
"culte genestealers": "Genestealer Cults",
"cultos genestealer": "Genestealer Cults",
"culto genestealer": "Genestealer Cults",
"symbiontenkulte": "Genestealer Cults",
"symbiontenkult": "Genestealer Cults",
"culti di genestealer": "Genestealer Cults",
"culto dei genestealer": "Genestealer Cults",
"culti dei genestealer": "Genestealer Cults",
"genestealer cults": "Genestealer Cults",
// Grey Knights
"chevaliers gris": "Grey Knights",
"caballeros grises": "Grey Knights",
"graue ritter": "Grey Knights",
"cavalieri grigi": "Grey Knights",
"grey knights": "Grey Knights",
// Imperial Knights
"chevaliers imperiaux": "Imperial Knights",
"caballeros imperiales": "Imperial Knights",
"imperiale ritter": "Imperial Knights",
"cavalieri imperiali": "Imperial Knights",
"imperial knights": "Imperial Knights",
// Leagues of Votann
"ligues de votann": "Leagues of Votann",
"ligas de votann": "Leagues of Votann",
"ligen von votann": "Leagues of Votann",
"leghe di votann": "Leagues of Votann",
"leagues of votann": "Leagues of Votann",
// Necrons
"necrons": "Necrons",
"necrones": "Necrons",
"necroni": "Necrons",
"necron": "Necrons",
// Salamanders
"salamandres": "Salamanders",
"salamandras": "Salamanders",
"salamandre": "Salamanders",
"salamanders": "Salamanders",
// Tyranids
"tyranides": "Tyranids",
"tiranidos": "Tyranids",
"tyraniden": "Tyranids",
"tiranidi": "Tyranids",
"tyranids": "Tyranids",
// Agents of the Imperium
"agents de l'imperium": "Agents of the Imperium",
"agentes del imperio": "Agents of the Imperium",
"agenten des imperiums": "Agents of the Imperium",
"agenti dell'imperium": "Agents of the Imperium",
"agents of the imperium": "Agents of the Imperium",
"agents imperiaux": "Agents of the Imperium",
"agentes imperiales": "Agents of the Imperium",
"imperiale agenten": "Agents of the Imperium",
"agenti imperiali": "Agents of the Imperium",
"imperial agents": "Agents of the Imperium",
// Space Marines
"marines espaciales": "Space Marines",
"marines spaziali": "Space Marines",
"space marines": "Space Marines",
"weltraummarines": "Space Marines",
// World Eaters
"devoradores de mundos": "World Eaters",
"divoratori di mondi": "World Eaters",
"mangeurs de mondes": "World Eaters",
"weltenfresser": "World Eaters",
"world eaters": "World Eaters",
// Death Guard
"guardia de la muerte": "Death Guard",
"guardia della morte": "Death Guard",
"garde de la mort": "Death Guard",
"todesgarde": "Death Guard",
"death guard": "Death Guard",
// Thousand Sons
"mil hijos": "Thousand Sons",
"mille figli": "Thousand Sons",
"mille fils": "Thousand Sons",
"tausend sohne": "Thousand Sons",
"thousand sons": "Thousand Sons",
// Chaos Space Marines
"marines espaciales del caos": "Chaos Space Marines",
"space marines del caos": "Chaos Space Marines",
"space marines du chaos": "Chaos Space Marines",
"chaos space marines": "Chaos Space Marines",
// Chaos Knights
"caballeros del caos": "Chaos Knights",
"cavalieri del caos": "Chaos Knights",
"chevaliers du chaos": "Chaos Knights",
"chaosritter": "Chaos Knights",
"chaos knights": "Chaos Knights",
// Dark Angels
"angeles oscuros": "Dark Angels",
"angeli oscuri": "Dark Angels",
"anges sombres": "Dark Angels",
"dunkle engel": "Dark Angels",
"dark angels": "Dark Angels",
// Blood Angels
"angeles sangrientos": "Blood Angels",
"angeli sanguinari": "Blood Angels",
"anges sanguins": "Blood Angels",
"blutengel": "Blood Angels",
"blood angels": "Blood Angels",
// Black Templars
"templarios negros": "Black Templars",
"templari neri": "Black Templars",
"templiers noirs": "Black Templars",
"schwarze templer": "Black Templars",
"black templars": "Black Templars",
// Space Wolves
"lobos espaciales": "Space Wolves",
"lupi spaziali": "Space Wolves",
"loups spatiaux": "Space Wolves",
"weltraumwolfe": "Space Wolves",
"space wolves": "Space Wolves",
// Adepta Sororitas
"sisters of battle": "Adepta Sororitas",
"soeurs de bataille": "Adepta Sororitas",
"hermanas de batalla": "Adepta Sororitas",
"schwestern des kampfes": "Adepta Sororitas",
"sororitas": "Adepta Sororitas",
"sorelle della battaglia": "Adepta Sororitas",
"adepta sororitas": "Adepta Sororitas",
// Astra Militarum
"imperial guard": "Astra Militarum",
"garde imperiale": "Astra Militarum",
"guardia imperial": "Astra Militarum",
"imperiale armee": "Astra Militarum",
"imperiale garde": "Astra Militarum",
"guardia imperiale": "Astra Militarum",
"astra militarum": "Astra Militarum",
// Deathwatch
"todeswache": "Deathwatch",
"guet de la mort": "Deathwatch",
"guardianes de la muerte": "Deathwatch",
"veglia della morte": "Deathwatch",
"deathwatch": "Deathwatch",
// Drukhari
"dark eldar": "Drukhari",
"eldars noirs": "Drukhari",
"eldar noirs": "Drukhari",
"eldars oscuros": "Drukhari",
"eldar oscuros": "Drukhari",
"dunkle eldar": "Drukhari",
"eldar oscuri": "Drukhari",
"drukhari": "Drukhari",
// Emperor's Children
"enfants de l'empereur": "Emperor's Children",
"hijos del emperador": "Emperor's Children",
"kinder des imperators": "Emperor's Children",
"figli dell'imperatore": "Emperor's Children",
"emperor's children": "Emperor's Children",
// Imperial Fists
"poings imperiaux": "Imperial Fists",
"punos imperiales": "Imperial Fists",
"imperiale fauste": "Imperial Fists",
"magli imperiali": "Imperial Fists",
"pugni imperiali": "Imperial Fists",
"imperial fists": "Imperial Fists",
// Iron Hands
"mains de fer": "Iron Hands",
"manos de hierro": "Iron Hands",
"eiserne hande": "Iron Hands",
"mani di ferro": "Iron Hands",
"iron hands": "Iron Hands",
// Orks
"orcs": "Orks",
"orkos": "Orks",
"orki": "Orks",
"orks": "Orks",
// Raven Guard
"garde du corbeau": "Raven Guard",
"guardia del cuervo": "Raven Guard",
"rabengarde": "Raven Guard",
"guardia del corvo": "Raven Guard",
"raven guard": "Raven Guard",
// White Scars
"cicatrices blanches": "White Scars",
"cicatrices blancas": "White Scars",
"weisse narben": "White Scars",
"cicatrici bianche": "White Scars",
"white scars": "White Scars"
};
if (mapping[normalized]) {
return mapping[normalized];
}
return faction;
}
export function isWargearSkippable(skippableWargearMap, faction, unitName, wargearName) {
if (!skippableWargearMap || !faction || !unitName || !wargearName) return false;
const normalizeKey = (s) => {
if (!s) return '';
try {
return s.toString().normalize('NFD')
.replace(/\p{M}/gu, '')
.replace(/[\u2018\u2019\u201B\u2032]/g, "'")
.replace(/[^\w\s'\-]/g, '')
.toLowerCase().trim();
} catch (e) {
return s.toString().toLowerCase().trim();
}
};
const unitKey = normalizeKey(unitName);
const unitAlt = unitKey.endsWith('s') ? unitKey.slice(0, -1) : unitKey + 's';
const wargearKey = normalizeWargearName(wargearName);
// A combined "Parent - Subfaction" faction (e.g. "Space Marines - Dark Angels")
// resolves to the subfaction for coloring, but skippable-wargear rules are often
// only defined once at the parent level. Try the subfaction first so any
// chapter-specific overrides win, then fall back to the parent when the
// subfaction's data has no entry at all for this unit.
const rawFaction = faction.toString();
const combinedMatch = rawFaction.match(/^(.+?)\s+-\s+(.+)$/);
const candidateFactions = combinedMatch ? [combinedMatch[2].trim(), combinedMatch[1].trim()] : [rawFaction];
for (const candidateFaction of candidateFactions) {
const canonicalFaction = getCanonicalFactionName(candidateFaction);
const factionKey = normalizeKey(canonicalFaction);
// Find faction entry
let factionData;
for (const [k, v] of Object.entries(skippableWargearMap)) {
if (normalizeKey(k) === factionKey) {
factionData = v;
break;
}
}
if (!factionData) continue;
// Find unit entry
let unitData;
const tryUnitKeys = [unitName, unitKey, unitAlt];
for (const uk of tryUnitKeys) {
if (Object.prototype.hasOwnProperty.call(factionData, uk)) {
unitData = factionData[uk];
break;
}
}
if (unitData === undefined) {
for (const [k, v] of Object.entries(factionData)) {
if (normalizeKey(k) === unitKey || normalizeKey(k) === unitAlt) {
unitData = v;
break;
}
}
}
if (unitData === undefined) continue;
if (unitData === true) return true;
if (Array.isArray(unitData)) {
return unitData.map(s => normalizeWargearName(s)).includes(wargearKey);
}
return false;
}
return false;
}
export function shouldHideSubunitsForUnit(unit, showMode) {
if (!unit || !Array.isArray(unit.subunits) || unit.subunits.length === 0) return false;
return unit.subunits.every(sub => {
const visibleWargear = (sub.wargear || []).filter(wg => {
if (showMode === 'show-all') return true;
if (showMode === 'hide-all') return false;
return !wg.skippable;
});
return visibleWargear.length === 0;
});
}
export function getModelsCount(unit) {
if (!unit) return 1;
if (Array.isArray(unit.subunits) && unit.subunits.length > 0) {
return unit.subunits.reduce((sum, sub) => sum + (parseInt(sub.quantity, 10) || 0), 0);
}
const q = parseInt((unit.quantity || '1').toString().replace('x', ''), 10);
return isNaN(q) ? 1 : q;
}
export function parseNewRecruitHeader(lines) {
const metadata = {
title: '',
armyName: '',
faction: '',
detachment: '',
detachments: [],
pointsTotal: 0,
totalPoints: 0,
pointsLimit: 0,
forceDispositions: [],
warlordName: '',
warlordId: '',
enhancements: []
};
let i = 0;
while (i < lines.length && !lines[i].trim()) {
i++;
}
if (i < lines.length && lines[i].trim().startsWith('+++')) {
i++; // skip opening +++
while (i < lines.length && !lines[i].trim().startsWith('+++')) {
const line = lines[i].trim();
if (line.startsWith('+') || line.startsWith('&')) {
const isAmp = line.startsWith('&');
const content = line.substring(1).trim();
if (isAmp) {
// Continuing enhancement or other field
const match = content.match(/^(.*?)\s*\(on\s+(Char\d+|Infa\d+|[A-Za-z]+\d+)?\s*:?\s*(.*)\)$/i);
if (match) {
metadata.enhancements.push({
name: match[1].trim(),
onId: match[2] ? match[2].trim() : '',
onName: match[3] ? match[3].trim() : ''
});
} else {
metadata.enhancements.push({
name: content,
onId: '',
onName: ''
});
}
i++;
continue;
}
const factionMatch = content.match(/^FACTION KEYWORD:\s*(.*)$/i);
if (factionMatch) {
let facVal = factionMatch[1].trim();
const hyphenIdx = facVal.indexOf('-');
if (hyphenIdx !== -1) {
facVal = facVal.substring(hyphenIdx + 1).trim();
}
metadata.faction = facVal;
i++;
continue;
}
const detachmentMatch = content.match(/^DETACHMENT:\s*(.*)$/i);
if (detachmentMatch) {
let detVal = detachmentMatch[1].trim();
detVal = detVal.replace(/\s*\(.*?\)/g, '').trim();
metadata.detachment = detVal;
metadata.detachments = detVal.split(',').map(d => d.trim()).filter(Boolean);
i++;
continue;
}
const forceDispositionMatch = content.match(/^FORCE DISPOSITION:\s*(.*)$/i);
if (forceDispositionMatch) {
const dispVal = forceDispositionMatch[1].trim();
metadata.forceDispositions = dispVal.split(',').map(d => d.trim()).filter(Boolean);
i++;
continue;
}
const pointsMatch = content.match(/^TOTAL ARMY POINTS:\s*(\d+)/i);
if (pointsMatch) {
const pts = parseInt(pointsMatch[1], 10) || 0;
metadata.pointsTotal = pts;
metadata.totalPoints = pts;
i++;
continue;
}
const warlordMatch = content.match(/^WARLORD:\s*(Char\d+|Infa\d+|[A-Za-z]+\d+)?\s*:?\s*(.*)$/i);
if (warlordMatch) {
if (warlordMatch[1]) {
metadata.warlordId = warlordMatch[1].trim();
}
metadata.warlordName = warlordMatch[2].trim();
i++;
continue;
}
const enhancementMatch = content.match(/^ENHANCEMENT:\s*(.*)$/i);
if (enhancementMatch) {
const enhContent = enhancementMatch[1].trim();
const match = enhContent.match(/^(.*?)\s*\(on\s+(Char\d+|Infa\d+|[A-Za-z]+\d+)?\s*:?\s*(.*)\)$/i);
if (match) {
metadata.enhancements.push({
name: match[1].trim(),
onId: match[2] ? match[2].trim() : '',
onName: match[3] ? match[3].trim() : ''
});
} else {
metadata.enhancements.push({
name: enhContent,
onId: '',
onName: ''
});
}
i++;
continue;
}
const secondaryMatch = content.match(/^SECONDARY:\s*(.*)$/i);
if (secondaryMatch) {
i++;
continue;
}
}
i++;
}
if (i < lines.length && lines[i].trim().startsWith('+++')) {
i++;
}
}
return { metadata, nextIndex: i };
}
File diff suppressed because it is too large Load Diff