Files
brevyscribe/src/helpers/lore.js
T
2026-08-03 11:20:31 +02:00

159 lines
5.4 KiB
JavaScript

// 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 };
};