159 lines
5.4 KiB
JavaScript
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 };
|
|
};
|