Add lore texts

This commit is contained in:
2026-08-03 11:20:31 +02:00
parent 1846df6ac2
commit e7ecbcabc0
10 changed files with 2433 additions and 149 deletions
+128 -5
View File
@@ -19,7 +19,11 @@ import { Arrow, wavyLine } from "../assets/icons";
import { Weapons, hasDifferentProfiles } from "./Weapons";
import { useIndexedDB } from "../helpers/useIndexedDB"; // New hook for IndexedDB
import { ImgEditor } from "./ImgEditor";
import { trySettingLocalStorage } from "../helpers/useLocalStorage";
import {
trySettingLocalStorage,
useLocalStorage,
} from "../helpers/useLocalStorage";
import { useLore } from "../helpers/useLore";
import { HIDE_UNIT_COMPOSITION, ShortSummaryTable } from "../fork";
const getShortSummarySubtitle = (force) => {
@@ -46,6 +50,7 @@ export const Roster = ({
onePerPage,
colorUserChoice,
primaryColor,
showLore,
}) => {
if (!roster) {
return null;
@@ -70,6 +75,7 @@ export const Roster = ({
force={force}
onePerPage={onePerPage}
colorUserChoice={colorUserChoice}
showLore={showLore}
/>
</React.Fragment>
))}
@@ -77,7 +83,7 @@ export const Roster = ({
);
};
const Force = ({ force, onePerPage, colorUserChoice }) => {
const Force = ({ force, onePerPage, colorUserChoice, showLore }) => {
const { units, factionRules, rules, catalog } = force;
const mergedRules = new Map([...factionRules, ...rules]);
@@ -173,6 +179,7 @@ const Force = ({ force, onePerPage, colorUserChoice }) => {
onePerPage={onePerPage}
forceRules={rules}
colorUserChoice={colorUserChoice}
showLore={showLore}
/>
</div>
))}
@@ -181,7 +188,14 @@ const Force = ({ force, onePerPage, colorUserChoice }) => {
);
};
const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
const Unit = ({
unit,
catalog,
onePerPage,
forceRules,
colorUserChoice,
showLore,
}) => {
const [hide, setHide] = useState(false);
const hideModelCount = HIDE_UNIT_COMPOSITION;
const uploadRef = useRef();
@@ -201,6 +215,38 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
const hasImage = image && image !== "undefined";
const [bgRemoved, setBgRemoved] = useState(false);
// Flavour text, as the official cards print it beside the model image. The
// export is matched on the unit name, and whatever it comes back with can be
// edited in place - the name match is a heuristic, and a few names carry
// lore for more than one faction. An empty edit falls back to the export.
const lore = useLore();
const [loreOverride, setLoreOverride] = useLocalStorage(`lore_${name}`);
const loreText =
loreOverride && loreOverride !== "undefined"
? loreOverride
: lore?.lookup(name, catalog);
const hasLore = showLore && Boolean(loreText);
// The panel is positioned absolutely, so a long legend cannot push the header
// taller by itself and the last lines would be clipped - the longest entry in
// the export overruns a 15rem header at any width below about 1300px. Measure
// the text instead and let the header grow. Observed rather than measured
// once, because the wrap changes with the card width.
const loreRef = useRef(null);
const [loreHeight, setLoreHeight] = useState(0);
// hasLore is what mounts the observed element, so the effect has to re-run on
// it; a ref is not a reactive value, so the rule cannot see that.
// biome-ignore lint/correctness/useExhaustiveDependencies: see above
useEffect(() => {
const element = loreRef.current;
if (!element) return;
const observer = new ResizeObserver(() =>
setLoreHeight(element.scrollHeight),
);
observer.observe(element);
return () => observer.disconnect();
}, [hasLore]);
const weapons = [...meleeWeapons, ...rangedWeapons];
const weaponDescriptions = weapons
@@ -332,6 +378,8 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
<div
className="min-h-[15rem]"
style={{
// 15rem unless the legend needs more; 44px is the panel's padding.
minHeight: hasLore ? `max(15rem, ${loreHeight + 44}px)` : undefined,
paddingTop: 24,
paddingBottom: 4,
background:
@@ -430,20 +478,95 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
</div>
</div>
</div>
{hasLore && (
<>
{/* Wider than the text it sits behind, so the model image fades
into the dark rather than ending at a hard edge. */}
<div
style={{
position: "absolute",
right: 0,
top: 0,
bottom: 0,
width: "40%",
zIndex: 101,
pointerEvents: "none",
background:
"linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,.55) 40%, rgba(0,0,0,.7) 100%)",
}}
/>
<div
style={{
position: "absolute",
right: 0,
top: 0,
bottom: 0,
width: "29%",
zIndex: 102,
display: "flex",
alignItems: "center",
padding: "30px 14px 14px 6px",
overflow: "hidden",
}}
>
{/* Editable in place: see the note on `loreText` above. */}
<div
ref={loreRef}
contentEditable
suppressContentEditableWarning
spellCheck={false}
title="Click to correct this text. Clearing it restores the text from Lore.csv."
onBlur={(e) =>
setLoreOverride(e.currentTarget.innerText.trim())
}
style={{
fontStyle: "italic",
// index.css turns font synthesis off globally, so if the
// italic face fails to load this would print upright. Let
// this one element fall back to a slanted upright face.
fontSynthesis: "style",
fontSize: "1rem",
lineHeight: 1.32,
textShadow: "0 1px 2px rgba(0,0,0,.6)",
outline: "none",
}}
>
{loreText}
</div>
</div>
</>
)}
<div
style={{
position: "absolute",
right: 0,
// The image gives up its right-hand third to the lore panel, which
// is where the official cards put the flavour text. Cards without
// lore keep the full-width image they have always had.
right: hasLore ? "29%" : 0,
top: 0,
height: "100%",
bottom: 0,
width: "60%",
width: hasLore ? "40%" : "60%",
zIndex: 100,
overflow: "hidden",
}}
>
{hasImage && <ImgEditor image={image} name={name} />}
<div className="absolute right-[1px] top-[3px] flex items-center gap-1.5">
{showLore && lore && !loreText && (
<button
type="button"
className="button print-display-none border-none bg-[#f0f0f0e6] hover:bg-[#f0f0f0]"
style={{
padding: "1px 4px",
fontSize: "0.8rem",
}}
onClick={() => setLoreOverride(`Lore for ${name}.`)}
title="Lore.csv has no entry under this name. Add the text by hand."
>
Add lore
</button>
)}
{hasImage && !bgRemoved && (
<button
type="button"
+18
View File
@@ -84,6 +84,7 @@ function App() {
const [roster, setRoster] = useState();
const [edition, setEdition] = useState(10); // [9, 10, 11]
const [onePerPage, setOnePerPage] = useState(false);
const [showLore, setShowLore] = useState(true);
const [primaryColor, setPrimaryColor] = useState("#536766");
const [colorUserChoice, setColorUserChoice] = useState(false);
const uploadRef = useRef();
@@ -419,6 +420,22 @@ function App() {
One Datacard per Page when Printing
</span>
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: 4,
minHeight: 26,
}}
title="Print the flavour text from public/Lore.csv beside the model image, the way the official datasheets do."
>
<input
type="checkbox"
checked={showLore}
onChange={(e) => setShowLore(e.target.checked)}
/>
<span className="select-none">Show Lore Text</span>
</label>
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<label style={{ display: "flex", alignItems: "center", gap: 4 }}>
<input
@@ -464,6 +481,7 @@ function App() {
onePerPage={onePerPage}
colorUserChoice={colorUserChoice}
primaryColor={primaryColor}
showLore={showLore}
/>
)}
+158
View File
@@ -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 };
};
+125
View File
@@ -0,0 +1,125 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { buildLoreIndex, normalizeName, parseLore } from "./lore";
const csv = (...lines) => `name|legend\n${lines.join("\n")}\n`;
describe("normalizeName", () => {
it("collapses the casing, punctuation and quote style that vary between exports", () => {
expect(normalizeName("Tech-priest Dominus")).toBe("tech priest dominus");
expect(normalizeName("Tech-Priest Dominus")).toBe("tech priest dominus");
expect(normalizeName("Khornes Hounds")).toBe("khorne s hounds");
expect(normalizeName("Khorne's Hounds")).toBe("khorne s hounds");
});
it("survives missing input", () => {
expect(normalizeName(undefined)).toBe("");
expect(normalizeName(null)).toBe("");
});
});
describe("parseLore", () => {
it("reads the pipe-delimited export, BOM and CRLF included", () => {
const rows = parseLore("name|legend\r\nCustodian Guard|Stalwart.\r\n");
expect(rows).toEqual([
{ name: "Custodian Guard", legend: "Stalwart.", faction: "" },
]);
});
it("drops rows whose legend has not been filled in", () => {
const rows = parseLore(csv("Webway Gate|", "Bonesinger|Sings to bone."));
expect(rows.map((row) => row.name)).toEqual(["Bonesinger"]);
});
it("locates columns by header, so an added faction column just works", () => {
const rows = parseLore(
"legend|faction|name\nSings to bone.|Aeldari|Bonesinger\n",
);
expect(rows).toEqual([
{ name: "Bonesinger", legend: "Sings to bone.", faction: "Aeldari" },
]);
});
it("returns nothing for junk rather than throwing", () => {
expect(parseLore("")).toEqual([]);
expect(parseLore("unit;text\nfoo;bar")).toEqual([]);
});
});
describe("buildLoreIndex", () => {
it("matches names that differ only in case or punctuation", () => {
const { lookup } = buildLoreIndex(csv("Tech-priest Dominus|Theocrat."));
expect(lookup("Tech-Priest Dominus")).toBe("Theocrat.");
});
it("matches a plural roster name against a singular entry", () => {
const { lookup } = buildLoreIndex(csv("Myphitic Blight-hauler|Belching."));
expect(lookup("Myphitic Blight-haulers")).toBe("Belching.");
});
it("matches a singular roster name against a plural entry", () => {
const { lookup } = buildLoreIndex(csv("Plaguebearers|Foot soldiers."));
expect(lookup("Plaguebearer")).toBe("Foot soldiers.");
});
it("strips a faction prefix the export does not carry", () => {
const { lookup } = buildLoreIndex(csv("Chaos Spawn|Roiling flesh."));
expect(lookup("Thousand Sons Chaos Spawn")).toBe("Roiling flesh.");
});
it("will not match on a single trailing word", () => {
const { lookup } = buildLoreIndex(csv("Guard|Some other unit entirely."));
expect(lookup("Custodian Guard")).toBeUndefined();
});
it("prefers the entry whose faction matches the card", () => {
const { lookup } = buildLoreIndex(
"name|legend|faction\n" +
"Plaguebearers|Daemon version, which is the longer of the two.|Chaos Daemons\n" +
"Plaguebearers|Guard version.|Death Guard\n",
);
expect(lookup("Plaguebearers", "Death Guard")).toBe("Guard version.");
expect(lookup("Plaguebearers", "Chaos Daemons")).toBe(
"Daemon version, which is the longer of the two.",
);
});
it("falls back to the longest legend when the faction cannot decide it", () => {
const { lookup } = buildLoreIndex(
csv("Servitors|Short.", "Servitors|The longer, fuller entry."),
);
expect(lookup("Servitors")).toBe("The longer, fuller entry.");
expect(lookup("Servitors", "Adeptus Mechanicus")).toBe(
"The longer, fuller entry.",
);
});
it("returns undefined for a unit the export does not cover", () => {
const { lookup } = buildLoreIndex(csv("Bonesinger|Sings to bone."));
expect(lookup("Rein and Raus")).toBeUndefined();
expect(lookup("")).toBeUndefined();
});
});
// Guards the shipped export itself: a re-export that changes the delimiter or
// the header names would otherwise fail silently, every card simply losing its
// flavour text.
describe("public/Lore.csv", () => {
const index = buildLoreIndex(readFileSync("public/Lore.csv", "utf8"));
it("parses into a usable number of entries", () => {
expect(index.size).toBeGreaterThan(1000);
});
it("covers the units in the bundled example rosters", () => {
for (const name of [
"Custodian Guard",
"Bladeguard Veteran Squad",
"Plague Marines",
"Myphitic Blight-haulers",
"Thousand Sons Chaos Spawn",
]) {
expect(index.lookup(name), name).toBeTruthy();
}
});
});
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useState } from "react";
import { buildLoreIndex } from "./lore";
// `public/Lore.csv` is ~400KB, so it is fetched once, lazily, and shared by
// every card rather than bundled into the main chunk. The promise is cached at
// module scope: a roster renders 20-odd Units, and they must not each kick off
// their own request.
let pending;
const loadLore = () => {
if (!pending) {
pending = fetch("Lore.csv")
.then((response) => {
if (!response.ok) throw new Error(`Lore.csv: ${response.status}`);
return response.text();
})
.then(buildLoreIndex)
.catch((error) => {
// A missing or unreadable export is not worth failing a card over -
// the datasheet simply prints without its flavour text.
console.error(error);
return { lookup: () => undefined, size: 0 };
});
}
return pending;
};
/**
* Resolves to the lore index, or `null` until it has loaded.
*/
export const useLore = () => {
const [index, setIndex] = useState(null);
useEffect(() => {
let live = true;
loadLore().then((loaded) => live && setIndex(loaded));
return () => {
live = false;
};
}, []);
return index;
};
+8
View File
@@ -40,6 +40,14 @@
src: url("/fonts/ConduitITCStd-Regular.woff2") format("woff2");
font-weight: 400;
}
/* Datasheet lore text. Without a real italic face there would be none at all:
font-synthesis is off below, so the browser may not slant an upright one. */
@font-face {
font-family: "ConduitITCStd";
src: url("/fonts/ConduitITCStd Italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
}
* {
box-sizing: border-box;