// Lookup of the flavour text that official datasheets print to the right of the // model image. The data lives in `public/Lore.csv`, a pipe-delimited export of // `name|legend` (plus an optional `faction` column, see `buildLoreIndex`). // // Nothing about roster names is reliable enough for an exact lookup: the same // unit is spelled "Tech-priest Dominus" in one place and "Tech-Priest Dominus" // in another, a roster may name a unit in the plural where the export uses the // singular ("Myphitic Blight-haulers" / "Myphitic Blight-hauler"), and faction // catalogues prefix names that the export does not ("Thousand Sons Chaos // Spawn" / "Chaos Spawn"). So the index is keyed by a normalised form and // consulted through three widening attempts. /** * Case, accents, curly quotes and punctuation all vary between the export and * the roster, and none of them carry meaning here, so collapse the lot. */ export const normalizeName = (name) => String(name ?? "") .normalize("NFKD") .replace(/\p{M}/gu, "") .toLowerCase() .replace(/[^a-z0-9]+/g, " ") .trim(); /** * Crude singularisation of the last word, which is the only place a roster and * the export tend to disagree on number. Deliberately not a real stemmer: it * runs over both sides of the comparison, so it only has to be consistent, not * correct. */ const singularize = (word) => { if (word.length < 4) return word; if (word.endsWith("ies")) return `${word.slice(0, -3)}y`; if (/(?:ss|sh|ch|x|z)es$/.test(word)) return word.slice(0, -2); if (word.endsWith("s") && !word.endsWith("ss")) return word.slice(0, -1); return word; }; const stemKey = (normalized) => { const words = normalized.split(" "); if (!words.length) return normalized; words[words.length - 1] = singularize(words[words.length - 1]); return words.join(" "); }; const splitLines = (text) => String(text ?? "") .replace(/^\ufeff/, "") .split(/\r?\n/); /** * Parses the export into `{ name, legend, faction }` rows. Rows without a * legend are dropped - the export carries a few hundred of them, one per unit * whose lore has not been transcribed yet, and they would otherwise shadow a * usable entry for the same name. * * Columns are located by the header line, so adding a `faction` column (see * `pickEntry`) does not need a code change. */ export const parseLore = (text) => { const lines = splitLines(text).filter((line) => line.trim()); if (!lines.length) return []; const header = lines[0].split("|").map((h) => h.trim().toLowerCase()); const nameCol = header.indexOf("name"); const legendCol = header.indexOf("legend"); const factionCol = header.indexOf("faction"); if (nameCol === -1 || legendCol === -1) return []; const rows = []; for (const line of lines.slice(1)) { const fields = line.split("|"); const name = fields[nameCol]?.trim(); const legend = fields[legendCol]?.trim(); if (!name || !legend) continue; rows.push({ name, legend, faction: factionCol === -1 ? "" : (fields[factionCol]?.trim() ?? ""), }); } return rows; }; /** * Several names carry more than one legend - either the same unit reworded * between editions, or a genuinely different unit sharing a name across * factions (Chaos Daemons and Death Guard both field Plaguebearers; three * different armies field a Ministorum Priest). * * Given a `faction` column in the export, that ambiguity is resolvable and we * prefer the entry whose faction matches the card. Without one - which is the * case for today's export - fall back to the longest legend. That is not * always the *right* variant, but it is deterministic, and the competing * variants are near-identical rewrites in all but a handful of cases. */ const pickEntry = (entries, faction) => { const wanted = normalizeName(faction); if (wanted) { const match = entries.find((entry) => { const entryFaction = normalizeName(entry.faction); return ( entryFaction && (entryFaction === wanted || wanted.includes(entryFaction) || entryFaction.includes(wanted)) ); }); if (match) return match; } return entries.reduce((best, entry) => entry.legend.length > best.legend.length ? entry : best, ); }; /** * Builds the lookup. `lookup(name, faction)` returns the legend string, or * `undefined` when the unit has no entry. */ export const buildLoreIndex = (text) => { const exact = new Map(); const stems = new Map(); for (const row of parseLore(text)) { const key = normalizeName(row.name); if (!key) continue; if (!exact.has(key)) exact.set(key, []); exact.get(key).push(row); const stem = stemKey(key); if (!stems.has(stem)) stems.set(stem, []); stems.get(stem).push(row); } // Multi-word keys, longest first, for the trailing-name pass below. Single // word keys are excluded: "Guard" or "Rangers" would match half the export. const suffixKeys = [...stems.keys()] .filter((key) => key.includes(" ")) .sort((a, b) => b.length - a.length); const lookup = (name, faction) => { const normalized = normalizeName(name); if (!normalized) return undefined; const entries = exact.get(normalized) ?? stems.get(stemKey(normalized)) ?? // Last resort: the roster name ends with a name we know, which is how // faction-prefixed datasheets ("Thousand Sons Chaos Spawn") arrive. stems.get( suffixKeys.find((key) => stemKey(normalized).endsWith(` ${key}`)), ); return entries ? pickEntry(entries, faction).legend : undefined; }; return { lookup, size: exact.size }; };