Add lore texts
This commit is contained in:
@@ -0,0 +1,151 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Guidance for agents that work in this repository. [README.md](README.md)
|
||||||
|
describes what the app does for its users. This file describes how it is built.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
npm test # the parity suite, see "Tests"
|
||||||
|
npm run build # static files in dist/
|
||||||
|
npm run fixtures # regenerate test fixtures, see "Tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run lint` reports findings that come from upstream, mostly
|
||||||
|
`a11y/useButtonType`. These findings are old, so lint is not part of CI. Before
|
||||||
|
you treat a finding as new, compare it against `git show upstream/main:<file>`.
|
||||||
|
|
||||||
|
## Where the transforms fit
|
||||||
|
|
||||||
|
```
|
||||||
|
upload/.rosz -> unzip -> DOMParser -> applyTransforms -> Create40kRoster* -> render
|
||||||
|
^
|
||||||
|
src/transforms/index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/App.jsx` runs the transforms between the parse of the XML and the build of
|
||||||
|
the roster. A toggle therefore rebuilds the cards from the original XML, and
|
||||||
|
the user uploads nothing a second time. For the same reason, a saved roster
|
||||||
|
holds the raw roster XML and not the parsed object.
|
||||||
|
|
||||||
|
[`src/transforms/config.js`](src/transforms/config.js) lists the abilities that
|
||||||
|
the transforms strip and split. When you find more of them, add them to
|
||||||
|
`abilitiesToStrip` and `abilitiesToConvert`.
|
||||||
|
|
||||||
|
## Lore text
|
||||||
|
|
||||||
|
[`src/helpers/lore.js`](src/helpers/lore.js) matches a roster name against
|
||||||
|
`public/Lore.csv` in three steps:
|
||||||
|
|
||||||
|
1. The normalized name. Case, accents, curly quotes and punctuation all differ
|
||||||
|
between the roster and the export.
|
||||||
|
2. The name with its last word in the singular form. Example: "Myphitic
|
||||||
|
Blight-haulers" against "Myphitic Blight-hauler".
|
||||||
|
3. The longest known name that the roster name *ends* with. Example: "Thousand
|
||||||
|
Sons Chaos Spawn" against "Chaos Spawn".
|
||||||
|
|
||||||
|
This search resolves every unit in the bundled 10th-edition examples.
|
||||||
|
|
||||||
|
`parseLore` finds columns by header name. If someone exports the file again
|
||||||
|
with a `faction` column, `parseLore` can match that column against the
|
||||||
|
catalogue of the force. Nothing else has to change. Today the export has no
|
||||||
|
such column, so the longest entry wins for a name with lore in more than one
|
||||||
|
faction.
|
||||||
|
|
||||||
|
Two properties of the card header are less obvious than they look:
|
||||||
|
|
||||||
|
- The italic text needs a **fourth font file**. `ConduitITCStd` shipped as
|
||||||
|
three upright faces, and `:root` in [`src/index.css`](src/index.css) sets
|
||||||
|
`font-synthesis: none`. A request for italic therefore printed upright text
|
||||||
|
and gave no warning. `public/fonts/ConduitITCStd Italic.woff2` and its
|
||||||
|
`@font-face` rule correct this. The element also sets
|
||||||
|
`font-synthesis: style`. As a result, a face that fails to load degrades to a
|
||||||
|
slanted upright face and not to no italic at all.
|
||||||
|
- The lore panel has absolute position, so it cannot make the header taller.
|
||||||
|
Without help, a long legend is clipped: the longest entry in the export
|
||||||
|
overruns a 15rem header at each width below approximately 1300px. A
|
||||||
|
`ResizeObserver` on the text feeds the `min-height` of the header instead.
|
||||||
|
The observer is necessary because the text wraps differently at each card
|
||||||
|
width.
|
||||||
|
|
||||||
|
Only the 10th-edition and 11th-edition renderers show lore. Leave the
|
||||||
|
9th-edition renderer in `src/9th/` alone. It lays out its header differently.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
The transforms began as three standalone Python scripts
|
||||||
|
(`merge_duplicate_units`, `remove_leader_abilities`, `convert_choice_abilities`)
|
||||||
|
that rewrote the `.ros` file before the upload to FancyScribe. The scripts are
|
||||||
|
gone, but the tests measure against their output.
|
||||||
|
|
||||||
|
`src/transforms/__fixtures__/` holds four 11th-edition rosters. For each roster
|
||||||
|
it also holds the output of those scripts: `<name>.{merge,strip,convert,all}.ros`.
|
||||||
|
The suite runs each transform over the input and asserts that the result is the
|
||||||
|
same roster. This is worth more than a snapshot of the current code, because
|
||||||
|
the expected output comes from a **different implementation**. A person
|
||||||
|
verified that implementation with printed cards.
|
||||||
|
|
||||||
|
CAUTION: Do not regenerate an existing fixture from the JavaScript code. The
|
||||||
|
test then compares the code against itself and passes whatever the code does.
|
||||||
|
`scripts/update-fixtures.mjs` therefore refuses to overwrite a fixture without
|
||||||
|
`--force`. Use the script when you add a new example roster, and read the diff:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Put a new roster in src/transforms/__fixtures__/, then:
|
||||||
|
npm run fixtures
|
||||||
|
```
|
||||||
|
|
||||||
|
The comparison is structural, not textual. The Python scripts edited the file
|
||||||
|
as text and kept its format byte for byte. These transforms build DOM nodes, so
|
||||||
|
attribute order and whitespace differ legitimately. The suite compares
|
||||||
|
generated `id` and `typeId` values as *tokens*. The requirement is that ids are
|
||||||
|
shared and distinct in the same pattern, not that both implementations hash
|
||||||
|
alike.
|
||||||
|
|
||||||
|
`integration.test.js` pushes the transformed document through the real roster
|
||||||
|
parser and checks what lands on the card:
|
||||||
|
|
||||||
|
- one Skitarii card that carries both weapons
|
||||||
|
- the data-tether ability on the model that brings it
|
||||||
|
- no Support ability
|
||||||
|
- one row for each Canticle
|
||||||
|
|
||||||
|
The tests run under jsdom, which does not implement scoped selectors like a
|
||||||
|
browser. `force.querySelectorAll("force>selections>…")` finds nothing under
|
||||||
|
jsdom. A browser matches the selector against the whole tree and then keeps the
|
||||||
|
descendants of `force`, so the parser found no units at all. The two call sites
|
||||||
|
that depend on this behavior now use `:scope>…`, which works in both.
|
||||||
|
|
||||||
|
## Merges from upstream
|
||||||
|
|
||||||
|
FancyScribe is under active development, and 11th-edition support landed
|
||||||
|
recently. This fork touches little of it. The `upstream` remote is configured:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git fetch upstream
|
||||||
|
git merge upstream/main
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect conflicts only in `src/App.jsx`, `index.html`, `vite.config.js` and
|
||||||
|
`package.json`. `src/transforms/` is completely new and never conflicts.
|
||||||
|
|
||||||
|
The fork also hides three things that upstream shows, because a generic
|
||||||
|
datasheet cannot use them: the roster overview card with its charts, the unit
|
||||||
|
composition, and the points cost of each unit. These decisions live in
|
||||||
|
[`src/fork.js`](src/fork.js), which upstream does not have.
|
||||||
|
`src/10th/Roster.jsx` uses them on as few lines as possible:
|
||||||
|
|
||||||
|
- It imports `ShortSummaryTable` from `../fork`, not from `./ShortSummaryTable`.
|
||||||
|
This one-line change leaves the render site untouched. The upstream component
|
||||||
|
stays in the tree, unused, so its future diffs continue to apply.
|
||||||
|
- `hideModelCount` is pinned to `HIDE_UNIT_COMPOSITION` and is no longer a
|
||||||
|
piece of checkbox state.
|
||||||
|
|
||||||
|
Only the two checkboxes and the `pts` span are deleted. A merge that touches
|
||||||
|
them therefore reports a conflict instead of quietly bringing them back.
|
||||||
|
|
||||||
|
`vite.config.js` sets `base: "/"`, because the app is served at the root of a
|
||||||
|
domain. Upstream sets `/fancyscribe` for GitHub Pages. If you serve the app
|
||||||
|
from a subpath, change this setting.
|
||||||
@@ -1,56 +1,87 @@
|
|||||||
# BrevyScribe
|
# BrevyScribe
|
||||||
|
|
||||||
A fork of [FancyScribe](https://github.com/NilsUeter/fancyscribe) that prints
|
BrevyScribe is a fork of [FancyScribe](https://github.com/NilsUeter/fancyscribe).
|
||||||
**generic datasheets** rather than a record of one particular army list.
|
It prints **generic datasheets** instead of a record of one army list.
|
||||||
|
|
||||||
FancyScribe renders a BattleScribe or New Recruit roster as 10th-edition-style
|
FancyScribe shows a BattleScribe or New Recruit roster as 10th-edition
|
||||||
datacards, showing exactly the wargear you picked. That is the right thing for a
|
datacards. The cards show only the wargear that you selected. That is correct
|
||||||
list you are about to play, but the wrong thing for a reference card you want to
|
for a list that you play today. It is not correct for a reference card that you
|
||||||
keep: the official cards show *every* option a unit could take. BrevyScribe
|
keep, because the official cards show *every* option of a unit. BrevyScribe
|
||||||
rewrites the roster on the way in so the printed cards read like the official
|
rewrites the roster before it renders the cards. As a result, the printed cards
|
||||||
ones.
|
read like the official ones.
|
||||||
|
|
||||||
Three transforms do the work, all of them toggleable in the UI:
|
Three transforms do this work. You can switch each one on or off in the user
|
||||||
|
interface.
|
||||||
|
|
||||||
| Transform | What it does |
|
| Transform | What it does |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Merge duplicates** | Mutually exclusive wargear forces you to take a datasheet twice - one Skatros with a radium jezzail, another with a transuranic arquebus. Copies of the same unit are folded into one card carrying every option. |
|
| **Merge duplicates** | Mutually exclusive wargear makes you take one datasheet two times. One Skatros has a radium jezzail, another has a transuranic arquebus. This transform folds the copies of a unit into one card that carries all options. |
|
||||||
| **Drop Leader/Support** | Removes the attachment rules. Once the army is built they say nothing you need mid-game, and they are long enough to push the rules you *do* need off the card. |
|
| **Drop Leader/Support** | This transform removes the attachment rules. After the army is built, these rules tell you nothing that you need during a game. They are also long, and they push the necessary rules off the card. |
|
||||||
| **Split choice abilities** | An ability like *Canticles of the Omnissiah* arrives as one blob of text. This splits it into the intro rule plus one titled row per option, which is how the datasheets print it. |
|
| **Split choice abilities** | An ability such as *Canticles of the Omnissiah* arrives as one block of text. This transform divides it into the intro rule and one titled row for each option. The official datasheets print it in this form. |
|
||||||
|
|
||||||
Everything still runs in the browser. There is no server component, no upload,
|
The app does all of its work in the browser. There is no server component, no
|
||||||
no account, and no analytics; rosters are held in `localStorage` and never leave
|
upload, no account and no analytics. Rosters stay in `localStorage` and never
|
||||||
the machine.
|
leave your machine.
|
||||||
|
|
||||||
## Running it
|
## Lore text
|
||||||
|
|
||||||
|
Official datasheets print a paragraph of flavor text to the right of the model
|
||||||
|
image. The **Show Lore Text** toggle prints this text. The card then gives the
|
||||||
|
right third of its image to the text, behind a gradient that fades the image
|
||||||
|
into the dark. Cards without an entry keep the full-width image.
|
||||||
|
|
||||||
|
[`public/Lore.csv`](public/Lore.csv) supplies the text. The file is a
|
||||||
|
pipe-delimited export in this form:
|
||||||
|
|
||||||
|
```
|
||||||
|
name|legend
|
||||||
|
Custodian Guard|These warriors form the backbone of the shield companies, ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The app fetches this file only when a roster is on screen. A missing or bad
|
||||||
|
file means that the cards print without lore text.
|
||||||
|
|
||||||
|
The names in a roster do not match the export exactly, so the app searches for
|
||||||
|
the closest entry. This search is a heuristic, and you can correct it:
|
||||||
|
|
||||||
|
- To change the text of a card, edit it in place. The app keeps your edit in
|
||||||
|
`localStorage` under `lore_<unit name>`. If you clear the edit, the text of
|
||||||
|
the export comes back.
|
||||||
|
- If the export does not cover a unit, the card shows an **Add lore** button.
|
||||||
|
|
||||||
|
About 40 names carry lore for more than one faction. Chaos Daemons and Death
|
||||||
|
Guard both field Plaguebearers, and three armies field a Ministorum Priest. The
|
||||||
|
export has no column that separates them, so the longest entry wins.
|
||||||
|
|
||||||
|
Only the 10th-edition and 11th-edition cards show lore text. The 9th-edition
|
||||||
|
renderer lays out its header differently.
|
||||||
|
|
||||||
|
## Run it on your machine
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install
|
npm install
|
||||||
npm run dev # http://localhost:5173
|
npm run dev # http://localhost:5173
|
||||||
npm test # the parity suite, see below
|
npm test
|
||||||
npm run build # static files into dist/
|
npm run build # static files in dist/
|
||||||
```
|
```
|
||||||
|
|
||||||
`npm run lint` reports pre-existing findings inherited from upstream (mostly
|
## Deploy to scribe.luxick.de
|
||||||
`a11y/useButtonType`), so it is deliberately not part of CI. Compare against
|
|
||||||
`git show upstream/main:<file>` before treating any of them as new.
|
|
||||||
|
|
||||||
## Deploying to scribe.luxick.de
|
The build output is **static files**. There is no application server and no
|
||||||
|
socket, so nginx serves the files directly. The app does all of its work in the
|
||||||
|
browser. For the same reason, upstream FancyScribe can live on GitHub Pages.
|
||||||
|
|
||||||
The build output is **static files** - no application server, no socket, nothing
|
The server needs no Node. Do the setup one time, in two stages. The two stages
|
||||||
for nginx to proxy to. The app does all its work in the browser, which is why
|
are necessary because the real configuration names a certificate, and nginx
|
||||||
upstream can live on GitHub Pages.
|
refuses to load a configuration whose certificate does not exist. Therefore
|
||||||
|
nginx first comes up on port 80 only. That is far enough for certbot to answer
|
||||||
One-time setup on the server. It is in two stages because of a chicken and egg:
|
the challenge.
|
||||||
the real config names a certificate, and nginx refuses to load a config whose
|
|
||||||
certificate does not exist yet - so nginx first comes up on port 80 only, just
|
|
||||||
far enough for certbot to answer the challenge there.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo mkdir -p /var/www/brevyscribe /var/www/certbot
|
sudo mkdir -p /var/www/brevyscribe /var/www/certbot
|
||||||
sudo chown "$USER" /var/www/brevyscribe
|
sudo chown "$USER" /var/www/brevyscribe
|
||||||
|
|
||||||
# Stage 1: HTTP only, so nginx starts without a certificate.
|
# Stage 1: HTTP only, so that nginx starts without a certificate.
|
||||||
sudo tee /etc/nginx/sites-available/scribe.luxick.de >/dev/null <<'EOF'
|
sudo tee /etc/nginx/sites-available/scribe.luxick.de >/dev/null <<'EOF'
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
@@ -62,132 +93,46 @@ EOF
|
|||||||
sudo ln -s /etc/nginx/sites-available/scribe.luxick.de /etc/nginx/sites-enabled/
|
sudo ln -s /etc/nginx/sites-available/scribe.luxick.de /etc/nginx/sites-enabled/
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
|
||||||
sudo certbot --nginx -d scribe.luxick.de
|
sudo certbot certonly --webroot -w /var/www/certbot -d scribe.luxick.de
|
||||||
|
|
||||||
# Stage 2: the real config, now that the certificate is on disk.
|
# Stage 2: the real configuration, now that the certificate is on disk.
|
||||||
sudo cp deploy/nginx-scribe.luxick.de.conf /etc/nginx/sites-available/scribe.luxick.de
|
sudo cp deploy/nginx-scribe.luxick.de.conf /etc/nginx/sites-available/scribe.luxick.de
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
`certonly --webroot` rather than `--nginx`: the config already carries its own
|
Use `certonly --webroot`, not `--nginx`. The configuration in the repository
|
||||||
redirect and TLS block, and the nginx plugin would rewrite the installed file,
|
carries its own redirect and TLS block. The nginx plugin rewrites the installed
|
||||||
leaving it drifted from the one in the repo. The port 80 block keeps its
|
file, and the installed file then drifts away from the one in the repository.
|
||||||
`acme-challenge` location for exactly this reason, so renewals go on working
|
The port 80 block keeps its `acme-challenge` location for this reason, so
|
||||||
unattended - but certbot's timer will not reload nginx by itself, so drop a
|
renewals continue to work without your attention.
|
||||||
one-line hook in `/etc/letsencrypt/renewal-hooks/deploy/` that runs
|
|
||||||
`systemctl reload nginx`, or a renewed certificate will not be served until the
|
|
||||||
next restart.
|
|
||||||
|
|
||||||
Then every deploy is one command from a checkout on your own machine - it runs
|
CAUTION: The timer of certbot does not reload nginx. Put a one-line hook that
|
||||||
the tests, builds, and rsyncs `dist/` over. The server needs no Node.
|
runs `systemctl reload nginx` in `/etc/letsencrypt/renewal-hooks/deploy/`.
|
||||||
|
Without this hook, the server serves a renewed certificate only after the next
|
||||||
|
restart.
|
||||||
|
|
||||||
|
Each deploy is then one command from a checkout on your own machine. The
|
||||||
|
command runs the tests, builds the app and copies `dist/` to the server with
|
||||||
|
rsync.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./deploy/deploy.sh
|
./deploy/deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
`--delete` is deliberate: asset filenames are content-hashed, so without it old
|
The script uses `--delete` on purpose. The asset filenames contain a hash of
|
||||||
bundles would pile up forever. There is no state on the server and nothing to
|
the content, so old bundles collect on the server without this flag. There is
|
||||||
back up; every roster lives in the browser's `localStorage`.
|
no state on the server and nothing to back up. Every roster stays in the
|
||||||
|
`localStorage` of the browser.
|
||||||
|
|
||||||
`vite.config.js` sets `base: "/"`, because this is served at a domain root -
|
`index.html` loads Noto Sans from Google Fonts, so a page load contacts
|
||||||
upstream sets `/fancyscribe` for GitHub Pages. If you ever serve it from a
|
`fonts.googleapis.com`. If you do not want this request, delete the `<link>`
|
||||||
subpath, that is the setting to change.
|
tags. Then host the font next to the fonts in `public/fonts/`.
|
||||||
|
|
||||||
Note that `index.html` loads Noto Sans from Google Fonts, so a page load reaches
|
|
||||||
out to `fonts.googleapis.com`. If you would rather it did not, drop the
|
|
||||||
`<link>` tags and self-host the font next to the ones already in `public/fonts/`.
|
|
||||||
|
|
||||||
## How the transforms fit in
|
|
||||||
|
|
||||||
```
|
|
||||||
upload/.rosz -> unzip -> DOMParser -> applyTransforms -> Create40kRoster* -> render
|
|
||||||
^
|
|
||||||
src/transforms/index.js
|
|
||||||
```
|
|
||||||
|
|
||||||
`src/App.jsx` runs them between parsing the XML and building the roster, so
|
|
||||||
flipping a toggle rebuilds from the original XML with no re-upload. That is also
|
|
||||||
why saved rosters hold the raw roster XML rather than the parsed object.
|
|
||||||
|
|
||||||
Which abilities get stripped and split is configured in
|
|
||||||
[`src/transforms/config.js`](src/transforms/config.js) - add to
|
|
||||||
`abilitiesToStrip` and `abilitiesToConvert` as you meet more of them.
|
|
||||||
|
|
||||||
## The test suite
|
|
||||||
|
|
||||||
The transforms began as three standalone Python scripts (`merge_duplicate_units`,
|
|
||||||
`remove_leader_abilities`, `convert_choice_abilities`) that rewrote the `.ros`
|
|
||||||
file before it was uploaded to FancyScribe. They are gone now, but they are what
|
|
||||||
the tests measure against.
|
|
||||||
|
|
||||||
`src/transforms/__fixtures__/` holds four 11th-edition rosters plus, for each,
|
|
||||||
the output those scripts produced: `<name>.{merge,strip,convert,all}.ros`. The
|
|
||||||
suite runs each transform over the input and asserts it produces the same roster.
|
|
||||||
That is worth more than a snapshot of the current code, because the expected
|
|
||||||
output came from a **different implementation** that was verified by actually
|
|
||||||
printing the cards.
|
|
||||||
|
|
||||||
That property is easy to destroy and hard to notice: regenerate a fixture from
|
|
||||||
the JS and the test compares the code against itself, passing no matter what it
|
|
||||||
does. So `scripts/update-fixtures.mjs` will not overwrite an existing fixture
|
|
||||||
without `--force`. Use it when you add a new example roster, and read the diff:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# drop a new roster in src/transforms/__fixtures__/, then
|
|
||||||
npm run fixtures
|
|
||||||
```
|
|
||||||
|
|
||||||
Comparison is structural rather than textual. The scripts edited the file as text
|
|
||||||
to keep its formatting byte for byte; these transforms build DOM nodes, so
|
|
||||||
attribute order and whitespace legitimately differ. Generated `id`/`typeId`
|
|
||||||
values are compared as *tokens*, so what has to match is that ids are shared and
|
|
||||||
distinct in the same pattern, not that both implementations hash alike.
|
|
||||||
|
|
||||||
`integration.test.js` goes further and pushes the transformed document through
|
|
||||||
the real roster parser, checking what lands on the card: one Skitarii card
|
|
||||||
carrying both weapons, the data-tether ability travelling with the model that
|
|
||||||
brings it, no Support ability left, and each Canticle its own row.
|
|
||||||
|
|
||||||
One wrinkle: the tests run under jsdom, which does not implement scoped selectors
|
|
||||||
the way browsers do. `force.querySelectorAll("force>selections>…")` finds nothing
|
|
||||||
there, where a browser matches the selector against the whole tree and then keeps
|
|
||||||
the descendants of `force` - so under jsdom the parser found no units at all. The
|
|
||||||
two call sites that relied on it now say `:scope>…`, which means the same thing in
|
|
||||||
a browser and works in both.
|
|
||||||
|
|
||||||
## Staying current with upstream
|
|
||||||
|
|
||||||
FancyScribe is actively developed - 11th edition support landed recently - and
|
|
||||||
this fork touches little of it. `upstream` is wired up:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git fetch upstream
|
|
||||||
git merge upstream/main
|
|
||||||
```
|
|
||||||
|
|
||||||
Conflicts should be confined to `src/App.jsx`, `index.html`, `vite.config.js` and
|
|
||||||
`package.json`. `src/transforms/` is entirely new and will never conflict.
|
|
||||||
|
|
||||||
The fork also hides three things upstream shows, because a generic datasheet has
|
|
||||||
nothing to say with them: the roster overview card and its charts, the unit
|
|
||||||
composition, and the per-unit points cost. Those decisions live in
|
|
||||||
[`src/fork.js`](src/fork.js) - another file upstream does not have - and
|
|
||||||
`src/10th/Roster.jsx` reaches for them on as few lines as possible:
|
|
||||||
|
|
||||||
- it imports `ShortSummaryTable` from `../fork` instead of `./ShortSummaryTable`,
|
|
||||||
a one-line change that leaves the render site untouched. The upstream
|
|
||||||
component is still in the tree, unused, so its future diffs keep applying.
|
|
||||||
- `hideModelCount` is pinned to `HIDE_UNIT_COMPOSITION` instead of being a piece
|
|
||||||
of checkbox state.
|
|
||||||
|
|
||||||
Only the two checkboxes and the `pts` span are deleted outright, so a merge that
|
|
||||||
touches them will say so rather than quietly bringing them back.
|
|
||||||
|
|
||||||
## Credit
|
## Credit
|
||||||
|
|
||||||
All the hard parts - the parsing, the card layout, the print CSS - are
|
The difficult parts are the work of
|
||||||
[Nils Ueter's](https://github.com/NilsUeter/fancyscribe), with parsing logic
|
[Nils Ueter](https://github.com/NilsUeter/fancyscribe): the parser, the card
|
||||||
descended in turn from
|
layout and the print CSS. The parsing logic comes in turn from
|
||||||
[PrettyScribe](https://github.com/rweyrauch/PrettyScribe). Upstream ships no
|
[PrettyScribe](https://github.com/rweyrauch/PrettyScribe). Upstream ships no
|
||||||
licence file, so treat this fork as a private, personal deployment rather than
|
license file. Treat this fork as a private, personal deployment, not as
|
||||||
something to redistribute.
|
something that you redistribute.
|
||||||
|
|||||||
+1713
File diff suppressed because it is too large
Load Diff
Binary file not shown.
+127
-4
@@ -19,7 +19,11 @@ import { Arrow, wavyLine } from "../assets/icons";
|
|||||||
import { Weapons, hasDifferentProfiles } from "./Weapons";
|
import { Weapons, hasDifferentProfiles } from "./Weapons";
|
||||||
import { useIndexedDB } from "../helpers/useIndexedDB"; // New hook for IndexedDB
|
import { useIndexedDB } from "../helpers/useIndexedDB"; // New hook for IndexedDB
|
||||||
import { ImgEditor } from "./ImgEditor";
|
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";
|
import { HIDE_UNIT_COMPOSITION, ShortSummaryTable } from "../fork";
|
||||||
|
|
||||||
const getShortSummarySubtitle = (force) => {
|
const getShortSummarySubtitle = (force) => {
|
||||||
@@ -46,6 +50,7 @@ export const Roster = ({
|
|||||||
onePerPage,
|
onePerPage,
|
||||||
colorUserChoice,
|
colorUserChoice,
|
||||||
primaryColor,
|
primaryColor,
|
||||||
|
showLore,
|
||||||
}) => {
|
}) => {
|
||||||
if (!roster) {
|
if (!roster) {
|
||||||
return null;
|
return null;
|
||||||
@@ -70,6 +75,7 @@ export const Roster = ({
|
|||||||
force={force}
|
force={force}
|
||||||
onePerPage={onePerPage}
|
onePerPage={onePerPage}
|
||||||
colorUserChoice={colorUserChoice}
|
colorUserChoice={colorUserChoice}
|
||||||
|
showLore={showLore}
|
||||||
/>
|
/>
|
||||||
</React.Fragment>
|
</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 { units, factionRules, rules, catalog } = force;
|
||||||
const mergedRules = new Map([...factionRules, ...rules]);
|
const mergedRules = new Map([...factionRules, ...rules]);
|
||||||
|
|
||||||
@@ -173,6 +179,7 @@ const Force = ({ force, onePerPage, colorUserChoice }) => {
|
|||||||
onePerPage={onePerPage}
|
onePerPage={onePerPage}
|
||||||
forceRules={rules}
|
forceRules={rules}
|
||||||
colorUserChoice={colorUserChoice}
|
colorUserChoice={colorUserChoice}
|
||||||
|
showLore={showLore}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 [hide, setHide] = useState(false);
|
||||||
const hideModelCount = HIDE_UNIT_COMPOSITION;
|
const hideModelCount = HIDE_UNIT_COMPOSITION;
|
||||||
const uploadRef = useRef();
|
const uploadRef = useRef();
|
||||||
@@ -201,6 +215,38 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
|||||||
const hasImage = image && image !== "undefined";
|
const hasImage = image && image !== "undefined";
|
||||||
const [bgRemoved, setBgRemoved] = useState(false);
|
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 weapons = [...meleeWeapons, ...rangedWeapons];
|
||||||
|
|
||||||
const weaponDescriptions = weapons
|
const weaponDescriptions = weapons
|
||||||
@@ -332,6 +378,8 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
|||||||
<div
|
<div
|
||||||
className="min-h-[15rem]"
|
className="min-h-[15rem]"
|
||||||
style={{
|
style={{
|
||||||
|
// 15rem unless the legend needs more; 44px is the panel's padding.
|
||||||
|
minHeight: hasLore ? `max(15rem, ${loreHeight + 44}px)` : undefined,
|
||||||
paddingTop: 24,
|
paddingTop: 24,
|
||||||
paddingBottom: 4,
|
paddingBottom: 4,
|
||||||
background:
|
background:
|
||||||
@@ -430,20 +478,95 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
right: 0,
|
right: 0,
|
||||||
top: 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",
|
||||||
|
// 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%",
|
height: "100%",
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
width: "60%",
|
width: hasLore ? "40%" : "60%",
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasImage && <ImgEditor image={image} name={name} />}
|
{hasImage && <ImgEditor image={image} name={name} />}
|
||||||
<div className="absolute right-[1px] top-[3px] flex items-center gap-1.5">
|
<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 && (
|
{hasImage && !bgRemoved && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
+18
@@ -84,6 +84,7 @@ function App() {
|
|||||||
const [roster, setRoster] = useState();
|
const [roster, setRoster] = useState();
|
||||||
const [edition, setEdition] = useState(10); // [9, 10, 11]
|
const [edition, setEdition] = useState(10); // [9, 10, 11]
|
||||||
const [onePerPage, setOnePerPage] = useState(false);
|
const [onePerPage, setOnePerPage] = useState(false);
|
||||||
|
const [showLore, setShowLore] = useState(true);
|
||||||
const [primaryColor, setPrimaryColor] = useState("#536766");
|
const [primaryColor, setPrimaryColor] = useState("#536766");
|
||||||
const [colorUserChoice, setColorUserChoice] = useState(false);
|
const [colorUserChoice, setColorUserChoice] = useState(false);
|
||||||
const uploadRef = useRef();
|
const uploadRef = useRef();
|
||||||
@@ -419,6 +420,22 @@ function App() {
|
|||||||
One Datacard per Page when Printing
|
One Datacard per Page when Printing
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||||
<label style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
<label style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||||
<input
|
<input
|
||||||
@@ -464,6 +481,7 @@ function App() {
|
|||||||
onePerPage={onePerPage}
|
onePerPage={onePerPage}
|
||||||
colorUserChoice={colorUserChoice}
|
colorUserChoice={colorUserChoice}
|
||||||
primaryColor={primaryColor}
|
primaryColor={primaryColor}
|
||||||
|
showLore={showLore}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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("Khorne’s 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -40,6 +40,14 @@
|
|||||||
src: url("/fonts/ConduitITCStd-Regular.woff2") format("woff2");
|
src: url("/fonts/ConduitITCStd-Regular.woff2") format("woff2");
|
||||||
font-weight: 400;
|
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;
|
box-sizing: border-box;
|
||||||
|
|||||||
Reference in New Issue
Block a user