Add lore texts
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
// 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 };
|
||||
};
|
||||
Reference in New Issue
Block a user