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
+89 -144
View File
@@ -1,56 +1,87 @@
# BrevyScribe
A fork of [FancyScribe](https://github.com/NilsUeter/fancyscribe) that prints
**generic datasheets** rather than a record of one particular army list.
BrevyScribe is a fork of [FancyScribe](https://github.com/NilsUeter/fancyscribe).
It prints **generic datasheets** instead of a record of one army list.
FancyScribe renders a BattleScribe or New Recruit roster as 10th-edition-style
datacards, showing exactly the wargear you picked. That is the right thing for a
list you are about to play, but the wrong thing for a reference card you want to
keep: the official cards show *every* option a unit could take. BrevyScribe
rewrites the roster on the way in so the printed cards read like the official
ones.
FancyScribe shows a BattleScribe or New Recruit roster as 10th-edition
datacards. The cards show only the wargear that you selected. That is correct
for a list that you play today. It is not correct for a reference card that you
keep, because the official cards show *every* option of a unit. BrevyScribe
rewrites the roster before it renders the cards. As a result, the printed cards
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 |
| --- | --- |
| **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. |
| **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. |
| **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. |
| **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** | 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 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,
no account, and no analytics; rosters are held in `localStorage` and never leave
the machine.
The app does all of its work in the browser. There is no server component, no
upload, no account and no analytics. Rosters stay in `localStorage` and never
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
npm install
npm run dev # http://localhost:5173
npm test # the parity suite, see below
npm run build # static files into dist/
npm test
npm run build # static files in dist/
```
`npm run lint` reports pre-existing findings inherited from upstream (mostly
`a11y/useButtonType`), so it is deliberately not part of CI. Compare against
`git show upstream/main:<file>` before treating any of them as new.
## Deploy to scribe.luxick.de
## 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
for nginx to proxy to. The app does all its work in the browser, which is why
upstream can live on GitHub Pages.
One-time setup on the server. It is in two stages because of a chicken and egg:
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.
The server needs no Node. Do the setup one time, in two stages. The two stages
are necessary because the real configuration names a certificate, and nginx
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
the challenge.
```sh
sudo mkdir -p /var/www/brevyscribe /var/www/certbot
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'
server {
listen 80;
@@ -62,132 +93,46 @@ EOF
sudo ln -s /etc/nginx/sites-available/scribe.luxick.de /etc/nginx/sites-enabled/
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 nginx -t && sudo systemctl reload nginx
```
`certonly --webroot` rather than `--nginx`: the config already carries its own
redirect and TLS block, and the nginx plugin would rewrite the installed file,
leaving it drifted from the one in the repo. The port 80 block keeps its
`acme-challenge` location for exactly this reason, so renewals go on working
unattended - but certbot's timer will not reload nginx by itself, so drop a
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.
Use `certonly --webroot`, not `--nginx`. The configuration in the repository
carries its own redirect and TLS block. The nginx plugin rewrites the installed
file, and the installed file then drifts away from the one in the repository.
The port 80 block keeps its `acme-challenge` location for this reason, so
renewals continue to work without your attention.
Then every deploy is one command from a checkout on your own machine - it runs
the tests, builds, and rsyncs `dist/` over. The server needs no Node.
CAUTION: The timer of certbot does not reload nginx. Put a one-line hook that
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
./deploy/deploy.sh
```
`--delete` is deliberate: asset filenames are content-hashed, so without it old
bundles would pile up forever. There is no state on the server and nothing to
back up; every roster lives in the browser's `localStorage`.
The script uses `--delete` on purpose. The asset filenames contain a hash of
the content, so old bundles collect on the server without this flag. There is
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 -
upstream sets `/fancyscribe` for GitHub Pages. If you ever serve it from a
subpath, that is the setting to change.
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.
`index.html` loads Noto Sans from Google Fonts, so a page load contacts
`fonts.googleapis.com`. If you do not want this request, delete the `<link>`
tags. Then host the font next to the fonts in `public/fonts/`.
## Credit
All the hard parts - the parsing, the card layout, the print CSS - are
[Nils Ueter's](https://github.com/NilsUeter/fancyscribe), with parsing logic
descended in turn from
The difficult parts are the work of
[Nils Ueter](https://github.com/NilsUeter/fancyscribe): the parser, the card
layout and the print CSS. The parsing logic comes in turn from
[PrettyScribe](https://github.com/rweyrauch/PrettyScribe). Upstream ships no
licence file, so treat this fork as a private, personal deployment rather than
something to redistribute.
license file. Treat this fork as a private, personal deployment, not as
something that you redistribute.