Add lore texts
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user