Compare commits

..

22 Commits

Author SHA1 Message Date
luxick 836a53e95e Rename Invulnerable Save for 11th edition 2026-08-03 17:36:53 +02:00
luxick 0623ba903b Update AdMech background 2026-08-03 17:32:52 +02:00
luxick 96bcc975a7 Adjust Image rendering in header
there are now layers in the card header so that the image does not
overlap any readable text
2026-08-03 17:15:03 +02:00
luxick e7ecbcabc0 Add lore texts 2026-08-03 11:20:31 +02:00
luxick 1846df6ac2 Revert "Comma serparate keywords on sheet"
This reverts commit 41edd40cd5.
2026-07-30 20:39:51 +02:00
luxick 163ee34d8d Use ConduitITCStd font 2026-07-25 20:21:19 +02:00
luxick 783ff0df1e Remove unused ci workflow 2026-07-25 19:45:44 +02:00
luxick 41edd40cd5 Comma serparate keywords on sheet
CI / check (push) Has been cancelled
2026-07-25 19:44:06 +02:00
luxick cf67918bc0 Update README.md
CI / check (push) Has been cancelled
2026-07-25 19:37:32 +02:00
luxick 60850f026c Update nginx-scribe.luxick.de.conf
CI / check (push) Has been cancelled
2026-07-25 19:36:41 +02:00
luxick 444d26fd47 Improve Keyword rendering
CI / check (push) Has been cancelled
2026-07-25 16:08:21 +02:00
luxick b35c88b451 Remove unneded upstream features
CI / check (push) Has been cancelled
2026-07-25 15:38:41 +02:00
luxick 62d6bd211b Fork FancyScribe as BrevyScribe with the datasheet transforms built in
CI / check (push) Has been cancelled
Print generic datasheets - every option a unit could take, the way the
official cards read - instead of a record of one particular list.

The three Python scripts that used to rewrite the .ros file before upload
are now transforms in src/transforms/, running in the browser between
DOMParser and the roster parser. Most of each script was machinery for
preserving the file byte for byte on the way back to disk; in the browser
the document is never serialised, so only the domain logic came across.

Because they run on the parsed document rather than the uploaded file,
flipping a transform off rebuilds from the original XML with no
re-upload. Saved rosters therefore hold the raw roster XML rather than
the parsed object, under new localStorage keys.

The scripts' output over four 11th-edition rosters is checked in as test
fixtures, so the port is measured against an independent implementation
that was verified by printing the cards; scripts/update-fixtures.mjs
refuses to overwrite those without --force.

Two parser call sites now use :scope> rather than repeating the scope
element's own name, which means the same thing in a browser and also
works under jsdom, where the old form matched nothing.

Also: served at the root rather than a GitHub Pages subpath, PostHog
analytics removed, and deploy/ carries an nginx site for
scribe.luxick.de plus an rsync deploy script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 15:11:10 +02:00
NilsUeter 3a0fdc2c26 add a skew-o-meter, small adjust 2026-06-25 18:15:14 +02:00
NilsUeter 368f42bfea add a skew-o-meter 2026-06-24 19:13:45 +02:00
NilsUeter d49e3bcd5f show force disposition 2026-06-23 19:13:12 +02:00
NilsUeter 040536d18c add 11th edition support 2026-06-23 18:45:14 +02:00
NilsUeter d5c1dda11a add 11th edition support 2026-06-23 18:09:20 +02:00
NilsUeter 668d598002 add 11th edition support 2026-06-23 16:54:00 +02:00
NilsUeter 53f2a2d69f Move Unit Composition into the main table 2026-06-07 11:28:35 +02:00
NilsUeter 50fd17d41b Move Unit Composition into the main table 2026-06-07 11:27:57 +02:00
NilsUeter 2ea21b1b60 small style improvements 2026-06-07 11:11:49 +02:00
54 changed files with 7271 additions and 303 deletions
+8
View File
@@ -0,0 +1,8 @@
# Roster files are test fixtures. Their ability text carries meaningful newlines
# and the paragraph splitter matches on \n, so a CRLF translation on checkout
# would silently change what the transforms see.
*.ros -text
*.rosz -text
# Has to stay LF to run on the server.
*.sh text eol=lf
-50
View File
@@ -1,50 +0,0 @@
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and deploys to Github Pages
name: Build with NodeJS & deploy static content to Pages
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
# Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow one concurrent deployment
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
# Single deploy job to Github Pages
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v4
- name: Set up NodeJS 19
uses: actions/setup-node@v4
with:
node-version: "19"
cache: "npm"
- name: Clean install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
# Upload build dir
path: "./dist"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+171
View File
@@ -0,0 +1,171 @@
# 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.
## The header has five layers
The card header stacks the coloured accent bar (1), the model image (2), the
name and the stat line (3) and the two lore elements (4, 5). The order comes
from the official cards, where a wide image passes *behind* the text. Every
layer therefore carries an explicit `z-index`, and the header sets
`isolation: isolate` so those five numbers never meet the rest of the card. A
new absolutely positioned element in the header needs a number from this scale;
without one it lands under the image.
The name and the stat line are wide, mostly empty boxes, and they now lie over
the image. `pointer-events: none` on the box with `auto` on the text inside
keeps the drag and the wheel that place the image (`ImgEditor`) reaching it. A
new element on layer 3 needs the same treatment, or it takes the pointer away
from the image behind it.
The image box also carries a `mask-image` (`imageFade` in
[`src/10th/Roster.jsx`](src/10th/Roster.jsx)). A picture wider than its box
would otherwise end at a hard vertical edge in the middle of the stat line.
## 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.
+138
View File
@@ -0,0 +1,138 @@
# BrevyScribe
BrevyScribe is a fork of [FancyScribe](https://github.com/NilsUeter/fancyscribe).
It prints **generic datasheets** instead of a record of one army list.
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 this work. You can switch each one on or off in the user
interface.
| Transform | What it does |
| --- | --- |
| **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. |
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.
## 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
npm run build # static files in dist/
```
## Deploy 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 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 that nginx starts without a certificate.
sudo tee /etc/nginx/sites-available/scribe.luxick.de >/dev/null <<'EOF'
server {
listen 80;
listen [::]:80;
server_name scribe.luxick.de;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
}
EOF
sudo ln -s /etc/nginx/sites-available/scribe.luxick.de /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot certonly --webroot -w /var/www/certbot -d scribe.luxick.de
# 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
```
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.
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
```
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.
`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
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
license file. Treat this fork as a private, personal deployment, not as
something that you redistribute.
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
#
# Build BrevyScribe and push it to the server. The site is static, so a deploy
# is just "replace the files".
#
# ./deploy/deploy.sh
# TARGET=me@otherhost:/srv/site ./deploy/deploy.sh
#
# Override TARGET to send it somewhere else. Nothing on the server needs Node:
# the build happens here and only dist/ travels.
set -euo pipefail
TARGET="${TARGET:-luxick@scribe.luxick.de:/var/www/brevyscribe/}"
cd "$(dirname "$0")/.."
# Don't ship a build whose transforms no longer match the Python reference.
npm test
npm run build
# --delete removes assets from previous builds; their filenames are hashed, so
# they would otherwise pile up forever. The trailing slash on dist/ copies the
# contents rather than the directory itself.
rsync --archive --compress --delete --human-readable --progress \
dist/ "$TARGET"
echo
echo "Deployed to $TARGET"
+73
View File
@@ -0,0 +1,73 @@
# BrevyScribe is a static site: the build produces plain files and the app does
# all its work in the browser, so nginx serves it directly - there is no
# application server, no socket and nothing to proxy to.
#
# Install as /etc/nginx/sites-available/scribe.luxick.de, symlink it into
# sites-enabled, then `nginx -t && systemctl reload nginx`.
server {
listen 80;
listen [::]:80;
server_name scribe.luxick.de;
# Let certbot answer the challenge, send everything else to HTTPS.
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
# The `http2 on;` directive only exists from nginx 1.25.1; on older builds
# (Debian bookworm ships 1.22, Ubuntu 22.04 ships 1.18) it is an unknown
# directive and the config will not load. This form works everywhere, at the
# price of a deprecation warning on 1.25.1+.
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name scribe.luxick.de;
ssl_certificate /etc/letsencrypt/live/scribe.luxick.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/scribe.luxick.de/privkey.pem;
# `certbot certonly` issues the certificate and stops there - unlike the nginx
# plugin it never writes an `options-ssl-nginx.conf` include, so the protocol
# and session settings have to live here.
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
root /var/www/brevyscribe;
index index.html;
gzip on;
gzip_types text/css application/javascript image/svg+xml application/xml;
gzip_min_length 1024;
# Vite gives these content-hashed filenames, so they can never go stale.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# The example rosters and the fonts keep their names across builds, so they
# get a short cache rather than an immutable one.
location ~* \.(rosz|woff2)$ {
expires 1h;
add_header Cache-Control "public";
}
# index.html names the hashed assets, so it must never be cached: a stale copy
# would point at a bundle that no longer exists.
location = /index.html {
add_header Cache-Control "no-cache";
}
# A single page app with no router, but serving index.html for an unknown path
# is friendlier than a bare 404.
location / {
try_files $uri $uri/ /index.html;
}
}
+2 -10
View File
@@ -3,10 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fancyscribe</title>
<title>BrevyScribe</title>
<meta
name="description"
content="A fancy way to view your Warhammer 40k BattleScribe rosters."
content="A fancy way to print generic Warhammer 40k datasheets from a BattleScribe roster."
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@@ -14,14 +14,6 @@
href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
<script>
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
// if localhost then return
if(window.location.hostname !== 'localhost')
{
posthog.init('phc_QqImEAwwTBa3yh4DhiQ1bCHWA3DBr3lPuosKNRwSuEq',{api_host:'https://cool-silence-eae1.nils-ueter.workers.dev/', ui_host: 'https://eu.posthog.com'})
}
</script>
</head>
<body>
<div id="root"></div>
+964 -8
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -1,5 +1,5 @@
{
"name": "fancyscribe",
"name": "brevyscribe",
"private": true,
"version": "0.0.0",
"type": "module",
@@ -7,7 +7,10 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "biome check ."
"lint": "biome check .",
"test": "vitest run",
"test:watch": "vitest",
"fixtures": "node scripts/update-fixtures.mjs"
},
"dependencies": {
"chart.js": "^4.4.8",
@@ -17,18 +20,20 @@
"react-dom": "^19.0.0"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "^19.0.0-beta-e993439-20250328",
"eslint-plugin-react-compiler": "^19.0.0-beta-e993439-20250328",
"jsdom": "^29.1.1",
"postcss": "^8.5.3",
"prettier": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^3.4.17",
"vite": "^6.2.5",
"@biomejs/biome": "^1.9.4"
"vitest": "^4.1.10"
},
"prettier": {
"useTabs": true,
+1713
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env node
//
// Write the expected-output fixtures for a roster added to
// src/transforms/__fixtures__/.
//
// node scripts/update-fixtures.mjs # only writes what is missing
// node scripts/update-fixtures.mjs --force # re-baselines everything
//
// Drop a new `<name>.ros` in that directory and run this to generate the four
// `<name>.{merge,strip,convert,all}.ros` files the test suite compares against.
// Read the diff before committing: this records whatever the transforms do
// today, so it cannot tell a fix from a regression.
//
// Existing fixtures are left alone unless --force is given, and that guard is
// the point rather than a convenience. The fixtures that came with this repo
// were produced by a separate Python implementation and verified by printing
// the cards; the test suite is worth something because it checks the transforms
// against that independent output. Re-baselining a fixture from the JS replaces
// it with the very thing under test, and the comparison becomes a tautology
// that passes no matter what the code does.
import { readFileSync, readdirSync, existsSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { JSDOM } from "jsdom";
import {
convertChoiceAbilities,
defaultConfig,
mergeDuplicateUnits,
removeLeaderAbilities,
} from "../src/transforms/index.js";
const FIXTURES = join(
dirname(fileURLToPath(import.meta.url)),
"..",
"src",
"transforms",
"__fixtures__",
);
const STEPS = {
merge: (doc) => mergeDuplicateUnits(doc, defaultConfig),
strip: (doc) => removeLeaderAbilities(doc, defaultConfig),
convert: (doc) => convertChoiceAbilities(doc, defaultConfig),
};
// Chained, in the order the app runs them; see src/transforms/index.js.
const PIPELINE = ["merge", "strip", "convert"];
const force = process.argv.includes("--force");
const { window } = new JSDOM();
const inputs = readdirSync(FIXTURES)
.filter((name) => name.endsWith(".ros") && name.split(".").length === 2)
.map((name) => name.replace(/\.ros$/, ""));
let written = 0;
let kept = 0;
for (const input of inputs) {
const xml = readFileSync(join(FIXTURES, `${input}.ros`), "utf8");
const outputs = { ...STEPS, all: null };
for (const label of Object.keys(outputs)) {
const path = join(FIXTURES, `${input}.${label}.ros`);
if (existsSync(path) && !force) {
kept++;
continue;
}
const doc = new window.DOMParser().parseFromString(xml, "text/xml");
if (label === "all") {
for (const step of PIPELINE) STEPS[step](doc);
} else {
STEPS[label](doc);
}
writeFileSync(path, new window.XMLSerializer().serializeToString(doc), "utf8");
console.log(`wrote ${input}.${label}.ros`);
written++;
}
}
console.log(`\n${written} written, ${kept} left alone.`);
if (kept > 0 && !force) {
console.log("Pass --force to re-baseline the rest, but read the header first.");
}
+257 -63
View File
@@ -19,14 +19,44 @@ 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 { ShortSummaryTable } from "./ShortSummaryTable";
import {
trySettingLocalStorage,
useLocalStorage,
} from "../helpers/useLocalStorage";
import { useLore } from "../helpers/useLore";
import { HIDE_UNIT_COMPOSITION, ShortSummaryTable } from "../fork";
// Soft left edge for the model image in the card header. Opaque over the right
// two thirds of the image box, so only the part that reaches across the stat
// line is faded.
const imageFade =
"linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.55) 16%, rgba(0,0,0,1) 36%)";
const getShortSummarySubtitle = (force) => {
const details = [];
if (force.forceDisposition) {
details.push(force.forceDisposition);
}
if (force.detachments?.length) {
details.push(
force.detachments
.map(
(detachment) =>
`${detachment.name} (${detachment.detachmentPoints} DP)`,
)
.join(", "),
);
}
return details.join(" - ");
};
export const Roster = ({
roster,
onePerPage,
colorUserChoice,
primaryColor,
showLore,
}) => {
if (!roster) {
return null;
@@ -43,6 +73,7 @@ export const Roster = ({
<ShortSummaryTable
force={force}
name={name}
subtitle={getShortSummarySubtitle(force)}
points={cost.points}
primaryColor={primaryColor}
/>
@@ -50,6 +81,7 @@ export const Roster = ({
force={force}
onePerPage={onePerPage}
colorUserChoice={colorUserChoice}
showLore={showLore}
/>
</React.Fragment>
))}
@@ -57,7 +89,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]);
@@ -83,11 +115,11 @@ const Force = ({ force, onePerPage, colorUserChoice }) => {
>
{unitOrder.map((unit, index) => (
<div key={unit.name + index} className="relative">
<div className="flex items-center justify-start gap-1 absolute text-[13px] -top-2.5 z-10 print-display-none">
<div className="print-display-none absolute -top-2.5 z-10 flex items-center justify-start gap-1 text-[13px]">
<button
disabled={index === 0}
type="button"
className="py-0.5 px-1.5 pl-1"
className="px-1.5 py-0.5 pl-1"
onClick={() =>
// move unit up in the order
setUnitOrder((prevOrder) => {
@@ -118,7 +150,7 @@ const Force = ({ force, onePerPage, colorUserChoice }) => {
<button
disabled={index === unitOrder.length - 1}
type="button"
className="py-0.5 px-1.5 pl-1"
className="px-1.5 py-0.5 pl-1"
onClick={() =>
// move unit down in the order
setUnitOrder((prevOrder) => {
@@ -153,6 +185,7 @@ const Force = ({ force, onePerPage, colorUserChoice }) => {
onePerPage={onePerPage}
forceRules={rules}
colorUserChoice={colorUserChoice}
showLore={showLore}
/>
</div>
))}
@@ -161,9 +194,16 @@ 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, setHideModelCount] = useState(false);
const hideModelCount = HIDE_UNIT_COMPOSITION;
const uploadRef = useRef();
let {
name,
@@ -181,6 +221,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
@@ -295,23 +367,6 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
}}
>
<div className="flex justify-end gap-3 pb-0.5">
<label
className="print-display-none"
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 4,
userSelect: "none",
}}
>
<input
className="hide-model-selection"
type="checkbox"
onChange={(e) => setHideModelCount(e.target.checked)}
/>
<span className="print-display-none">Hide model selection</span>
</label>
<label
className="print-display-none"
style={{
@@ -329,6 +384,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:
@@ -337,6 +394,9 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
backgroundSize: "cover",
color: "#fff",
position: "relative",
// Keeps the header's five layers to itself, so their z-indices
// never have to be compared against the rest of the card.
isolation: "isolate",
}}
>
<div
@@ -355,6 +415,11 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
top: 0,
width: "100%",
display: "flex",
// The model image sits between this accent bar and the text,
// as it does on the official cards. Every layer of the header
// therefore needs an explicit z-index: bar 1, image 2, text 3,
// lore 4 and 5.
zIndex: 1,
}}
>
<div
@@ -395,25 +460,29 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
lineHeight: "1",
fontWeight: 800,
textTransform: "uppercase",
zIndex: 1,
zIndex: 3,
position: "relative",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 2,
// This box is as wide as the card and now lies over the image,
// where it would swallow the drag and the wheel that place the
// image. Only the glyphs need the pointer.
pointerEvents: "none",
}}
>
{name}
<span style={{ textTransform: "initial", fontSize: "1.2rem" }}>
{cost.points}pts
</span>
<span style={{ pointerEvents: "auto" }}>{name}</span>
</div>
<div className="relative flex gap-4">
<div className="pointer-events-none relative z-[3] flex gap-4">
<div
style={{
display: "flex",
flexDirection: "column",
gap: 6,
// See the note on the name above. The stat boxes are narrow,
// but the box around them reaches the far edge of the card.
pointerEvents: "auto",
}}
>
{modelStats.map((model, index) => (
@@ -428,37 +497,103 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
/>
))}
</div>
{!hideModelCount && modelStats?.[0] && (
<div
className={`pointer-events-none mt-[17px] ${
modelStats.length > 1 ? "" : "self-center"
}`}
style={{
fontSize: "0.7em",
zIndex: 101,
}}
>
{modelList.map((model, index) => (
<div key={model}>{model}</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
style={{
position: "absolute",
right: 0,
top: 0,
bottom: 0,
width: "40%",
zIndex: 4,
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: 5,
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%",
bottom: 0,
width: "60%",
zIndex: 100,
width: hasLore ? "40%" : "60%",
zIndex: 2,
overflow: "hidden",
// A picture wider than its box would otherwise end at a hard
// vertical edge over the stat line. The official cards let it
// fade into the dark background instead. The mask fades the
// image itself, so the background stays untouched.
maskImage: imageFade,
WebkitMaskImage: imageFade,
}}
>
{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"
@@ -506,9 +641,6 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
className="print-display-none"
accept=".jpg,.png,.jpeg,.gif,.bmp,.tif,.tiff,.webp,.svg,.jfif,.pjpeg,.pjp,.avif,.apng,.ico,.cur,.ani"
onChange={(e) => {
posthog?.capture?.("user_uploaded_image", {
unit_name: name,
});
if (e.target.files?.[0]) {
const reader = new FileReader();
reader.onload = ((ev) => {
@@ -584,6 +716,11 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
forceRules={forceRules}
/>
<OtherAbilities abilities={abilities} />
<UnitComposition
hideModelCount={hideModelCount}
modelStats={modelStats}
modelList={modelList}
/>
</table>
<div style={{ flex: "1" }} />
<table
@@ -648,6 +785,45 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
);
};
const UnitComposition = ({ hideModelCount, modelStats, modelList }) => {
return (
<>
{!hideModelCount && modelStats?.[0] && (
<>
<thead>
<tr
style={{
backgroundColor: "var(--primary-color)",
color: "#fff",
}}
>
<th></th>
<th className="text-left uppercase">Unit Composition</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody style={{
fontSize: "0.7em",
}}>
{modelList.map((model, index) => (
<tr key={model}>
<td></td>
<td style={{ textAlign: "left" }} colSpan={7} key={model}>{model}</td>
</tr>
))}
</tbody>
</>
)}
</>
);
};
const backgrounds = {
"Imperium - Adeptus Astartes - Dark Angels": imperiumBackground,
"Imperium - Adeptus Astartes - Space Wolves": imperiumBackground,
@@ -793,7 +969,7 @@ const ModelStats = ({ modelStat, index, showName, abilities }) => {
wounds = "/";
}
const hasInvul = checkAbilitiesForInvul(abilities, name);
const hasInvul = modelStat.invulnerableSave || checkAbilitiesForInvul(abilities, name);
return (
<div className="flex flex-col">
<div style={{ display: "flex", gap: "1.2rem" }}>
@@ -848,13 +1024,13 @@ const InvulRow = ({ hasInvul }) => {
marginTop: -2,
marginLeft: "-6rem",
paddingLeft: "6.4rem",
paddingRight: "1.2rem",
paddingRight: "0.4rem",
paddingTop: 2,
paddingBottom: 1,
backgroundColor: "var(--primary-color)",
}}
>
INVULNERABLE SAVE{isSpecialInvul ? "*" : ""}
INSV{isSpecialInvul ? "*" : ""}
</span>
</div>
);
@@ -1034,11 +1210,13 @@ const removeKeywordsSet = new Set([
"Melee Weapon",
"Ranged Weapon",
"Attacks Dx Weapon",
"Extra Attacks Weapon"
"Extra Attacks Weapon",
]);
const Keywords = ({ keywords }) => {
const joinedKeywords = [...keywords].filter(key => !removeKeywordsSet.has(key)).join(", ")
const joinedKeywords = [...keywords]
.filter((key) => !removeKeywordsSet.has(key))
.join("; ");
return (
<div
style={{
@@ -1058,12 +1236,14 @@ const Keywords = ({ keywords }) => {
gap: 3,
}}
>
<span style={{ fontSize: "1.1em" }}>KEYWORDS:</span>
<span className="font-normal" style={{ fontSize: "1em" }}>
KEYWORDS:
</span>
<span
className="uppercase"
style={{
fontSize: joinedKeywords.length > 70 ? ".8em" : "1em",
fontWeight: 800,
marginTop: 1,
}}
>
{joinedKeywords}
@@ -1093,10 +1273,13 @@ const Factions = ({ factions }) => {
minHeight: 54,
}}
>
<span style={{ fontSize: ".9em", lineHeight: 1.3 }}>
<span
className="font-normal"
style={{ fontSize: ".9em", lineHeight: 1.3 }}
>
FACTION KEYWORDS:
</span>
<span style={{ fontSize: ".9em", fontWeight: 600 }}>
<span className="uppercase" style={{ fontSize: ".9em", fontWeight: 600 }}>
{[...factions].join(", ")}
</span>
</div>
@@ -1325,8 +1508,15 @@ const makeKeywordsBold = (text) => {
}
}
// replace words wrapped in ^^ ^^ with <strong> tags
newValue = newValue.replace(/\^\^([^\^]+)\^\^/g, "<strong>$1</strong>");
// replace words wrapped in ^^ ^^ with <strong> tags. ^^ is the exporter's
// datasheet-keyword marker, and the printed cards set keywords in bold
// uppercase - the same treatment boldKeywords gives the ones it knows. The
// case comes from CSS rather than toUpperCase() because the replacements above
// have already put HTML in here, and var(--primary-color) is case-sensitive.
newValue = newValue.replace(
/\^\^([^\^]+)\^\^/g,
'<strong style="text-transform: uppercase;">$1</strong>',
);
// replace words wrapped in ** ** with <strong> tags
newValue = newValue.replace(/\*\*([^\*]+)\*\*/g, "<strong>$1</strong>");
@@ -1451,7 +1641,11 @@ const OtherAbilities = ({ abilities }) => {
paddingBottom: 4,
}}
>
<span style={{ fontWeight: 700 }}>{name}:</span> {value}
<span style={{ fontWeight: 700 }}>{name}:</span>{" "}
<span
className="whitespace-pre-line"
dangerouslySetInnerHTML={{ __html: makeKeywordsBold(value) }}
/>
</td>
</tr>
))}
+238 -9
View File
@@ -67,7 +67,10 @@ const getNameMatchScore = (statName = "", modelName = "") => {
}
let prefixMatches = 0;
const maxPrefixLength = Math.min(normalizedStat.length, normalizedModel.length);
const maxPrefixLength = Math.min(
normalizedStat.length,
normalizedModel.length,
);
for (let i = 0; i < maxPrefixLength; i++) {
if (normalizedStat[i] !== normalizedModel[i]) {
break;
@@ -118,8 +121,121 @@ const getUnitTotalOc = (unit) =>
0,
) || 0;
export const ShortSummaryTable = ({ force, primaryColor, name, points }) => {
const getUnitTotalModels = (unit) =>
unit?.models?.reduce((sum, model) => sum + (model.count || 0), 0) || 1;
const getUnitPointsPerModel = (unit) => {
const totalModels = getUnitTotalModels(unit);
return totalModels > 0 ? (unit?.cost?.points || 0) / totalModels : 0;
};
const getDefensiveProfile = (unit, stat = {}, modelCount = 1) => {
const toughness = stat.toughness || 0;
const wounds = stat.wounds || 0;
const save = stat.save?.replace(/\+/g, "") || 0;
const pointsPerModel = getUnitPointsPerModel(unit);
const cheapBodies = pointsPerModel > 0 && pointsPerModel <= 10;
const cheapMultiWoundBodies = pointsPerModel > 0 && pointsPerModel <= 15;
const eliteCost = pointsPerModel >= 22;
const numerousBodies = modelCount >= 15 || (modelCount >= 10 && cheapBodies);
const hordeBodies =
(wounds <= 2 && cheapBodies && numerousBodies) ||
(toughness <= 4 && wounds <= 1);
const swarmBodies = toughness <= 4 && wounds >= 3 && cheapMultiWoundBodies;
if (toughness >= 10) {
return "Heavy vehicles / monsters";
}
if (toughness >= 7) {
return "Light vehicles / monsters";
}
if (hordeBodies || swarmBodies) {
return "Horde / Swarm";
}
if (
toughness >= 6 ||
(toughness >= 5 && save <= 2) ||
(eliteCost && wounds >= 3 && save <= 3)
) {
return "Elite bodies";
}
return "Standard bodies";
};
const getSkewMeterData = (units) => {
const profileWeights = new Map();
let totalWeight = 0;
let totalModels = 0;
for (const unit of units) {
const modelStats = unit.modelStats?.length ? unit.modelStats : [{}];
totalModels += getUnitTotalModels(unit);
const weightedProfiles = modelStats.map((stat) => {
const modelCount = getModelCountForStat(unit, stat);
const woundShare = Math.max((stat.wounds || 1) * modelCount, modelCount);
console.log(
`Unit: ${unit.name}, Stat: ${stat.name || "N/A"}, Model Count: ${modelCount}, Wound Share: ${woundShare}, Profile: ${getDefensiveProfile(unit, stat, modelCount)}`,
);
return {
profile: getDefensiveProfile(unit, stat, modelCount),
woundShare,
};
});
const unitWoundShare = weightedProfiles.reduce(
(sum, profile) => sum + profile.woundShare,
0,
);
for (const { profile, woundShare } of weightedProfiles) {
const weight =
unitWoundShare > 0
? (unit.cost.points * woundShare) / unitWoundShare
: 0;
profileWeights.set(profile, (profileWeights.get(profile) || 0) + weight);
totalWeight += weight;
}
}
const profiles = Array.from(profileWeights, ([name, weight]) => ({
name,
weight,
share: totalWeight > 0 ? weight / totalWeight : 0,
})).sort((a, b) => b.weight - a.weight);
const dominantProfile = profiles[0] || { name: "No profile", share: 0 };
const score = Math.round(
Math.min(100, Math.max(0, ((dominantProfile.share - 0.35) / 0.55) * 100)),
);
let label = "Balanced";
if (score >= 80) {
label = "Oops, all stat-check";
} else if (score >= 50) {
label = "Oh Lawd He Skewin";
} else if (score >= 35) {
label = "Moderate skew";
} else if (score >= 20) {
label = "Light skew";
}
return {
score,
label,
dominantProfile,
profiles,
totalModels,
};
};
export const ShortSummaryTable = ({
force,
primaryColor,
name,
subtitle,
points,
}) => {
const [hide, setHide] = useState(false);
const [hideSkewMeter, setHideSkewMeter] = useState(false);
const { units, factionRules, rules, catalog } = force;
const sortedUnits = units.slice().sort((a, b) => {
@@ -196,10 +312,36 @@ export const ShortSummaryTable = ({ force, primaryColor, name, points }) => {
(sum, unit) => sum + getUnitTotalWounds(unit),
0,
);
const totalArmyPoints = sortedUnits.reduce(
(sum, unit) => sum + unit.cost.points,
0,
);
const canShowSkewMeter = sortedUnits.length > 1 && totalArmyPoints >= 500;
const showSkewMeter = canShowSkewMeter && !hideSkewMeter;
const skewMeterData = showSkewMeter ? getSkewMeterData(sortedUnits) : null;
return (
<>
<div className="flex justify-end gap-3">
{canShowSkewMeter && (
<label
className="print-display-none"
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 4,
userSelect: "none",
}}
>
<input
type="checkbox"
checked={hideSkewMeter}
onChange={() => setHideSkewMeter(!hideSkewMeter)}
/>
<span className="print-display-none">Hide Skew-O-Meter</span>
</label>
)}
<label
className="print-display-none"
style={{
@@ -230,7 +372,23 @@ export const ShortSummaryTable = ({ force, primaryColor, name, points }) => {
textTransform: "uppercase",
}}
>
{subtitle ? (
<span className="flex flex-col leading-none">
<span>{name}</span>
<span
style={{
fontSize: ".55em",
fontWeight: 700,
lineHeight: 1.15,
marginTop: 2,
}}
>
{subtitle}
</span>
</span>
) : (
<span>{name}</span>
)}
<span>{points} pts</span>
</div>
<div
@@ -349,7 +507,9 @@ export const ShortSummaryTable = ({ force, primaryColor, name, points }) => {
{getUnitTotalOc(unit)}
</div>
<div className="table-cell border border-dotted border-[#9e9fa1] px-4 py-1 text-right">
{totalWounds > 0 ? (cost.points / totalWounds).toFixed(1) : "—"}
{totalWounds > 0
? (cost.points / totalWounds).toFixed(1)
: "—"}
</div>
<div className="table-cell border border-dotted border-[#9e9fa1] px-4 py-1 text-right">
{cost.points} pts
@@ -370,23 +530,39 @@ export const ShortSummaryTable = ({ force, primaryColor, name, points }) => {
</div>
<div className="table-cell border border-[var(--primary-color)] px-4 py-1 text-right">
{/* Sum of all OC values */}
{sortedUnits.reduce((sum, unit) => sum + getUnitTotalOc(unit), 0)}
{sortedUnits.reduce(
(sum, unit) => sum + getUnitTotalOc(unit),
0,
)}
</div>
<div className="table-cell border border-[var(--primary-color)] px-4 py-1 text-right"> {totalArmyWounds > 0
<div className="table-cell border border-[var(--primary-color)] px-4 py-1 text-right">
{" "}
{totalArmyWounds > 0
? (
sortedUnits.reduce((sum, unit) => sum + unit.cost.points, 0) /
totalArmyWounds
sortedUnits.reduce(
(sum, unit) => sum + unit.cost.points,
0,
) / totalArmyWounds
).toFixed(1)
: "—"}
</div>
<div className="table-cell border border-[var(--primary-color)] px-4 py-1 text-right"> {sortedUnits.reduce((sum, unit) => sum + unit.cost.points, 0)}{" "}
<div className="table-cell border border-[var(--primary-color)] px-4 py-1 text-right">
{" "}
{sortedUnits.reduce(
(sum, unit) => sum + unit.cost.points,
0,
)}{" "}
pts
</div>
</div>
</div>
</div>
<div className="flex w-full flex-col gap-2.5 border-[var(--primary-color)] p-4 pb-2 pt-3.5 md:w-[50%] md:flex-1 md:border-l-2 print:w-[100%]">
<div className="flex w-full flex-col gap-2.5 self-stretch border-[var(--primary-color)] p-4 pb-2 pt-3.5 md:w-[50%] md:flex-1 md:border-l-2 print:w-[100%]">
{showSkewMeter && (
<SkewMeter data={skewMeterData} primaryColor={primaryColor} />
)}
<ChartComponent
data={{
labels: groupedChartDataMovement.map(
@@ -452,6 +628,59 @@ export const ShortSummaryTable = ({ force, primaryColor, name, points }) => {
);
};
const SkewMeter = ({ data, primaryColor }) => {
const topProfiles = data.profiles;
return (
<div className="w-full border-2 border-[var(--primary-color)] bg-white/80 p-3 text-black">
<div className="flex items-end justify-between gap-3">
<div>
<div className="text-[0.8em] font-bold uppercase leading-none text-[var(--primary-color)]">
Skew-O-Meter
</div>
<div className="text-[1.35em] font-extrabold uppercase leading-tight">
{data.label}
</div>
</div>
<div className="text-right text-[1.6em] font-extrabold leading-none">
{data.score}
<span className="text-[0.48em] font-bold">/100</span>
</div>
</div>
<div className="mt-2 h-3 overflow-hidden border border-[var(--primary-color)] bg-white">
<div
className="h-full"
style={{
width: `${data.score}%`,
backgroundColor: primaryColor,
}}
/>
</div>
<div className="mt-2 grid gap-1 text-[0.76em] font-semibold leading-tight">
{topProfiles.map((profile, index) => (
<div
key={profile.name}
className={`grid grid-cols-[1fr_3rem] items-center gap-2 ${index === 0 ? "font-extrabold uppercase" : ""}`}
>
<div className="flex items-center gap-1.5">
<span
className="inline-block h-2 w-2 shrink-0"
style={{ backgroundColor: primaryColor }}
/>
<span>{profile.name}</span>
</div>
<span className="text-right">
{Math.round(profile.share * 100)}%
</span>
</div>
))}
</div>
</div>
);
};
const ChartComponent = ({ data, title }) => {
return (
<div className="relative w-full" style={{ height: "150px" }}>
+164 -124
View File
@@ -1,7 +1,7 @@
import JSZip from "jszip";
import { Create40kRoster } from "./roster40k";
import { Create40kRoster10th } from "./roster40k-10th";
import { useEffect, useState, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import Demo0 from "./assets/Demo0.png";
import Demo1 from "./assets/Demo1.png";
@@ -9,6 +9,35 @@ import { Roster } from "./9th/Roster";
import { Roster as Roster10th } from "./10th/Roster";
import { useLocalStorage } from "./helpers/useLocalStorage";
import { parseJSON, stringifyJSON } from "./helpers/json";
import { Create40kRoster11th } from "./roster40k-11th";
import { applyTransforms, defaultToggles } from "./transforms";
// The transforms, in the order they are offered in the UI. These are what makes
// this a fork: they rewrite the roster into the generic datasheets the official
// cards print, rather than a record of one particular list.
const TRANSFORMS = [
[
"mergeDuplicateUnits",
"Merge duplicates",
"Fold copies of a datasheet - taken only because their wargear is mutually exclusive - into a single card showing every option.",
],
[
"removeLeaderAbilities",
"Drop Leader/Support",
"Remove the attachment rules. Once the army is built they say nothing you need mid-game, and they push the rules you do need off the card.",
],
[
"convertChoiceAbilities",
"Split choice abilities",
"Print each option of a 'select one each turn' ability as its own titled row, the way the official datasheets do.",
],
];
const PARSERS = {
"Warhammer 40,000 9th Edition": [Create40kRoster, 9],
"Warhammer 40,000 10th Edition": [Create40kRoster10th, 10],
"Warhammer 40,000 11th Edition": [Create40kRoster11th, 11],
};
const throttle = (func, limit) => {
let lastFunc;
@@ -33,32 +62,37 @@ const throttle = (func, limit) => {
};
function App() {
const [rosters, setRosters] = useLocalStorage("rosters", "[]");
// Saved rosters hold the *raw* roster XML rather than the parsed roster, so
// that flipping a transform toggle can rebuild them without a re-upload.
// The keys are BrevyScribe's own, so nothing upstream FancyScribe left in
// localStorage is read back in the wrong shape.
const [rosters, setRosters] = useLocalStorage("brevyscribe.rosters", "[]");
const rostersJSON = parseJSON(rosters ?? "[]");
const [savedToggles, setSavedToggles] = useLocalStorage(
"brevyscribe.transforms",
stringifyJSON(defaultToggles),
);
// Memoised so the rebuild effect can depend on the toggles themselves rather
// than their serialised form; a fresh object every render would re-run it.
const toggles = useMemo(
() => ({ ...defaultToggles, ...parseJSON(savedToggles ?? "{}") }),
[savedToggles],
);
// { xml, save } - the roster to show, and whether it belongs in the list.
const [source, setSource] = useState();
const [error, setError] = useState();
const [roster, setRoster] = useState();
const [edition, setEdition] = useState(10); // [9, 10]
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 [hideModelSelections, setHideModelSelections] = useState(false);
const uploadRef = useRef();
const throttledSetPrimaryColor = useRef(
throttle((color) => setPrimaryColor(color), 50),
).current;
const toggleHideModelSelections = (hide) => {
const checkboxes = document.querySelectorAll(
'input[type="checkbox"].hide-model-selection',
);
checkboxes.forEach((checkbox) => {
if (checkbox.checked !== hide) {
checkbox.click();
}
});
};
async function handleFileSelect(event) {
const files = event?.target?.files;
@@ -70,8 +104,7 @@ function App() {
};
reader.onloadend = async () => {
const content = reader.result;
const xmldata = await unzip(content);
parseXML(xmldata, true);
setSource({ xml: await unzip(content), save: true });
};
reader.readAsBinaryString(files[0]);
} else {
@@ -92,32 +125,18 @@ function App() {
default:
break;
}
// load example
posthog?.capture?.("user_loaded_example", {
roster_faction: event,
});
if (!example) return;
const arrayBuffer = await example.arrayBuffer();
// Create a new Blob object from the zip file contents
const zipBlob = new Blob([arrayBuffer], { type: "application/zip" });
const xmldata = await unzip(zipBlob);
parseXML(xmldata, false, true);
setSource({ xml: await unzip(zipBlob), save: false });
}
}
const loadFromLocalStorage = (roster) => {
if (roster.gameType == "Warhammer 40,000 9th Edition") {
setRoster(roster);
setEdition(9);
setError("");
} else if (roster.gameType == "Warhammer 40,000 10th Edition") {
setRoster(roster);
setEdition(10);
setError("");
}
posthog?.capture?.("user_loaded_roster_from_localstorage", {
roster_faction: roster.forces[0].catalog,
roster_type: roster.gameType,
});
};
// Already in the list, so there is nothing to save; the stored XML goes
// through the same rebuild as a fresh upload.
const loadFromLocalStorage = (saved) =>
setSource({ xml: saved.xml, save: false });
const unzip = async (file) => {
if (file?.charAt && file.charAt(0) !== "P") {
@@ -133,54 +152,52 @@ function App() {
}
};
function parseXML(xmldata, addToLocalStorage, isExample = false) {
const parser = new DOMParser();
const doc = parser.parseFromString(xmldata, "text/xml");
if (!doc) return;
// Rebuild whenever a new roster arrives or a transform is toggled. The
// transforms run here rather than over the uploaded file, so flipping a
// toggle rebuilds from the original XML and nothing has to be re-uploaded.
//
// biome-ignore lint/correctness/useExhaustiveDependencies: the roster list is
// read to append to it, so depending on it would re-run this on its own write.
useEffect(() => {
if (!source?.xml) return;
// Determine roster type (game system).
const info = doc.querySelector("roster");
const doc = new DOMParser().parseFromString(source.xml, "text/xml");
const info = doc?.querySelector("roster");
if (!info) return;
// Determine roster type (game system).
const gameType = info.getAttribute("gameSystemName");
if (!gameType) return;
const rosterName = info.getAttribute("name");
if (rosterName) {
document.title = `FancyScribe ${rosterName}`;
}
let roster;
if (gameType == "Warhammer 40,000 9th Edition") {
roster = Create40kRoster(doc, gameType);
if (roster && roster.forces.length > 0) {
setRoster(roster);
setEdition(9);
setError("");
}
} else if (gameType == "Warhammer 40,000 10th Edition") {
roster = Create40kRoster10th(doc, gameType);
if (roster && roster.forces.length > 0) {
setRoster(roster);
setEdition(10);
setError("");
}
} else {
setError("No support for game type '" + gameType + "'.");
}
if (!roster) {
const parser = PARSERS[gameType];
if (!parser) {
setError(`No support for game type '${gameType}'.`);
return;
}
if (addToLocalStorage) {
console.log(roster);
posthog?.capture?.("user_uploaded_roster", {
roster_faction: roster.forces[0].catalog,
roster_type: gameType,
name: rosterName,
});
setRosters(stringifyJSON([roster, ...rostersJSON]));
}
const report = applyTransforms(doc, toggles);
const [createRoster, rosterEdition] = parser;
const built = createRoster(doc, gameType);
if (!built || built.forces.length === 0) return;
setRoster(built);
setEdition(rosterEdition);
setError("");
const name = info.getAttribute("name");
if (name) document.title = `BrevyScribe ${name}`;
console.log(built, report);
if (source.save) {
// Keyed on the XML, so re-saving the same roster moves it to the front
// of the list rather than adding a second copy.
setRosters(
stringifyJSON([
{ name: name || "Roster", xml: source.xml },
...rostersJSON.filter((entry) => entry.xml !== source.xml),
]),
);
}
}, [source, toggles, setRosters]);
useEffect(() => {
// Check if the browser is Safari, and if so, remove the accept attribute
@@ -227,7 +244,7 @@ function App() {
>
<div className="header print-display-none">
<a
href="/fancyscribe"
href="/"
style={{
color: "#fff",
fontWeight: 800,
@@ -235,18 +252,18 @@ function App() {
flexDirection: "column",
}}
>
FancyScribe{" "}
BrevyScribe{" "}
<span
style={{
fontSize: "0.8rem",
fontWeight: 400,
}}
>
Now with 10th edition support!
Generic datasheets, not list-specific ones
</span>
</a>
<div className="subheader">
A fancy way to view your Warhammer 40k BattleScribe rosters
A fancy way to print your Warhammer 40k datasheets
</div>
</div>
@@ -342,18 +359,47 @@ function App() {
<button
style={{ display: roster ? "" : "none" }}
onClick={() => {
posthog?.capture?.("user_printed_roster", {
roster_faction: roster.forces[0].catalog,
roster_type: roster.gameType,
});
window.print();
}}
onClick={() => window.print()}
>
Print roster
</button>
</div>
<div
className="print-display-none max-w-[95vw]"
style={{
display: roster ? "flex" : "none",
width: "100%",
gap: 16,
flexWrap: "wrap",
}}
>
<span style={{ fontWeight: 600, minHeight: 26 }}>Datasheets:</span>
{TRANSFORMS.map(([key, label, description]) => (
<label
key={key}
title={description}
style={{
display: "flex",
alignItems: "center",
gap: 4,
minHeight: 26,
}}
>
<input
type="checkbox"
checked={toggles[key]}
onChange={(e) =>
setSavedToggles(
stringifyJSON({ ...toggles, [key]: e.target.checked }),
)
}
/>
<span className="select-none">{label}</span>
</label>
))}
</div>
<div
className="print-display-none max-w-[95vw]"
style={{ display: roster ? "flex" : "none", width: "100%", gap: 16 }}
@@ -374,9 +420,6 @@ function App() {
One Datacard per Page when Printing
</span>
</label>
{
// only show when 10th edition
edition === 10 && (
<label
style={{
display: "flex",
@@ -384,20 +427,15 @@ function App() {
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"
value={hideModelSelections}
onChange={(e) => {
setHideModelSelections(e.target.checked);
toggleHideModelSelections(e.target.checked);
}}
className="hide-model-selection"
checked={showLore}
onChange={(e) => setShowLore(e.target.checked)}
/>
<span className="select-none">Hide all model selections</span>
<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
@@ -437,12 +475,13 @@ function App() {
{error}
</div>
{edition === 9 && <Roster roster={roster} onePerPage={onePerPage} />}
{edition === 10 && (
{(edition === 10 || edition === 11) && (
<Roster10th
roster={roster}
onePerPage={onePerPage}
colorUserChoice={colorUserChoice}
primaryColor={primaryColor}
showLore={showLore}
/>
)}
@@ -500,7 +539,7 @@ function App() {
>
<div style={{ padding: "8px 0", fontSize: "1.7rem" }}>About</div>
<div style={{ fontSize: "1.2rem" }}>
FancyScribe is a website that renders{" "}
BrevyScribe renders{" "}
<a
href="https://www.battlescribe.net/"
target="_blank"
@@ -509,9 +548,28 @@ function App() {
BattleScribe
</a>{" "}
or <a href="https://www.newrecruit.eu/">New Recruit</a> roster files
in an opinionated format inspired by the new 10th edition datacards.
Additional inspiration and large parts of the parsing logic come
from the{" "}
in an opinionated format inspired by the 10th edition datacards.
</div>
<div style={{ fontSize: "1.2rem" }}>
It differs from its upstream in what it prints: rather than a record
of one particular list, it rewrites the roster into the generic
datasheets the official cards show. Duplicate copies of a unit are
folded into one card carrying every option, the Leader and Support
attachment rules are dropped, and a &quot;select one each
turn&quot; ability is split into one titled row per option. Use the{" "}
<b>Datasheets</b> toggles above to turn any of that off.
</div>
<div style={{ fontSize: "1.2rem" }}>
BrevyScribe is a fork of{" "}
<a
href="https://github.com/NilsUeter/fancyscribe"
target="_blank"
rel="noreferrer"
>
FancyScribe
</a>{" "}
by Nils Ueter, which does all the heavy lifting here. Additional
inspiration and large parts of the parsing logic come from the{" "}
<a
href="https://rweyrauch.github.io/PrettyScribe"
target="_blank"
@@ -521,24 +579,6 @@ function App() {
</a>{" "}
website.
</div>
<div style={{ fontSize: "1.2rem" }}>
FancyScribe is an open-source project and can be found on Github (
<a
href="https://github.com/NilsUeter/fancyscribe"
target="_blank"
rel="noreferrer"
>
FancyScribe
</a>
).
</div>
<div style={{ fontSize: "1.2rem" }}>
If you have any feedback or find any bugs, write{" "}
<a href="https://www.reddit.com/r/WarhammerCompetitive/comments/13ajo3b/fancyscribe_convert_9th_edition_battlescribe">
here
</a>{" "}
or send me a message.
</div>
<div style={{ padding: "8px 0", fontSize: "1.7rem" }}>
Output Examples
</div>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 KiB

After

Width:  |  Height:  |  Size: 928 KiB

+23
View File
@@ -0,0 +1,23 @@
// Fork-local display decisions.
//
// BrevyScribe prints the generic datasheet for a unit rather than a record of
// one particular list, so a few of the things FancyScribe shows have nothing
// left to say here. They are switched off from this file - which upstream does
// not have, and which therefore can never conflict - so that the components
// upstream owns are touched on as few lines as possible.
// The roster overview table summarises the list that was uploaded: its total
// cost, a unit-by-unit breakdown and the charts drawn from them. None of that
// survives the transforms, so the card is not rendered at all.
//
// 10th/Roster.jsx imports this in place of ./ShortSummaryTable, which leaves the
// render site there byte-identical to upstream. ShortSummaryTable.jsx itself is
// kept in the tree, unused, so upstream changes to it keep merging cleanly (Vite
// tree-shakes it, and chart.js with it, out of the build).
export const ShortSummaryTable = () => null;
// The unit composition lists the models this particular list took, which is the
// list-specific detail the fork exists to strip - and after duplicate units are
// merged into one card it is misleading as well. It is always hidden, and the
// checkboxes that used to toggle it are gone.
export const HIDE_UNIT_COMPOSITION = true;
+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;
};
+21 -1
View File
@@ -4,7 +4,7 @@
@layer base {
:root {
font-family: "Noto Sans", sans-serif, Inter, system-ui, Avenir, Helvetica,
font-family: ConduitITCStd, "Noto Sans", sans-serif, Inter, system-ui, Avenir, Helvetica,
Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
@@ -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;
@@ -200,6 +208,18 @@
}
@media print {
/* The cards get cut apart, so the white border around a cut card comes
from two different places: the page margin on the outer edges, and
half of the gap between two cards on the edges in between. The gap is
therefore twice the page margin, and every edge ends up the same. */
@page {
margin: 8mm;
}
.avoid-page-break {
margin-bottom: 16mm !important;
}
.print-display-none,
.print-display-none * {
display: none !important;
+20 -13
View File
@@ -101,6 +101,7 @@ export class Model extends BaseNotes {
attacks = "";
leadership = 7;
save = "";
invulnerableSave = "";
rangedWeapons = [];
meleeWeapons = [];
@@ -434,7 +435,7 @@ function ParseForces(doc, roster) {
// Only include the allegiance rules once.
if (!DuplicateForce(f, roster)) {
const rules = root.querySelectorAll("force>rules>rule");
const rules = root.querySelectorAll(":scope>rules>rule");
for (let rule of rules) {
ExtractRuleDescription(rule, f.rules);
}
@@ -448,7 +449,7 @@ function ParseForces(doc, roster) {
}
function ParseSelections(root, force) {
let selections = root.querySelectorAll("force>selections>selection");
let selections = root.querySelectorAll(":scope>selections>selection");
for (let selection of selections) {
// What kind of selection is this
@@ -870,37 +871,43 @@ function ParseModelStatsProfiles(profiles, unit, unitName) {
const charName = char.getAttribute("name");
if (!charName) continue;
if (char.textContent) {
const charValue = char.textContent?.trim();
if (charValue) {
switch (charName) {
case "M":
model.move = char.textContent;
model.move = charValue;
break;
case "WS":
model.ws = char.textContent;
model.ws = charValue;
break;
case "BS":
model.bs = char.textContent;
model.bs = charValue;
break;
case "S":
model.str = +char.textContent;
model.str = +charValue;
break;
case "T":
model.toughness = +char.textContent;
model.toughness = +charValue;
break;
case "W":
model.wounds = +char.textContent;
model.wounds = +charValue;
break;
case "A":
model.attacks = char.textContent;
model.attacks = charValue;
break;
case "LD":
model.leadership = char.textContent;
model.leadership = charValue;
break;
case "SV":
model.save = char.textContent;
case "Sv":
model.save = charValue;
break;
case "InSv":
case "Invulnerable Save":
if (charValue !== "-") model.invulnerableSave = charValue;
break;
case "OC":
model.oc = char.textContent;
model.oc = charValue;
break;
}
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -449,7 +449,7 @@ function ParseForces(doc, roster) {
// Only include the allegiance rules once.
if (!DuplicateForce(f, roster)) {
const rules = root.querySelectorAll("force>rules>rule");
const rules = root.querySelectorAll(":scope>rules>rule");
for (let rule of rules) {
ExtractRuleDescription(rule, f.rules);
}
@@ -463,7 +463,7 @@ function ParseForces(doc, roster) {
}
function ParseSelections(root, force) {
let selections = root.querySelectorAll("force>selections>selection");
let selections = root.querySelectorAll(":scope>selections>selection");
for (let selection of selections) {
// What kind of selection is this
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<roster id="7rmu2zl" name="TEST" battleScribeVersion="2.03" generatedBy="https://newrecruit.eu" gameSystemId="sys-352e-adc2-7639-d610" gameSystemName="Warhammer 40,000 11th Edition" gameSystemRevision="6" xmlns="http://www.battlescribe.net/schema/rosterSchema"><costs><cost name="pts" typeId="51b2-306e-1021-d207" value="220" /></costs><forces><force id="7wfvr6o" name="Army Roster" entryId="bb9d-299a-ed60-2d8a" catalogueId="77b9-2f66-3f9b-5cf3" catalogueRevision="5" catalogueName="Imperium - Adeptus Mechanicus"><selections><selection id="bky5c11" name="Battle Size" entryId="7380-3e40-6ed6-b7cc::564e-fbc6-5266-3ea4" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="bg2lcx" name="Detachment" entryId="2874-c86-3152-393::c82f-1b42-946-9c9a" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="bdcmhqi" name="Force Disposition" entryId="8bc8-6bfe-78bd-2480::2f69-9148-45b4-86a8" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="bnoi9vo" name="Show/Hide Options" entryId="3458-3cff-ef5f-a7c5::e8ef-836a-a9d1-901d" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="ladocm" name="Belisarius Cawl" entryId="cb05-4e43-6776-3c51::1f1e-2989-4762-cf88" number="1" type="model" from="entry"><rules><rule id="7a21-a958-e47d-5c0d" name="Doctrina Imperatives" hidden="false"><description>At the start of the battle round, you can select one of the Doctrina Imperatives below. Until the end of the battle round, that Doctrina Imperative is active for your army, and all units from your army that have the Doctrina Imperatives ability gain the relevant abilities shown below.
PROTECTOR IMPERATIVE
■ Ranged weapons equipped by models in this unit have the [HEAVY] ability.
■ Improve the Ballistic Skill characteristic of ranged weapons equipped by models in this unit by 1.
■ Each time a melee attack targets this unit, if this unit has the ^^**Battleline**^^ keyword and/or it is within 6&quot; of one or more friendly **^^Adeptus Mechanicus Battleline**^^ units, subtract 1 from the Hit roll.
CONQUEROR IMPERATIVE
■ Ranged weapons equipped by models in this unit have the [ASSAULT] ability.
■ Improve the Weapon Skill characteristic of melee weapons equipped by models in this unit by 1.
■ Each time a model in this unit makes an attack, if this unit has the **^^Battleline**^^ keyword and/or it is within 6&quot; of one or more friendly **^^Adeptus Mechanicus Battleline**^^ units, improve the Armour Penetration characteristic of that attack by 1.</description></rule></rules><profiles><profile id="1f5d-20a5-f37c-7d5a" name="Belisarius Cawl" hidden="false" typeId="c547-1836-d8a-ff4f" typeName="Unit" from="entry"><characteristics><characteristic name="M" typeId="e703-ecb6-5ce7-aec1">8&quot;</characteristic><characteristic name="T" typeId="d29d-cf75-fc2d-34a4">8</characteristic><characteristic name="Sv" typeId="450-a17e-9d5e-29da">2+</characteristic><characteristic name="W" typeId="750a-a2ec-90d3-21fe">10</characteristic><characteristic name="LD" typeId="58d2-b879-49c7-43bc">6+</characteristic><characteristic name="OC" typeId="bef7-942a-1a23-59f8">3</characteristic><characteristic name="InSv" typeId="55a7-5b54-c60d-11dc">4+</characteristic></characteristics></profile><profile id="9e99-0aa1-2c11-33ca" name="Canticles of the Omnissiah" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry">
<characteristics>
<characteristic name="Description" typeId="9b8f-694b-e5e-b573">At the start of your Command phase, select one of the abilities in the Canticles of the Omnissiah section. Until the start of your next Command phase, this model has that ability.</characteristic>
</characteristics>
</profile>
<profile id="481f-052d-6273-e87d" name="Invocation of Machine Vengeance (Aura)" hidden="false" typeId="206d-0762-de03-4bcd" typeName="CANTICLES OF THE OMNISSIAH" from="entry">
<characteristics>
<characteristic name="Description" typeId="f61f-4a40-c8b5-338e">At the start of your Command phase, select one unit from your opponents army. Until the start of your next Command phase, that enemy unit is your Machine Vengeance target. Each time a model in a friendly ADEPTUS MECHANICUS unit makes an attack that targets your Machine Vengeance target, you can reroll the Hit roll.</characteristic>
</characteristics>
</profile>
<profile id="29f0-41e7-f2f6-adf3" name="Mantra of Discipline" hidden="false" typeId="206d-0762-de03-4bcd" typeName="CANTICLES OF THE OMNISSIAH" from="entry">
<characteristics>
<characteristic name="Description" typeId="f61f-4a40-c8b5-338e">This model has the BATTLELINE keyword and has the following ability:
Binharic Courage (Aura): While a friendly ADEPTUS MECHANICUS unit is within 6&quot; of this model, add 1 to the Objective Control characteristic of models in that unit and each time you take a Battle-shock or Leadership test for that unit, add 1 to that test.</characteristic>
</characteristics>
</profile>
<profile id="df91-6e15-f507-1807" name="Shroudpsalm (Aura)" hidden="false" typeId="206d-0762-de03-4bcd" typeName="CANTICLES OF THE OMNISSIAH" from="entry">
<characteristics>
<characteristic name="Description" typeId="f61f-4a40-c8b5-338e">While a friendly ADEPTUS MECHANICUS unit is within 6&quot; of this model, that unit has the Stealth ability.</characteristic>
</characteristics>
</profile><profile id="f172-384f-e1fa-d656" name="Mechanicus Bodyguard" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry"><characteristics><characteristic name="Description" typeId="9b8f-694b-e5e-b573">While this model is within 3&quot; of one or more other friendly ^^**Adeptus Mechanicus**^^ units, this model has the Lone Operative ability.</characteristic></characteristics></profile><profile id="26a7-711f-28f6-7044" name="Self-repair Mechanisms" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry"><characteristics><characteristic name="Description" typeId="9b8f-694b-e5e-b573">At the start of your Command phase, this model regains up to D3 lost wounds.</characteristic></characteristics></profile><profile id="784a-55c7-c7b9-db88" name="Supreme Commander" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry"><characteristics><characteristic name="Description" typeId="9b8f-694b-e5e-b573">If this model is in your army, it must be your **^^Warlord^^**.</characteristic></characteristics></profile></profiles><selections><selection id="la280rb" name="Arc scourge" entryId="cb05-4e43-6776-3c51::2f59-b41c-83be-4efe" number="1" type="upgrade" from="entry"><rules><rule id="4111-82e3-9444-e942" name="Anti" hidden="false" page="28"><description>This ability always takes the form **[ANTI-X Y+]**. Each time an attack is made with an **[ANTI]** weapon, if the target unit has the keyword denoted by **X**, an unmodified **wound roll** of **Y+** is a **critical wound**.
***Example:** An attack made with an **[ANTI-VEHICLE 4+]** weapon against a ^^**Vehicle**^^ unit will result in a **critical wound** on an unmodified **wound roll** of 4+, while an attack made with an **[ANTI-PSYKER 2+]** weapon against a ^^**Psyker**^^ unit will result in a **critical wound** on an unmodified **wound roll** of 2+.*</description></rule><rule id="be1e-ac8e-1e2c-3528" name="Devastating Wounds" hidden="false" page="28"><description>Each time an attack made with a **[DEVASTATING WOUNDS]** weapon results in a **critical wound**, the attack sequence for that attack ends and the target unit suffers a number of **mortal wounds** equal to the **D** characteristic of that weapon. These are inflicted after resolving any normal damage inflicted by those attacks. 
**Mortal wounds** inflicted by **[DEVASTATING WOUNDS]** weapons can damage a maximum of one model for each **critical wound**; any remaining **mortal wounds** inflicted by that attack are lost. 
**Example: An attack made with a **[DEVASTATING WOUNDS]** weapon with a **D** characteristic of 3 results in a **critical wound** against an Intercessor Squad, so inflicts 3 **mortal wounds**. The first 2 **mortal wounds** are sufficient to **destroy** 1 Intercessor model, so the remaining **mortal wound** is lost.*</description></rule><rule id="115b-79dc-f723-d761" name="Extra Attacks" hidden="false" page="28"><description>Each time a unit containing one or more models with an **[EXTRA ATTACKS]** weapon fights, those models will make attacks with those weapons in addition to any others. In the Select Weapons step (04.01), for each of those models, you must select: 
- All of that models **[EXTRA ATTACKS]** weapons. 
- One of that models other melee weapons, if possible.</description></rule></rules><profiles><profile id="e4be-19e1-54b-d219" name="Arc Scourge" hidden="false" typeId="8a40-4aaa-c780-9046" typeName="Melee Weapons" from="entry"><characteristics><characteristic name="Range" typeId="914c-b413-91e3-a132">Melee</characteristic><characteristic name="A" typeId="2337-daa1-6682-b110">4</characteristic><characteristic name="WS" typeId="95d1-95f-45b4-11d6">2+</characteristic><characteristic name="S" typeId="ab33-d393-96ce-ccba">5</characteristic><characteristic name="AP" typeId="41a0-1301-112a-e2f2">-1</characteristic><characteristic name="D" typeId="3254-9fe6-d824-513e">1</characteristic><characteristic name="Keywords" typeId="893f-9000-ccf7-648e">Anti-Vehicle 4+, Devastating Wounds, Extra Attacks</characteristic></characteristics></profile></profiles><categories><category id="84c4-6d1e-e724-bd6e" name="Extra Attacks Weapon" entryId="84c4-6d1e-e724-bd6e" primary="false" /></categories></selection><selection id="ladirbc" name="Cawl's Omnissian axe" entryId="cb05-4e43-6776-3c51::4858-7bde-a8d4-e7da" number="1" type="upgrade" from="entry"><profiles><profile id="f04a-c6ee-6971-f7da" name="Cawl's Omnissian axe" hidden="false" typeId="8a40-4aaa-c780-9046" typeName="Melee Weapons" from="entry"><characteristics><characteristic name="Range" typeId="914c-b413-91e3-a132">Melee</characteristic><characteristic name="A" typeId="2337-daa1-6682-b110">4</characteristic><characteristic name="WS" typeId="95d1-95f-45b4-11d6">2+</characteristic><characteristic name="S" typeId="ab33-d393-96ce-ccba">8</characteristic><characteristic name="AP" typeId="41a0-1301-112a-e2f2">-2</characteristic><characteristic name="D" typeId="3254-9fe6-d824-513e">2</characteristic><characteristic name="Keywords" typeId="893f-9000-ccf7-648e">-</characteristic></characteristics></profile></profiles></selection><selection id="lafgy0s" name="Mechadendrite hive" entryId="cb05-4e43-6776-3c51::665-8af3-4b52-8ac6" number="1" type="upgrade" from="entry"><rules><rule id="115b-79dc-f723-d761" name="Extra Attacks" hidden="false" page="28"><description>Each time a unit containing one or more models with an **[EXTRA ATTACKS]** weapon fights, those models will make attacks with those weapons in addition to any others. In the Select Weapons step (04.01), for each of those models, you must select: 
- All of that models **[EXTRA ATTACKS]** weapons. 
- One of that models other melee weapons, if possible.</description></rule></rules><profiles><profile id="7a81-a490-ee0-ffdd" name="Mechadendrite hive" hidden="false" typeId="8a40-4aaa-c780-9046" typeName="Melee Weapons" from="entry"><characteristics><characteristic name="Range" typeId="914c-b413-91e3-a132">Melee</characteristic><characteristic name="A" typeId="2337-daa1-6682-b110">2D6</characteristic><characteristic name="WS" typeId="95d1-95f-45b4-11d6">3+</characteristic><characteristic name="S" typeId="ab33-d393-96ce-ccba">4</characteristic><characteristic name="AP" typeId="41a0-1301-112a-e2f2">0</characteristic><characteristic name="D" typeId="3254-9fe6-d824-513e">1</characteristic><characteristic name="Keywords" typeId="893f-9000-ccf7-648e">Extra Attacks</characteristic></characteristics></profile></profiles><categories><category id="84c4-6d1e-e724-bd6e" name="Extra Attacks Weapon" entryId="84c4-6d1e-e724-bd6e" primary="false" /></categories></selection><selection id="lac4b2c" name="Solar atomiser" entryId="cb05-4e43-6776-3c51::238d-d584-47ee-856d" number="1" type="upgrade" from="entry"><rules><rule id="6c1f-1cf7-ff25-c99e" name="Blast" hidden="false" page="26"><description>Each time you gather attack dice for a **[BLAST]** weapon, add one additional **attack dice** for every five models that were in the target unit in the Select Targets step (rounding down). If this ability takes the form **[BLAST X]**, each time you gather **attack dice** for such a weapon, add **X** additional **attack dice** for every five models that were in the target unit in the Select Targets step (rounding down) instead. 
***Example:** If a **[BLAST 2]** weapon with an **A** characteristic of 3 targets a unit containing 12 models, you would gather four additional **attack dice** for that weapon (for a total of seven for that weapon).*</description></rule><rule id="7cdb-fb99-44a9-8849" name="Melta" hidden="false" page="26"><description>This ability always takes the form **[MELTA X]**. Each time a model makes an attack with a **[MELTA]** weapon, if the target unit was within half range of that weapon in the Select Targets step, until the attacking units attacks have been resolved, add **X** to that weapons **D** characteristic. 
***Example:** A model targets a unit that is within half range of a **[MELTA 2]** weapon with a **D** characteristic of D6. While resolving those attacks, that weapon has a **D** characteristic of **D6+2**.</description></rule></rules><profiles><profile id="2fd0-1925-e091-75ef" name="Solar atomiser" hidden="false" typeId="f77d-b953-8fa4-b762" typeName="Ranged Weapons" from="entry"><characteristics><characteristic name="Range" typeId="9896-9419-16a1-92fc">18&quot;</characteristic><characteristic name="A" typeId="3bb-c35f-f54-fb08">3</characteristic><characteristic name="BS" typeId="94d-8a98-cf90-183e">2+</characteristic><characteristic name="S" typeId="2229-f494-25db-c5d3">14</characteristic><characteristic name="AP" typeId="9ead-8a10-520-de15">-4</characteristic><characteristic name="D" typeId="a354-c1c8-a745-f9e3">D6</characteristic><characteristic name="Keywords" typeId="7f1b-8591-2fcf-d01c">Melta 3</characteristic></characteristics></profile></profiles></selection><selection id="lanv51g" name="Warlord" entryId="cb05-4e43-6776-3c51::c097-c2fe-be86-de31::0580-79ca-da98-77c3" number="1" type="upgrade" from="entry"><categories><category id="5c0e-4c31-d51b-e470" name="Warlord" entryId="5c0e-4c31-d51b-e470" primary="false" /><category id="75fe-c657-43c6-7c09" name="Supreme Commander" entryId="75fe-c657-43c6-7c09" primary="false" /></categories></selection></selections><costs><cost name="pts" typeId="51b2-306e-1021-d207" value="220" /></costs><categories><category id="9106-4e44-7994-8859" name="Belisarius Cawl" entryId="9106-4e44-7994-8859" primary="false" /><category id="4f3a-f0f7-6647-348d" entryId="4f3a-f0f7-6647-348d" name="Epic Hero" primary="true" /><category id="9693-cf84-fe69-37a9" name="Monster" entryId="9693-cf84-fe69-37a9" primary="false" /><category id="9cfd-1c32-585f-7d5c" name="Character" entryId="9cfd-1c32-585f-7d5c" primary="false" /><category id="59a9-b5cc-7c11-aaad" name="Tech-Priest" entryId="59a9-b5cc-7c11-aaad" primary="false" /><category id="5418-f86b-6e76-c5a" name="Faction: Adeptus Mechanicus" entryId="5418-f86b-6e76-c5a" primary="false" /><category id="aff3-d6a3-2a95-9dc" name="Imperium" entryId="aff3-d6a3-2a95-9dc" primary="false" /><category id="6d46-7883-e5d1-45ee" name="Cult Mechanicus" entryId="6d46-7883-e5d1-45ee" primary="false" /></categories></selection></selections><categories><category name="Uncategorized" id="o5b8aw" primary="false" entryId="(No Category)" /><category name="Configuration" id="7z2e0hj" primary="false" entryId="4ac9-fd30-1e3d-b249" /><category name="Epic Hero" id="o5e736q" primary="false" entryId="4f3a-f0f7-6647-348d" /><category name="Character" id="o6rksa8" primary="false" entryId="9cfd-1c32-585f-7d5c" /><category name="Battleline" id="o6ay1he" primary="false" entryId="e338-111e-d0c6-b687" /><category name="Infantry" id="o6kppe6" primary="false" entryId="cf47-a0d7-7207-29dc" /><category name="Swarm" id="o6tx7a" primary="false" entryId="b00b-5bae-444f-964e" /><category name="Mounted" id="o6cf37m" primary="false" entryId="14a0-40c9-2748-ae6e" /><category name="Beast" id="o6cnr9" primary="false" entryId="4c3e-9310-a516-3590" /><category name="Monster" id="o60v22i" primary="false" entryId="9693-cf84-fe69-37a9" /><category name="Vehicle" id="o6d7b7r" primary="false" entryId="dbd4-63-af05-998" /><category name="Drone" id="o6reyz" primary="false" entryId="2471-e2e0-3f55-d6cb" /><category name="Dedicated Transport" id="o6t0vy" primary="false" entryId="ba07-411c-2832-1f79" /><category name="Fortification" id="o63ey3d" primary="false" entryId="19d7-9c74-2140-5851" /><category name="Unit" id="o6f99k" primary="false" entryId="1160-70ae-a862-b1a8" /><category name="Allies: Astra Militarum" id="o73fsgv" primary="false" entryId="8247-35f6-ec2a-7caa" /><category name="Allies: Chaos Knights" id="o7wqmgi" primary="false" entryId="8dd6-af11-abd3-55f1" /><category name="Allies: Heretic Astartes" id="o7fadd" primary="false" entryId="92e6-f82e-0a48-2276" /><category name="Allies: Imperial Agents" id="o7a694k" primary="false" entryId="742f-5727-1bc9-6da5" /><category name="Allies: Imperial Knights" id="o7xkohf" primary="false" entryId="5e03-9044-273d-e818" /><category name="Allies: Legiones Daemonica" id="o7h1wqa" primary="false" entryId="e1ea-71b9-f0ef-d788" /><category name="Allies: Titanicus Traitoris" id="o7131zt" primary="false" entryId="24fa-8caf-cd68-31fb" /><category name="Allies: Unaligned Forces" id="o7u4bvf" primary="false" entryId="07d9-ab1c-cf28-0939" /><category name="Reference" id="o7g3qsb" primary="false" entryId="eef1-be80-500a-edfc" /><category name="Allies: Adeptus Titanicus" id="o8ica9m" primary="false" entryId="9988-6b5e-660e-d973" /><category name="Illegal Units" id="o881cch" primary="false" entryId="(Illegal Units)" /></categories></force></forces></roster>
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<roster id="7rmu2zl" name="TEST" battleScribeVersion="2.03" generatedBy="https://newrecruit.eu" gameSystemId="sys-352e-adc2-7639-d610" gameSystemName="Warhammer 40,000 11th Edition" gameSystemRevision="6" xmlns="http://www.battlescribe.net/schema/rosterSchema"><costs><cost name="pts" typeId="51b2-306e-1021-d207" value="220" /></costs><forces><force id="7wfvr6o" name="Army Roster" entryId="bb9d-299a-ed60-2d8a" catalogueId="77b9-2f66-3f9b-5cf3" catalogueRevision="5" catalogueName="Imperium - Adeptus Mechanicus"><selections><selection id="bky5c11" name="Battle Size" entryId="7380-3e40-6ed6-b7cc::564e-fbc6-5266-3ea4" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="bg2lcx" name="Detachment" entryId="2874-c86-3152-393::c82f-1b42-946-9c9a" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="bdcmhqi" name="Force Disposition" entryId="8bc8-6bfe-78bd-2480::2f69-9148-45b4-86a8" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="bnoi9vo" name="Show/Hide Options" entryId="3458-3cff-ef5f-a7c5::e8ef-836a-a9d1-901d" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="ladocm" name="Belisarius Cawl" entryId="cb05-4e43-6776-3c51::1f1e-2989-4762-cf88" number="1" type="model" from="entry"><rules><rule id="7a21-a958-e47d-5c0d" name="Doctrina Imperatives" hidden="false"><description>At the start of the battle round, you can select one of the Doctrina Imperatives below. Until the end of the battle round, that Doctrina Imperative is active for your army, and all units from your army that have the Doctrina Imperatives ability gain the relevant abilities shown below.
PROTECTOR IMPERATIVE
■ Ranged weapons equipped by models in this unit have the [HEAVY] ability.
■ Improve the Ballistic Skill characteristic of ranged weapons equipped by models in this unit by 1.
■ Each time a melee attack targets this unit, if this unit has the ^^**Battleline**^^ keyword and/or it is within 6&quot; of one or more friendly **^^Adeptus Mechanicus Battleline**^^ units, subtract 1 from the Hit roll.
CONQUEROR IMPERATIVE
■ Ranged weapons equipped by models in this unit have the [ASSAULT] ability.
■ Improve the Weapon Skill characteristic of melee weapons equipped by models in this unit by 1.
■ Each time a model in this unit makes an attack, if this unit has the **^^Battleline**^^ keyword and/or it is within 6&quot; of one or more friendly **^^Adeptus Mechanicus Battleline**^^ units, improve the Armour Penetration characteristic of that attack by 1.</description></rule></rules><profiles><profile id="1f5d-20a5-f37c-7d5a" name="Belisarius Cawl" hidden="false" typeId="c547-1836-d8a-ff4f" typeName="Unit" from="entry"><characteristics><characteristic name="M" typeId="e703-ecb6-5ce7-aec1">8&quot;</characteristic><characteristic name="T" typeId="d29d-cf75-fc2d-34a4">8</characteristic><characteristic name="Sv" typeId="450-a17e-9d5e-29da">2+</characteristic><characteristic name="W" typeId="750a-a2ec-90d3-21fe">10</characteristic><characteristic name="LD" typeId="58d2-b879-49c7-43bc">6+</characteristic><characteristic name="OC" typeId="bef7-942a-1a23-59f8">3</characteristic><characteristic name="InSv" typeId="55a7-5b54-c60d-11dc">4+</characteristic></characteristics></profile><profile id="9e99-0aa1-2c11-33ca" name="Canticles of the Omnissiah" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry">
<characteristics>
<characteristic name="Description" typeId="9b8f-694b-e5e-b573">At the start of your Command phase, select one of the abilities in the Canticles of the Omnissiah section. Until the start of your next Command phase, this model has that ability.</characteristic>
</characteristics>
</profile>
<profile id="481f-052d-6273-e87d" name="Invocation of Machine Vengeance (Aura)" hidden="false" typeId="206d-0762-de03-4bcd" typeName="CANTICLES OF THE OMNISSIAH" from="entry">
<characteristics>
<characteristic name="Description" typeId="f61f-4a40-c8b5-338e">At the start of your Command phase, select one unit from your opponents army. Until the start of your next Command phase, that enemy unit is your Machine Vengeance target. Each time a model in a friendly ADEPTUS MECHANICUS unit makes an attack that targets your Machine Vengeance target, you can reroll the Hit roll.</characteristic>
</characteristics>
</profile>
<profile id="29f0-41e7-f2f6-adf3" name="Mantra of Discipline" hidden="false" typeId="206d-0762-de03-4bcd" typeName="CANTICLES OF THE OMNISSIAH" from="entry">
<characteristics>
<characteristic name="Description" typeId="f61f-4a40-c8b5-338e">This model has the BATTLELINE keyword and has the following ability:
Binharic Courage (Aura): While a friendly ADEPTUS MECHANICUS unit is within 6&quot; of this model, add 1 to the Objective Control characteristic of models in that unit and each time you take a Battle-shock or Leadership test for that unit, add 1 to that test.</characteristic>
</characteristics>
</profile>
<profile id="df91-6e15-f507-1807" name="Shroudpsalm (Aura)" hidden="false" typeId="206d-0762-de03-4bcd" typeName="CANTICLES OF THE OMNISSIAH" from="entry">
<characteristics>
<characteristic name="Description" typeId="f61f-4a40-c8b5-338e">While a friendly ADEPTUS MECHANICUS unit is within 6&quot; of this model, that unit has the Stealth ability.</characteristic>
</characteristics>
</profile><profile id="f172-384f-e1fa-d656" name="Mechanicus Bodyguard" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry"><characteristics><characteristic name="Description" typeId="9b8f-694b-e5e-b573">While this model is within 3&quot; of one or more other friendly ^^**Adeptus Mechanicus**^^ units, this model has the Lone Operative ability.</characteristic></characteristics></profile><profile id="26a7-711f-28f6-7044" name="Self-repair Mechanisms" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry"><characteristics><characteristic name="Description" typeId="9b8f-694b-e5e-b573">At the start of your Command phase, this model regains up to D3 lost wounds.</characteristic></characteristics></profile><profile id="784a-55c7-c7b9-db88" name="Supreme Commander" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry"><characteristics><characteristic name="Description" typeId="9b8f-694b-e5e-b573">If this model is in your army, it must be your **^^Warlord^^**.</characteristic></characteristics></profile></profiles><selections><selection id="la280rb" name="Arc scourge" entryId="cb05-4e43-6776-3c51::2f59-b41c-83be-4efe" number="1" type="upgrade" from="entry"><rules><rule id="4111-82e3-9444-e942" name="Anti" hidden="false" page="28"><description>This ability always takes the form **[ANTI-X Y+]**. Each time an attack is made with an **[ANTI]** weapon, if the target unit has the keyword denoted by **X**, an unmodified **wound roll** of **Y+** is a **critical wound**.
***Example:** An attack made with an **[ANTI-VEHICLE 4+]** weapon against a ^^**Vehicle**^^ unit will result in a **critical wound** on an unmodified **wound roll** of 4+, while an attack made with an **[ANTI-PSYKER 2+]** weapon against a ^^**Psyker**^^ unit will result in a **critical wound** on an unmodified **wound roll** of 2+.*</description></rule><rule id="be1e-ac8e-1e2c-3528" name="Devastating Wounds" hidden="false" page="28"><description>Each time an attack made with a **[DEVASTATING WOUNDS]** weapon results in a **critical wound**, the attack sequence for that attack ends and the target unit suffers a number of **mortal wounds** equal to the **D** characteristic of that weapon. These are inflicted after resolving any normal damage inflicted by those attacks. 
**Mortal wounds** inflicted by **[DEVASTATING WOUNDS]** weapons can damage a maximum of one model for each **critical wound**; any remaining **mortal wounds** inflicted by that attack are lost. 
**Example: An attack made with a **[DEVASTATING WOUNDS]** weapon with a **D** characteristic of 3 results in a **critical wound** against an Intercessor Squad, so inflicts 3 **mortal wounds**. The first 2 **mortal wounds** are sufficient to **destroy** 1 Intercessor model, so the remaining **mortal wound** is lost.*</description></rule><rule id="115b-79dc-f723-d761" name="Extra Attacks" hidden="false" page="28"><description>Each time a unit containing one or more models with an **[EXTRA ATTACKS]** weapon fights, those models will make attacks with those weapons in addition to any others. In the Select Weapons step (04.01), for each of those models, you must select: 
- All of that models **[EXTRA ATTACKS]** weapons. 
- One of that models other melee weapons, if possible.</description></rule></rules><profiles><profile id="e4be-19e1-54b-d219" name="Arc Scourge" hidden="false" typeId="8a40-4aaa-c780-9046" typeName="Melee Weapons" from="entry"><characteristics><characteristic name="Range" typeId="914c-b413-91e3-a132">Melee</characteristic><characteristic name="A" typeId="2337-daa1-6682-b110">4</characteristic><characteristic name="WS" typeId="95d1-95f-45b4-11d6">2+</characteristic><characteristic name="S" typeId="ab33-d393-96ce-ccba">5</characteristic><characteristic name="AP" typeId="41a0-1301-112a-e2f2">-1</characteristic><characteristic name="D" typeId="3254-9fe6-d824-513e">1</characteristic><characteristic name="Keywords" typeId="893f-9000-ccf7-648e">Anti-Vehicle 4+, Devastating Wounds, Extra Attacks</characteristic></characteristics></profile></profiles><categories><category id="84c4-6d1e-e724-bd6e" name="Extra Attacks Weapon" entryId="84c4-6d1e-e724-bd6e" primary="false" /></categories></selection><selection id="ladirbc" name="Cawl's Omnissian axe" entryId="cb05-4e43-6776-3c51::4858-7bde-a8d4-e7da" number="1" type="upgrade" from="entry"><profiles><profile id="f04a-c6ee-6971-f7da" name="Cawl's Omnissian axe" hidden="false" typeId="8a40-4aaa-c780-9046" typeName="Melee Weapons" from="entry"><characteristics><characteristic name="Range" typeId="914c-b413-91e3-a132">Melee</characteristic><characteristic name="A" typeId="2337-daa1-6682-b110">4</characteristic><characteristic name="WS" typeId="95d1-95f-45b4-11d6">2+</characteristic><characteristic name="S" typeId="ab33-d393-96ce-ccba">8</characteristic><characteristic name="AP" typeId="41a0-1301-112a-e2f2">-2</characteristic><characteristic name="D" typeId="3254-9fe6-d824-513e">2</characteristic><characteristic name="Keywords" typeId="893f-9000-ccf7-648e">-</characteristic></characteristics></profile></profiles></selection><selection id="lafgy0s" name="Mechadendrite hive" entryId="cb05-4e43-6776-3c51::665-8af3-4b52-8ac6" number="1" type="upgrade" from="entry"><rules><rule id="115b-79dc-f723-d761" name="Extra Attacks" hidden="false" page="28"><description>Each time a unit containing one or more models with an **[EXTRA ATTACKS]** weapon fights, those models will make attacks with those weapons in addition to any others. In the Select Weapons step (04.01), for each of those models, you must select: 
- All of that models **[EXTRA ATTACKS]** weapons. 
- One of that models other melee weapons, if possible.</description></rule></rules><profiles><profile id="7a81-a490-ee0-ffdd" name="Mechadendrite hive" hidden="false" typeId="8a40-4aaa-c780-9046" typeName="Melee Weapons" from="entry"><characteristics><characteristic name="Range" typeId="914c-b413-91e3-a132">Melee</characteristic><characteristic name="A" typeId="2337-daa1-6682-b110">2D6</characteristic><characteristic name="WS" typeId="95d1-95f-45b4-11d6">3+</characteristic><characteristic name="S" typeId="ab33-d393-96ce-ccba">4</characteristic><characteristic name="AP" typeId="41a0-1301-112a-e2f2">0</characteristic><characteristic name="D" typeId="3254-9fe6-d824-513e">1</characteristic><characteristic name="Keywords" typeId="893f-9000-ccf7-648e">Extra Attacks</characteristic></characteristics></profile></profiles><categories><category id="84c4-6d1e-e724-bd6e" name="Extra Attacks Weapon" entryId="84c4-6d1e-e724-bd6e" primary="false" /></categories></selection><selection id="lac4b2c" name="Solar atomiser" entryId="cb05-4e43-6776-3c51::238d-d584-47ee-856d" number="1" type="upgrade" from="entry"><rules><rule id="6c1f-1cf7-ff25-c99e" name="Blast" hidden="false" page="26"><description>Each time you gather attack dice for a **[BLAST]** weapon, add one additional **attack dice** for every five models that were in the target unit in the Select Targets step (rounding down). If this ability takes the form **[BLAST X]**, each time you gather **attack dice** for such a weapon, add **X** additional **attack dice** for every five models that were in the target unit in the Select Targets step (rounding down) instead. 
***Example:** If a **[BLAST 2]** weapon with an **A** characteristic of 3 targets a unit containing 12 models, you would gather four additional **attack dice** for that weapon (for a total of seven for that weapon).*</description></rule><rule id="7cdb-fb99-44a9-8849" name="Melta" hidden="false" page="26"><description>This ability always takes the form **[MELTA X]**. Each time a model makes an attack with a **[MELTA]** weapon, if the target unit was within half range of that weapon in the Select Targets step, until the attacking units attacks have been resolved, add **X** to that weapons **D** characteristic. 
***Example:** A model targets a unit that is within half range of a **[MELTA 2]** weapon with a **D** characteristic of D6. While resolving those attacks, that weapon has a **D** characteristic of **D6+2**.</description></rule></rules><profiles><profile id="2fd0-1925-e091-75ef" name="Solar atomiser" hidden="false" typeId="f77d-b953-8fa4-b762" typeName="Ranged Weapons" from="entry"><characteristics><characteristic name="Range" typeId="9896-9419-16a1-92fc">18&quot;</characteristic><characteristic name="A" typeId="3bb-c35f-f54-fb08">3</characteristic><characteristic name="BS" typeId="94d-8a98-cf90-183e">2+</characteristic><characteristic name="S" typeId="2229-f494-25db-c5d3">14</characteristic><characteristic name="AP" typeId="9ead-8a10-520-de15">-4</characteristic><characteristic name="D" typeId="a354-c1c8-a745-f9e3">D6</characteristic><characteristic name="Keywords" typeId="7f1b-8591-2fcf-d01c">Melta 3</characteristic></characteristics></profile></profiles></selection><selection id="lanv51g" name="Warlord" entryId="cb05-4e43-6776-3c51::c097-c2fe-be86-de31::0580-79ca-da98-77c3" number="1" type="upgrade" from="entry"><categories><category id="5c0e-4c31-d51b-e470" name="Warlord" entryId="5c0e-4c31-d51b-e470" primary="false" /><category id="75fe-c657-43c6-7c09" name="Supreme Commander" entryId="75fe-c657-43c6-7c09" primary="false" /></categories></selection></selections><costs><cost name="pts" typeId="51b2-306e-1021-d207" value="220" /></costs><categories><category id="9106-4e44-7994-8859" name="Belisarius Cawl" entryId="9106-4e44-7994-8859" primary="false" /><category id="4f3a-f0f7-6647-348d" entryId="4f3a-f0f7-6647-348d" name="Epic Hero" primary="true" /><category id="9693-cf84-fe69-37a9" name="Monster" entryId="9693-cf84-fe69-37a9" primary="false" /><category id="9cfd-1c32-585f-7d5c" name="Character" entryId="9cfd-1c32-585f-7d5c" primary="false" /><category id="59a9-b5cc-7c11-aaad" name="Tech-Priest" entryId="59a9-b5cc-7c11-aaad" primary="false" /><category id="5418-f86b-6e76-c5a" name="Faction: Adeptus Mechanicus" entryId="5418-f86b-6e76-c5a" primary="false" /><category id="aff3-d6a3-2a95-9dc" name="Imperium" entryId="aff3-d6a3-2a95-9dc" primary="false" /><category id="6d46-7883-e5d1-45ee" name="Cult Mechanicus" entryId="6d46-7883-e5d1-45ee" primary="false" /></categories></selection></selections><categories><category name="Uncategorized" id="o5b8aw" primary="false" entryId="(No Category)" /><category name="Configuration" id="7z2e0hj" primary="false" entryId="4ac9-fd30-1e3d-b249" /><category name="Epic Hero" id="o5e736q" primary="false" entryId="4f3a-f0f7-6647-348d" /><category name="Character" id="o6rksa8" primary="false" entryId="9cfd-1c32-585f-7d5c" /><category name="Battleline" id="o6ay1he" primary="false" entryId="e338-111e-d0c6-b687" /><category name="Infantry" id="o6kppe6" primary="false" entryId="cf47-a0d7-7207-29dc" /><category name="Swarm" id="o6tx7a" primary="false" entryId="b00b-5bae-444f-964e" /><category name="Mounted" id="o6cf37m" primary="false" entryId="14a0-40c9-2748-ae6e" /><category name="Beast" id="o6cnr9" primary="false" entryId="4c3e-9310-a516-3590" /><category name="Monster" id="o60v22i" primary="false" entryId="9693-cf84-fe69-37a9" /><category name="Vehicle" id="o6d7b7r" primary="false" entryId="dbd4-63-af05-998" /><category name="Drone" id="o6reyz" primary="false" entryId="2471-e2e0-3f55-d6cb" /><category name="Dedicated Transport" id="o6t0vy" primary="false" entryId="ba07-411c-2832-1f79" /><category name="Fortification" id="o63ey3d" primary="false" entryId="19d7-9c74-2140-5851" /><category name="Unit" id="o6f99k" primary="false" entryId="1160-70ae-a862-b1a8" /><category name="Allies: Astra Militarum" id="o73fsgv" primary="false" entryId="8247-35f6-ec2a-7caa" /><category name="Allies: Chaos Knights" id="o7wqmgi" primary="false" entryId="8dd6-af11-abd3-55f1" /><category name="Allies: Heretic Astartes" id="o7fadd" primary="false" entryId="92e6-f82e-0a48-2276" /><category name="Allies: Imperial Agents" id="o7a694k" primary="false" entryId="742f-5727-1bc9-6da5" /><category name="Allies: Imperial Knights" id="o7xkohf" primary="false" entryId="5e03-9044-273d-e818" /><category name="Allies: Legiones Daemonica" id="o7h1wqa" primary="false" entryId="e1ea-71b9-f0ef-d788" /><category name="Allies: Titanicus Traitoris" id="o7131zt" primary="false" entryId="24fa-8caf-cd68-31fb" /><category name="Allies: Unaligned Forces" id="o7u4bvf" primary="false" entryId="07d9-ab1c-cf28-0939" /><category name="Reference" id="o7g3qsb" primary="false" entryId="eef1-be80-500a-edfc" /><category name="Allies: Adeptus Titanicus" id="o8ica9m" primary="false" entryId="9988-6b5e-660e-d973" /><category name="Illegal Units" id="o881cch" primary="false" entryId="(Illegal Units)" /></categories></force></forces></roster>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<roster id="7rmu2zl" name="TEST" battleScribeVersion="2.03" generatedBy="https://newrecruit.eu" gameSystemId="sys-352e-adc2-7639-d610" gameSystemName="Warhammer 40,000 11th Edition" gameSystemRevision="6" xmlns="http://www.battlescribe.net/schema/rosterSchema"><costs><cost name="pts" typeId="51b2-306e-1021-d207" value="220" /></costs><forces><force id="7wfvr6o" name="Army Roster" entryId="bb9d-299a-ed60-2d8a" catalogueId="77b9-2f66-3f9b-5cf3" catalogueRevision="5" catalogueName="Imperium - Adeptus Mechanicus"><selections><selection id="bky5c11" name="Battle Size" entryId="7380-3e40-6ed6-b7cc::564e-fbc6-5266-3ea4" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="bg2lcx" name="Detachment" entryId="2874-c86-3152-393::c82f-1b42-946-9c9a" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="bdcmhqi" name="Force Disposition" entryId="8bc8-6bfe-78bd-2480::2f69-9148-45b4-86a8" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="bnoi9vo" name="Show/Hide Options" entryId="3458-3cff-ef5f-a7c5::e8ef-836a-a9d1-901d" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="ladocm" name="Belisarius Cawl" entryId="cb05-4e43-6776-3c51::1f1e-2989-4762-cf88" number="1" type="model" from="entry"><rules><rule id="7a21-a958-e47d-5c0d" name="Doctrina Imperatives" hidden="false"><description>At the start of the battle round, you can select one of the Doctrina Imperatives below. Until the end of the battle round, that Doctrina Imperative is active for your army, and all units from your army that have the Doctrina Imperatives ability gain the relevant abilities shown below.
PROTECTOR IMPERATIVE
■ Ranged weapons equipped by models in this unit have the [HEAVY] ability.
■ Improve the Ballistic Skill characteristic of ranged weapons equipped by models in this unit by 1.
■ Each time a melee attack targets this unit, if this unit has the ^^**Battleline**^^ keyword and/or it is within 6&quot; of one or more friendly **^^Adeptus Mechanicus Battleline**^^ units, subtract 1 from the Hit roll.
CONQUEROR IMPERATIVE
■ Ranged weapons equipped by models in this unit have the [ASSAULT] ability.
■ Improve the Weapon Skill characteristic of melee weapons equipped by models in this unit by 1.
■ Each time a model in this unit makes an attack, if this unit has the **^^Battleline**^^ keyword and/or it is within 6&quot; of one or more friendly **^^Adeptus Mechanicus Battleline**^^ units, improve the Armour Penetration characteristic of that attack by 1.</description></rule></rules><profiles><profile id="1f5d-20a5-f37c-7d5a" name="Belisarius Cawl" hidden="false" typeId="c547-1836-d8a-ff4f" typeName="Unit" from="entry"><characteristics><characteristic name="M" typeId="e703-ecb6-5ce7-aec1">8&quot;</characteristic><characteristic name="T" typeId="d29d-cf75-fc2d-34a4">8</characteristic><characteristic name="Sv" typeId="450-a17e-9d5e-29da">2+</characteristic><characteristic name="W" typeId="750a-a2ec-90d3-21fe">10</characteristic><characteristic name="LD" typeId="58d2-b879-49c7-43bc">6+</characteristic><characteristic name="OC" typeId="bef7-942a-1a23-59f8">3</characteristic><characteristic name="InSv" typeId="55a7-5b54-c60d-11dc">4+</characteristic></characteristics></profile><profile id="9e99-0aa1-2c11-33ca" name="Canticles of the Omnissiah" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry"><characteristics><characteristic name="Description" typeId="9b8f-694b-e5e-b573">At the start of your Command phase, select one of the abilities in the Canticles of the Omnissiah section. Until the start of your next Command phase, this model has that ability.
Invocation of Machine Vengeance (Aura): At the start of your Command phase, select one unit from your opponents army. Until the start of your next Command phase, that enemy unit is your Machine Vengeance target. Each time a model in a friendly ^^**Adeptus Mechanicus^^** unit makes an attack that targets your Machine Vengeance target, you can reroll the Hit roll.
Mantra of Discipline:  This model has the ^^**Battleline**^^ keyword and has the following ability:
**Binharic Courage (Aura)**: While a friendly ^^**Adeptus Mechanicus**^^ unit is within 6&quot; of this model, add 1 to the Objective Control characteristic of models in that unit and each time you take a Battle-shock or Leadership test for that unit, add 1 to that test.
Shroudpsalm (Aura): While a friendly **^^Adeptus Mechanicus**^^ unit is within 6&quot; of this model, that unit has the Stealth ability.</characteristic></characteristics></profile><profile id="f172-384f-e1fa-d656" name="Mechanicus Bodyguard" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry"><characteristics><characteristic name="Description" typeId="9b8f-694b-e5e-b573">While this model is within 3&quot; of one or more other friendly ^^**Adeptus Mechanicus**^^ units, this model has the Lone Operative ability.</characteristic></characteristics></profile><profile id="26a7-711f-28f6-7044" name="Self-repair Mechanisms" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry"><characteristics><characteristic name="Description" typeId="9b8f-694b-e5e-b573">At the start of your Command phase, this model regains up to D3 lost wounds.</characteristic></characteristics></profile><profile id="784a-55c7-c7b9-db88" name="Supreme Commander" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry"><characteristics><characteristic name="Description" typeId="9b8f-694b-e5e-b573">If this model is in your army, it must be your **^^Warlord^^**.</characteristic></characteristics></profile></profiles><selections><selection id="la280rb" name="Arc scourge" entryId="cb05-4e43-6776-3c51::2f59-b41c-83be-4efe" number="1" type="upgrade" from="entry"><rules><rule id="4111-82e3-9444-e942" name="Anti" hidden="false" page="28"><description>This ability always takes the form **[ANTI-X Y+]**. Each time an attack is made with an **[ANTI]** weapon, if the target unit has the keyword denoted by **X**, an unmodified **wound roll** of **Y+** is a **critical wound**.
***Example:** An attack made with an **[ANTI-VEHICLE 4+]** weapon against a ^^**Vehicle**^^ unit will result in a **critical wound** on an unmodified **wound roll** of 4+, while an attack made with an **[ANTI-PSYKER 2+]** weapon against a ^^**Psyker**^^ unit will result in a **critical wound** on an unmodified **wound roll** of 2+.*</description></rule><rule id="be1e-ac8e-1e2c-3528" name="Devastating Wounds" hidden="false" page="28"><description>Each time an attack made with a **[DEVASTATING WOUNDS]** weapon results in a **critical wound**, the attack sequence for that attack ends and the target unit suffers a number of **mortal wounds** equal to the **D** characteristic of that weapon. These are inflicted after resolving any normal damage inflicted by those attacks. 
**Mortal wounds** inflicted by **[DEVASTATING WOUNDS]** weapons can damage a maximum of one model for each **critical wound**; any remaining **mortal wounds** inflicted by that attack are lost. 
**Example: An attack made with a **[DEVASTATING WOUNDS]** weapon with a **D** characteristic of 3 results in a **critical wound** against an Intercessor Squad, so inflicts 3 **mortal wounds**. The first 2 **mortal wounds** are sufficient to **destroy** 1 Intercessor model, so the remaining **mortal wound** is lost.*</description></rule><rule id="115b-79dc-f723-d761" name="Extra Attacks" hidden="false" page="28"><description>Each time a unit containing one or more models with an **[EXTRA ATTACKS]** weapon fights, those models will make attacks with those weapons in addition to any others. In the Select Weapons step (04.01), for each of those models, you must select: 
- All of that models **[EXTRA ATTACKS]** weapons. 
- One of that models other melee weapons, if possible.</description></rule></rules><profiles><profile id="e4be-19e1-54b-d219" name="Arc Scourge" hidden="false" typeId="8a40-4aaa-c780-9046" typeName="Melee Weapons" from="entry"><characteristics><characteristic name="Range" typeId="914c-b413-91e3-a132">Melee</characteristic><characteristic name="A" typeId="2337-daa1-6682-b110">4</characteristic><characteristic name="WS" typeId="95d1-95f-45b4-11d6">2+</characteristic><characteristic name="S" typeId="ab33-d393-96ce-ccba">5</characteristic><characteristic name="AP" typeId="41a0-1301-112a-e2f2">-1</characteristic><characteristic name="D" typeId="3254-9fe6-d824-513e">1</characteristic><characteristic name="Keywords" typeId="893f-9000-ccf7-648e">Anti-Vehicle 4+, Devastating Wounds, Extra Attacks</characteristic></characteristics></profile></profiles><categories><category id="84c4-6d1e-e724-bd6e" name="Extra Attacks Weapon" entryId="84c4-6d1e-e724-bd6e" primary="false" /></categories></selection><selection id="ladirbc" name="Cawl's Omnissian axe" entryId="cb05-4e43-6776-3c51::4858-7bde-a8d4-e7da" number="1" type="upgrade" from="entry"><profiles><profile id="f04a-c6ee-6971-f7da" name="Cawl's Omnissian axe" hidden="false" typeId="8a40-4aaa-c780-9046" typeName="Melee Weapons" from="entry"><characteristics><characteristic name="Range" typeId="914c-b413-91e3-a132">Melee</characteristic><characteristic name="A" typeId="2337-daa1-6682-b110">4</characteristic><characteristic name="WS" typeId="95d1-95f-45b4-11d6">2+</characteristic><characteristic name="S" typeId="ab33-d393-96ce-ccba">8</characteristic><characteristic name="AP" typeId="41a0-1301-112a-e2f2">-2</characteristic><characteristic name="D" typeId="3254-9fe6-d824-513e">2</characteristic><characteristic name="Keywords" typeId="893f-9000-ccf7-648e">-</characteristic></characteristics></profile></profiles></selection><selection id="lafgy0s" name="Mechadendrite hive" entryId="cb05-4e43-6776-3c51::665-8af3-4b52-8ac6" number="1" type="upgrade" from="entry"><rules><rule id="115b-79dc-f723-d761" name="Extra Attacks" hidden="false" page="28"><description>Each time a unit containing one or more models with an **[EXTRA ATTACKS]** weapon fights, those models will make attacks with those weapons in addition to any others. In the Select Weapons step (04.01), for each of those models, you must select: 
- All of that models **[EXTRA ATTACKS]** weapons. 
- One of that models other melee weapons, if possible.</description></rule></rules><profiles><profile id="7a81-a490-ee0-ffdd" name="Mechadendrite hive" hidden="false" typeId="8a40-4aaa-c780-9046" typeName="Melee Weapons" from="entry"><characteristics><characteristic name="Range" typeId="914c-b413-91e3-a132">Melee</characteristic><characteristic name="A" typeId="2337-daa1-6682-b110">2D6</characteristic><characteristic name="WS" typeId="95d1-95f-45b4-11d6">3+</characteristic><characteristic name="S" typeId="ab33-d393-96ce-ccba">4</characteristic><characteristic name="AP" typeId="41a0-1301-112a-e2f2">0</characteristic><characteristic name="D" typeId="3254-9fe6-d824-513e">1</characteristic><characteristic name="Keywords" typeId="893f-9000-ccf7-648e">Extra Attacks</characteristic></characteristics></profile></profiles><categories><category id="84c4-6d1e-e724-bd6e" name="Extra Attacks Weapon" entryId="84c4-6d1e-e724-bd6e" primary="false" /></categories></selection><selection id="lac4b2c" name="Solar atomiser" entryId="cb05-4e43-6776-3c51::238d-d584-47ee-856d" number="1" type="upgrade" from="entry"><rules><rule id="6c1f-1cf7-ff25-c99e" name="Blast" hidden="false" page="26"><description>Each time you gather attack dice for a **[BLAST]** weapon, add one additional **attack dice** for every five models that were in the target unit in the Select Targets step (rounding down). If this ability takes the form **[BLAST X]**, each time you gather **attack dice** for such a weapon, add **X** additional **attack dice** for every five models that were in the target unit in the Select Targets step (rounding down) instead. 
***Example:** If a **[BLAST 2]** weapon with an **A** characteristic of 3 targets a unit containing 12 models, you would gather four additional **attack dice** for that weapon (for a total of seven for that weapon).*</description></rule><rule id="7cdb-fb99-44a9-8849" name="Melta" hidden="false" page="26"><description>This ability always takes the form **[MELTA X]**. Each time a model makes an attack with a **[MELTA]** weapon, if the target unit was within half range of that weapon in the Select Targets step, until the attacking units attacks have been resolved, add **X** to that weapons **D** characteristic. 
***Example:** A model targets a unit that is within half range of a **[MELTA 2]** weapon with a **D** characteristic of D6. While resolving those attacks, that weapon has a **D** characteristic of **D6+2**.</description></rule></rules><profiles><profile id="2fd0-1925-e091-75ef" name="Solar atomiser" hidden="false" typeId="f77d-b953-8fa4-b762" typeName="Ranged Weapons" from="entry"><characteristics><characteristic name="Range" typeId="9896-9419-16a1-92fc">18&quot;</characteristic><characteristic name="A" typeId="3bb-c35f-f54-fb08">3</characteristic><characteristic name="BS" typeId="94d-8a98-cf90-183e">2+</characteristic><characteristic name="S" typeId="2229-f494-25db-c5d3">14</characteristic><characteristic name="AP" typeId="9ead-8a10-520-de15">-4</characteristic><characteristic name="D" typeId="a354-c1c8-a745-f9e3">D6</characteristic><characteristic name="Keywords" typeId="7f1b-8591-2fcf-d01c">Melta 3</characteristic></characteristics></profile></profiles></selection><selection id="lanv51g" name="Warlord" entryId="cb05-4e43-6776-3c51::c097-c2fe-be86-de31::0580-79ca-da98-77c3" number="1" type="upgrade" from="entry"><categories><category id="5c0e-4c31-d51b-e470" name="Warlord" entryId="5c0e-4c31-d51b-e470" primary="false" /><category id="75fe-c657-43c6-7c09" name="Supreme Commander" entryId="75fe-c657-43c6-7c09" primary="false" /></categories></selection></selections><costs><cost name="pts" typeId="51b2-306e-1021-d207" value="220" /></costs><categories><category id="9106-4e44-7994-8859" name="Belisarius Cawl" entryId="9106-4e44-7994-8859" primary="false" /><category id="4f3a-f0f7-6647-348d" entryId="4f3a-f0f7-6647-348d" name="Epic Hero" primary="true" /><category id="9693-cf84-fe69-37a9" name="Monster" entryId="9693-cf84-fe69-37a9" primary="false" /><category id="9cfd-1c32-585f-7d5c" name="Character" entryId="9cfd-1c32-585f-7d5c" primary="false" /><category id="59a9-b5cc-7c11-aaad" name="Tech-Priest" entryId="59a9-b5cc-7c11-aaad" primary="false" /><category id="5418-f86b-6e76-c5a" name="Faction: Adeptus Mechanicus" entryId="5418-f86b-6e76-c5a" primary="false" /><category id="aff3-d6a3-2a95-9dc" name="Imperium" entryId="aff3-d6a3-2a95-9dc" primary="false" /><category id="6d46-7883-e5d1-45ee" name="Cult Mechanicus" entryId="6d46-7883-e5d1-45ee" primary="false" /></categories></selection></selections><categories><category name="Uncategorized" id="o5b8aw" primary="false" entryId="(No Category)" /><category name="Configuration" id="7z2e0hj" primary="false" entryId="4ac9-fd30-1e3d-b249" /><category name="Epic Hero" id="o5e736q" primary="false" entryId="4f3a-f0f7-6647-348d" /><category name="Character" id="o6rksa8" primary="false" entryId="9cfd-1c32-585f-7d5c" /><category name="Battleline" id="o6ay1he" primary="false" entryId="e338-111e-d0c6-b687" /><category name="Infantry" id="o6kppe6" primary="false" entryId="cf47-a0d7-7207-29dc" /><category name="Swarm" id="o6tx7a" primary="false" entryId="b00b-5bae-444f-964e" /><category name="Mounted" id="o6cf37m" primary="false" entryId="14a0-40c9-2748-ae6e" /><category name="Beast" id="o6cnr9" primary="false" entryId="4c3e-9310-a516-3590" /><category name="Monster" id="o60v22i" primary="false" entryId="9693-cf84-fe69-37a9" /><category name="Vehicle" id="o6d7b7r" primary="false" entryId="dbd4-63-af05-998" /><category name="Drone" id="o6reyz" primary="false" entryId="2471-e2e0-3f55-d6cb" /><category name="Dedicated Transport" id="o6t0vy" primary="false" entryId="ba07-411c-2832-1f79" /><category name="Fortification" id="o63ey3d" primary="false" entryId="19d7-9c74-2140-5851" /><category name="Unit" id="o6f99k" primary="false" entryId="1160-70ae-a862-b1a8" /><category name="Allies: Astra Militarum" id="o73fsgv" primary="false" entryId="8247-35f6-ec2a-7caa" /><category name="Allies: Chaos Knights" id="o7wqmgi" primary="false" entryId="8dd6-af11-abd3-55f1" /><category name="Allies: Heretic Astartes" id="o7fadd" primary="false" entryId="92e6-f82e-0a48-2276" /><category name="Allies: Imperial Agents" id="o7a694k" primary="false" entryId="742f-5727-1bc9-6da5" /><category name="Allies: Imperial Knights" id="o7xkohf" primary="false" entryId="5e03-9044-273d-e818" /><category name="Allies: Legiones Daemonica" id="o7h1wqa" primary="false" entryId="e1ea-71b9-f0ef-d788" /><category name="Allies: Titanicus Traitoris" id="o7131zt" primary="false" entryId="24fa-8caf-cd68-31fb" /><category name="Allies: Unaligned Forces" id="o7u4bvf" primary="false" entryId="07d9-ab1c-cf28-0939" /><category name="Reference" id="o7g3qsb" primary="false" entryId="eef1-be80-500a-edfc" /><category name="Allies: Adeptus Titanicus" id="o8ica9m" primary="false" entryId="9988-6b5e-660e-d973" /><category name="Illegal Units" id="o881cch" primary="false" entryId="(Illegal Units)" /></categories></force></forces></roster>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<roster id="7rmu2zl" name="TEST" battleScribeVersion="2.03" generatedBy="https://newrecruit.eu" gameSystemId="sys-352e-adc2-7639-d610" gameSystemName="Warhammer 40,000 11th Edition" gameSystemRevision="6" xmlns="http://www.battlescribe.net/schema/rosterSchema"><costs><cost name="pts" typeId="51b2-306e-1021-d207" value="220" /></costs><forces><force id="7wfvr6o" name="Army Roster" entryId="bb9d-299a-ed60-2d8a" catalogueId="77b9-2f66-3f9b-5cf3" catalogueRevision="5" catalogueName="Imperium - Adeptus Mechanicus"><selections><selection id="bky5c11" name="Battle Size" entryId="7380-3e40-6ed6-b7cc::564e-fbc6-5266-3ea4" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="bg2lcx" name="Detachment" entryId="2874-c86-3152-393::c82f-1b42-946-9c9a" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="bdcmhqi" name="Force Disposition" entryId="8bc8-6bfe-78bd-2480::2f69-9148-45b4-86a8" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="bnoi9vo" name="Show/Hide Options" entryId="3458-3cff-ef5f-a7c5::e8ef-836a-a9d1-901d" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="ladocm" name="Belisarius Cawl" entryId="cb05-4e43-6776-3c51::1f1e-2989-4762-cf88" number="1" type="model" from="entry"><rules><rule id="7a21-a958-e47d-5c0d" name="Doctrina Imperatives" hidden="false"><description>At the start of the battle round, you can select one of the Doctrina Imperatives below. Until the end of the battle round, that Doctrina Imperative is active for your army, and all units from your army that have the Doctrina Imperatives ability gain the relevant abilities shown below.
PROTECTOR IMPERATIVE
■ Ranged weapons equipped by models in this unit have the [HEAVY] ability.
■ Improve the Ballistic Skill characteristic of ranged weapons equipped by models in this unit by 1.
■ Each time a melee attack targets this unit, if this unit has the ^^**Battleline**^^ keyword and/or it is within 6&quot; of one or more friendly **^^Adeptus Mechanicus Battleline**^^ units, subtract 1 from the Hit roll.
CONQUEROR IMPERATIVE
■ Ranged weapons equipped by models in this unit have the [ASSAULT] ability.
■ Improve the Weapon Skill characteristic of melee weapons equipped by models in this unit by 1.
■ Each time a model in this unit makes an attack, if this unit has the **^^Battleline**^^ keyword and/or it is within 6&quot; of one or more friendly **^^Adeptus Mechanicus Battleline**^^ units, improve the Armour Penetration characteristic of that attack by 1.</description></rule></rules><profiles><profile id="1f5d-20a5-f37c-7d5a" name="Belisarius Cawl" hidden="false" typeId="c547-1836-d8a-ff4f" typeName="Unit" from="entry"><characteristics><characteristic name="M" typeId="e703-ecb6-5ce7-aec1">8&quot;</characteristic><characteristic name="T" typeId="d29d-cf75-fc2d-34a4">8</characteristic><characteristic name="Sv" typeId="450-a17e-9d5e-29da">2+</characteristic><characteristic name="W" typeId="750a-a2ec-90d3-21fe">10</characteristic><characteristic name="LD" typeId="58d2-b879-49c7-43bc">6+</characteristic><characteristic name="OC" typeId="bef7-942a-1a23-59f8">3</characteristic><characteristic name="InSv" typeId="55a7-5b54-c60d-11dc">4+</characteristic></characteristics></profile><profile id="9e99-0aa1-2c11-33ca" name="Canticles of the Omnissiah" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry"><characteristics><characteristic name="Description" typeId="9b8f-694b-e5e-b573">At the start of your Command phase, select one of the abilities in the Canticles of the Omnissiah section. Until the start of your next Command phase, this model has that ability.
Invocation of Machine Vengeance (Aura): At the start of your Command phase, select one unit from your opponents army. Until the start of your next Command phase, that enemy unit is your Machine Vengeance target. Each time a model in a friendly ^^**Adeptus Mechanicus^^** unit makes an attack that targets your Machine Vengeance target, you can reroll the Hit roll.
Mantra of Discipline:  This model has the ^^**Battleline**^^ keyword and has the following ability:
**Binharic Courage (Aura)**: While a friendly ^^**Adeptus Mechanicus**^^ unit is within 6&quot; of this model, add 1 to the Objective Control characteristic of models in that unit and each time you take a Battle-shock or Leadership test for that unit, add 1 to that test.
Shroudpsalm (Aura): While a friendly **^^Adeptus Mechanicus**^^ unit is within 6&quot; of this model, that unit has the Stealth ability.</characteristic></characteristics></profile><profile id="f172-384f-e1fa-d656" name="Mechanicus Bodyguard" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry"><characteristics><characteristic name="Description" typeId="9b8f-694b-e5e-b573">While this model is within 3&quot; of one or more other friendly ^^**Adeptus Mechanicus**^^ units, this model has the Lone Operative ability.</characteristic></characteristics></profile><profile id="26a7-711f-28f6-7044" name="Self-repair Mechanisms" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry"><characteristics><characteristic name="Description" typeId="9b8f-694b-e5e-b573">At the start of your Command phase, this model regains up to D3 lost wounds.</characteristic></characteristics></profile><profile id="784a-55c7-c7b9-db88" name="Supreme Commander" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry"><characteristics><characteristic name="Description" typeId="9b8f-694b-e5e-b573">If this model is in your army, it must be your **^^Warlord^^**.</characteristic></characteristics></profile></profiles><selections><selection id="la280rb" name="Arc scourge" entryId="cb05-4e43-6776-3c51::2f59-b41c-83be-4efe" number="1" type="upgrade" from="entry"><rules><rule id="4111-82e3-9444-e942" name="Anti" hidden="false" page="28"><description>This ability always takes the form **[ANTI-X Y+]**. Each time an attack is made with an **[ANTI]** weapon, if the target unit has the keyword denoted by **X**, an unmodified **wound roll** of **Y+** is a **critical wound**.
***Example:** An attack made with an **[ANTI-VEHICLE 4+]** weapon against a ^^**Vehicle**^^ unit will result in a **critical wound** on an unmodified **wound roll** of 4+, while an attack made with an **[ANTI-PSYKER 2+]** weapon against a ^^**Psyker**^^ unit will result in a **critical wound** on an unmodified **wound roll** of 2+.*</description></rule><rule id="be1e-ac8e-1e2c-3528" name="Devastating Wounds" hidden="false" page="28"><description>Each time an attack made with a **[DEVASTATING WOUNDS]** weapon results in a **critical wound**, the attack sequence for that attack ends and the target unit suffers a number of **mortal wounds** equal to the **D** characteristic of that weapon. These are inflicted after resolving any normal damage inflicted by those attacks. 
**Mortal wounds** inflicted by **[DEVASTATING WOUNDS]** weapons can damage a maximum of one model for each **critical wound**; any remaining **mortal wounds** inflicted by that attack are lost. 
**Example: An attack made with a **[DEVASTATING WOUNDS]** weapon with a **D** characteristic of 3 results in a **critical wound** against an Intercessor Squad, so inflicts 3 **mortal wounds**. The first 2 **mortal wounds** are sufficient to **destroy** 1 Intercessor model, so the remaining **mortal wound** is lost.*</description></rule><rule id="115b-79dc-f723-d761" name="Extra Attacks" hidden="false" page="28"><description>Each time a unit containing one or more models with an **[EXTRA ATTACKS]** weapon fights, those models will make attacks with those weapons in addition to any others. In the Select Weapons step (04.01), for each of those models, you must select: 
- All of that models **[EXTRA ATTACKS]** weapons. 
- One of that models other melee weapons, if possible.</description></rule></rules><profiles><profile id="e4be-19e1-54b-d219" name="Arc Scourge" hidden="false" typeId="8a40-4aaa-c780-9046" typeName="Melee Weapons" from="entry"><characteristics><characteristic name="Range" typeId="914c-b413-91e3-a132">Melee</characteristic><characteristic name="A" typeId="2337-daa1-6682-b110">4</characteristic><characteristic name="WS" typeId="95d1-95f-45b4-11d6">2+</characteristic><characteristic name="S" typeId="ab33-d393-96ce-ccba">5</characteristic><characteristic name="AP" typeId="41a0-1301-112a-e2f2">-1</characteristic><characteristic name="D" typeId="3254-9fe6-d824-513e">1</characteristic><characteristic name="Keywords" typeId="893f-9000-ccf7-648e">Anti-Vehicle 4+, Devastating Wounds, Extra Attacks</characteristic></characteristics></profile></profiles><categories><category id="84c4-6d1e-e724-bd6e" name="Extra Attacks Weapon" entryId="84c4-6d1e-e724-bd6e" primary="false" /></categories></selection><selection id="ladirbc" name="Cawl's Omnissian axe" entryId="cb05-4e43-6776-3c51::4858-7bde-a8d4-e7da" number="1" type="upgrade" from="entry"><profiles><profile id="f04a-c6ee-6971-f7da" name="Cawl's Omnissian axe" hidden="false" typeId="8a40-4aaa-c780-9046" typeName="Melee Weapons" from="entry"><characteristics><characteristic name="Range" typeId="914c-b413-91e3-a132">Melee</characteristic><characteristic name="A" typeId="2337-daa1-6682-b110">4</characteristic><characteristic name="WS" typeId="95d1-95f-45b4-11d6">2+</characteristic><characteristic name="S" typeId="ab33-d393-96ce-ccba">8</characteristic><characteristic name="AP" typeId="41a0-1301-112a-e2f2">-2</characteristic><characteristic name="D" typeId="3254-9fe6-d824-513e">2</characteristic><characteristic name="Keywords" typeId="893f-9000-ccf7-648e">-</characteristic></characteristics></profile></profiles></selection><selection id="lafgy0s" name="Mechadendrite hive" entryId="cb05-4e43-6776-3c51::665-8af3-4b52-8ac6" number="1" type="upgrade" from="entry"><rules><rule id="115b-79dc-f723-d761" name="Extra Attacks" hidden="false" page="28"><description>Each time a unit containing one or more models with an **[EXTRA ATTACKS]** weapon fights, those models will make attacks with those weapons in addition to any others. In the Select Weapons step (04.01), for each of those models, you must select: 
- All of that models **[EXTRA ATTACKS]** weapons. 
- One of that models other melee weapons, if possible.</description></rule></rules><profiles><profile id="7a81-a490-ee0-ffdd" name="Mechadendrite hive" hidden="false" typeId="8a40-4aaa-c780-9046" typeName="Melee Weapons" from="entry"><characteristics><characteristic name="Range" typeId="914c-b413-91e3-a132">Melee</characteristic><characteristic name="A" typeId="2337-daa1-6682-b110">2D6</characteristic><characteristic name="WS" typeId="95d1-95f-45b4-11d6">3+</characteristic><characteristic name="S" typeId="ab33-d393-96ce-ccba">4</characteristic><characteristic name="AP" typeId="41a0-1301-112a-e2f2">0</characteristic><characteristic name="D" typeId="3254-9fe6-d824-513e">1</characteristic><characteristic name="Keywords" typeId="893f-9000-ccf7-648e">Extra Attacks</characteristic></characteristics></profile></profiles><categories><category id="84c4-6d1e-e724-bd6e" name="Extra Attacks Weapon" entryId="84c4-6d1e-e724-bd6e" primary="false" /></categories></selection><selection id="lac4b2c" name="Solar atomiser" entryId="cb05-4e43-6776-3c51::238d-d584-47ee-856d" number="1" type="upgrade" from="entry"><rules><rule id="6c1f-1cf7-ff25-c99e" name="Blast" hidden="false" page="26"><description>Each time you gather attack dice for a **[BLAST]** weapon, add one additional **attack dice** for every five models that were in the target unit in the Select Targets step (rounding down). If this ability takes the form **[BLAST X]**, each time you gather **attack dice** for such a weapon, add **X** additional **attack dice** for every five models that were in the target unit in the Select Targets step (rounding down) instead. 
***Example:** If a **[BLAST 2]** weapon with an **A** characteristic of 3 targets a unit containing 12 models, you would gather four additional **attack dice** for that weapon (for a total of seven for that weapon).*</description></rule><rule id="7cdb-fb99-44a9-8849" name="Melta" hidden="false" page="26"><description>This ability always takes the form **[MELTA X]**. Each time a model makes an attack with a **[MELTA]** weapon, if the target unit was within half range of that weapon in the Select Targets step, until the attacking units attacks have been resolved, add **X** to that weapons **D** characteristic. 
***Example:** A model targets a unit that is within half range of a **[MELTA 2]** weapon with a **D** characteristic of D6. While resolving those attacks, that weapon has a **D** characteristic of **D6+2**.</description></rule></rules><profiles><profile id="2fd0-1925-e091-75ef" name="Solar atomiser" hidden="false" typeId="f77d-b953-8fa4-b762" typeName="Ranged Weapons" from="entry"><characteristics><characteristic name="Range" typeId="9896-9419-16a1-92fc">18&quot;</characteristic><characteristic name="A" typeId="3bb-c35f-f54-fb08">3</characteristic><characteristic name="BS" typeId="94d-8a98-cf90-183e">2+</characteristic><characteristic name="S" typeId="2229-f494-25db-c5d3">14</characteristic><characteristic name="AP" typeId="9ead-8a10-520-de15">-4</characteristic><characteristic name="D" typeId="a354-c1c8-a745-f9e3">D6</characteristic><characteristic name="Keywords" typeId="7f1b-8591-2fcf-d01c">Melta 3</characteristic></characteristics></profile></profiles></selection><selection id="lanv51g" name="Warlord" entryId="cb05-4e43-6776-3c51::c097-c2fe-be86-de31::0580-79ca-da98-77c3" number="1" type="upgrade" from="entry"><categories><category id="5c0e-4c31-d51b-e470" name="Warlord" entryId="5c0e-4c31-d51b-e470" primary="false" /><category id="75fe-c657-43c6-7c09" name="Supreme Commander" entryId="75fe-c657-43c6-7c09" primary="false" /></categories></selection></selections><costs><cost name="pts" typeId="51b2-306e-1021-d207" value="220" /></costs><categories><category id="9106-4e44-7994-8859" name="Belisarius Cawl" entryId="9106-4e44-7994-8859" primary="false" /><category id="4f3a-f0f7-6647-348d" entryId="4f3a-f0f7-6647-348d" name="Epic Hero" primary="true" /><category id="9693-cf84-fe69-37a9" name="Monster" entryId="9693-cf84-fe69-37a9" primary="false" /><category id="9cfd-1c32-585f-7d5c" name="Character" entryId="9cfd-1c32-585f-7d5c" primary="false" /><category id="59a9-b5cc-7c11-aaad" name="Tech-Priest" entryId="59a9-b5cc-7c11-aaad" primary="false" /><category id="5418-f86b-6e76-c5a" name="Faction: Adeptus Mechanicus" entryId="5418-f86b-6e76-c5a" primary="false" /><category id="aff3-d6a3-2a95-9dc" name="Imperium" entryId="aff3-d6a3-2a95-9dc" primary="false" /><category id="6d46-7883-e5d1-45ee" name="Cult Mechanicus" entryId="6d46-7883-e5d1-45ee" primary="false" /></categories></selection></selections><categories><category name="Uncategorized" id="o5b8aw" primary="false" entryId="(No Category)" /><category name="Configuration" id="7z2e0hj" primary="false" entryId="4ac9-fd30-1e3d-b249" /><category name="Epic Hero" id="o5e736q" primary="false" entryId="4f3a-f0f7-6647-348d" /><category name="Character" id="o6rksa8" primary="false" entryId="9cfd-1c32-585f-7d5c" /><category name="Battleline" id="o6ay1he" primary="false" entryId="e338-111e-d0c6-b687" /><category name="Infantry" id="o6kppe6" primary="false" entryId="cf47-a0d7-7207-29dc" /><category name="Swarm" id="o6tx7a" primary="false" entryId="b00b-5bae-444f-964e" /><category name="Mounted" id="o6cf37m" primary="false" entryId="14a0-40c9-2748-ae6e" /><category name="Beast" id="o6cnr9" primary="false" entryId="4c3e-9310-a516-3590" /><category name="Monster" id="o60v22i" primary="false" entryId="9693-cf84-fe69-37a9" /><category name="Vehicle" id="o6d7b7r" primary="false" entryId="dbd4-63-af05-998" /><category name="Drone" id="o6reyz" primary="false" entryId="2471-e2e0-3f55-d6cb" /><category name="Dedicated Transport" id="o6t0vy" primary="false" entryId="ba07-411c-2832-1f79" /><category name="Fortification" id="o63ey3d" primary="false" entryId="19d7-9c74-2140-5851" /><category name="Unit" id="o6f99k" primary="false" entryId="1160-70ae-a862-b1a8" /><category name="Allies: Astra Militarum" id="o73fsgv" primary="false" entryId="8247-35f6-ec2a-7caa" /><category name="Allies: Chaos Knights" id="o7wqmgi" primary="false" entryId="8dd6-af11-abd3-55f1" /><category name="Allies: Heretic Astartes" id="o7fadd" primary="false" entryId="92e6-f82e-0a48-2276" /><category name="Allies: Imperial Agents" id="o7a694k" primary="false" entryId="742f-5727-1bc9-6da5" /><category name="Allies: Imperial Knights" id="o7xkohf" primary="false" entryId="5e03-9044-273d-e818" /><category name="Allies: Legiones Daemonica" id="o7h1wqa" primary="false" entryId="e1ea-71b9-f0ef-d788" /><category name="Allies: Titanicus Traitoris" id="o7131zt" primary="false" entryId="24fa-8caf-cd68-31fb" /><category name="Allies: Unaligned Forces" id="o7u4bvf" primary="false" entryId="07d9-ab1c-cf28-0939" /><category name="Reference" id="o7g3qsb" primary="false" entryId="eef1-be80-500a-edfc" /><category name="Allies: Adeptus Titanicus" id="o8ica9m" primary="false" entryId="9988-6b5e-660e-d973" /><category name="Illegal Units" id="o881cch" primary="false" entryId="(Illegal Units)" /></categories></force></forces></roster>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<roster id="7rmu2zl" name="TEST" battleScribeVersion="2.03" generatedBy="https://newrecruit.eu" gameSystemId="sys-352e-adc2-7639-d610" gameSystemName="Warhammer 40,000 11th Edition" gameSystemRevision="6" xmlns="http://www.battlescribe.net/schema/rosterSchema"><costs><cost name="pts" typeId="51b2-306e-1021-d207" value="220" /></costs><forces><force id="7wfvr6o" name="Army Roster" entryId="bb9d-299a-ed60-2d8a" catalogueId="77b9-2f66-3f9b-5cf3" catalogueRevision="5" catalogueName="Imperium - Adeptus Mechanicus"><selections><selection id="bky5c11" name="Battle Size" entryId="7380-3e40-6ed6-b7cc::564e-fbc6-5266-3ea4" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="bg2lcx" name="Detachment" entryId="2874-c86-3152-393::c82f-1b42-946-9c9a" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="bdcmhqi" name="Force Disposition" entryId="8bc8-6bfe-78bd-2480::2f69-9148-45b4-86a8" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="bnoi9vo" name="Show/Hide Options" entryId="3458-3cff-ef5f-a7c5::e8ef-836a-a9d1-901d" number="1" type="upgrade" from="entry"><categories><category id="4ac9-fd30-1e3d-b249" entryId="4ac9-fd30-1e3d-b249" name="Configuration" primary="true" /></categories></selection><selection id="ladocm" name="Belisarius Cawl" entryId="cb05-4e43-6776-3c51::1f1e-2989-4762-cf88" number="1" type="model" from="entry"><rules><rule id="7a21-a958-e47d-5c0d" name="Doctrina Imperatives" hidden="false"><description>At the start of the battle round, you can select one of the Doctrina Imperatives below. Until the end of the battle round, that Doctrina Imperative is active for your army, and all units from your army that have the Doctrina Imperatives ability gain the relevant abilities shown below.
PROTECTOR IMPERATIVE
■ Ranged weapons equipped by models in this unit have the [HEAVY] ability.
■ Improve the Ballistic Skill characteristic of ranged weapons equipped by models in this unit by 1.
■ Each time a melee attack targets this unit, if this unit has the ^^**Battleline**^^ keyword and/or it is within 6&quot; of one or more friendly **^^Adeptus Mechanicus Battleline**^^ units, subtract 1 from the Hit roll.
CONQUEROR IMPERATIVE
■ Ranged weapons equipped by models in this unit have the [ASSAULT] ability.
■ Improve the Weapon Skill characteristic of melee weapons equipped by models in this unit by 1.
■ Each time a model in this unit makes an attack, if this unit has the **^^Battleline**^^ keyword and/or it is within 6&quot; of one or more friendly **^^Adeptus Mechanicus Battleline**^^ units, improve the Armour Penetration characteristic of that attack by 1.</description></rule></rules><profiles><profile id="1f5d-20a5-f37c-7d5a" name="Belisarius Cawl" hidden="false" typeId="c547-1836-d8a-ff4f" typeName="Unit" from="entry"><characteristics><characteristic name="M" typeId="e703-ecb6-5ce7-aec1">8&quot;</characteristic><characteristic name="T" typeId="d29d-cf75-fc2d-34a4">8</characteristic><characteristic name="Sv" typeId="450-a17e-9d5e-29da">2+</characteristic><characteristic name="W" typeId="750a-a2ec-90d3-21fe">10</characteristic><characteristic name="LD" typeId="58d2-b879-49c7-43bc">6+</characteristic><characteristic name="OC" typeId="bef7-942a-1a23-59f8">3</characteristic><characteristic name="InSv" typeId="55a7-5b54-c60d-11dc">4+</characteristic></characteristics></profile><profile id="9e99-0aa1-2c11-33ca" name="Canticles of the Omnissiah" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry"><characteristics><characteristic name="Description" typeId="9b8f-694b-e5e-b573">At the start of your Command phase, select one of the abilities in the Canticles of the Omnissiah section. Until the start of your next Command phase, this model has that ability.
Invocation of Machine Vengeance (Aura): At the start of your Command phase, select one unit from your opponents army. Until the start of your next Command phase, that enemy unit is your Machine Vengeance target. Each time a model in a friendly ^^**Adeptus Mechanicus^^** unit makes an attack that targets your Machine Vengeance target, you can reroll the Hit roll.
Mantra of Discipline:  This model has the ^^**Battleline**^^ keyword and has the following ability:
**Binharic Courage (Aura)**: While a friendly ^^**Adeptus Mechanicus**^^ unit is within 6&quot; of this model, add 1 to the Objective Control characteristic of models in that unit and each time you take a Battle-shock or Leadership test for that unit, add 1 to that test.
Shroudpsalm (Aura): While a friendly **^^Adeptus Mechanicus**^^ unit is within 6&quot; of this model, that unit has the Stealth ability.</characteristic></characteristics></profile><profile id="f172-384f-e1fa-d656" name="Mechanicus Bodyguard" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry"><characteristics><characteristic name="Description" typeId="9b8f-694b-e5e-b573">While this model is within 3&quot; of one or more other friendly ^^**Adeptus Mechanicus**^^ units, this model has the Lone Operative ability.</characteristic></characteristics></profile><profile id="26a7-711f-28f6-7044" name="Self-repair Mechanisms" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry"><characteristics><characteristic name="Description" typeId="9b8f-694b-e5e-b573">At the start of your Command phase, this model regains up to D3 lost wounds.</characteristic></characteristics></profile><profile id="784a-55c7-c7b9-db88" name="Supreme Commander" hidden="false" typeId="9cc3-6d83-4dd3-9b64" typeName="Abilities" from="entry"><characteristics><characteristic name="Description" typeId="9b8f-694b-e5e-b573">If this model is in your army, it must be your **^^Warlord^^**.</characteristic></characteristics></profile></profiles><selections><selection id="la280rb" name="Arc scourge" entryId="cb05-4e43-6776-3c51::2f59-b41c-83be-4efe" number="1" type="upgrade" from="entry"><rules><rule id="4111-82e3-9444-e942" name="Anti" hidden="false" page="28"><description>This ability always takes the form **[ANTI-X Y+]**. Each time an attack is made with an **[ANTI]** weapon, if the target unit has the keyword denoted by **X**, an unmodified **wound roll** of **Y+** is a **critical wound**.
***Example:** An attack made with an **[ANTI-VEHICLE 4+]** weapon against a ^^**Vehicle**^^ unit will result in a **critical wound** on an unmodified **wound roll** of 4+, while an attack made with an **[ANTI-PSYKER 2+]** weapon against a ^^**Psyker**^^ unit will result in a **critical wound** on an unmodified **wound roll** of 2+.*</description></rule><rule id="be1e-ac8e-1e2c-3528" name="Devastating Wounds" hidden="false" page="28"><description>Each time an attack made with a **[DEVASTATING WOUNDS]** weapon results in a **critical wound**, the attack sequence for that attack ends and the target unit suffers a number of **mortal wounds** equal to the **D** characteristic of that weapon. These are inflicted after resolving any normal damage inflicted by those attacks. 
**Mortal wounds** inflicted by **[DEVASTATING WOUNDS]** weapons can damage a maximum of one model for each **critical wound**; any remaining **mortal wounds** inflicted by that attack are lost. 
**Example: An attack made with a **[DEVASTATING WOUNDS]** weapon with a **D** characteristic of 3 results in a **critical wound** against an Intercessor Squad, so inflicts 3 **mortal wounds**. The first 2 **mortal wounds** are sufficient to **destroy** 1 Intercessor model, so the remaining **mortal wound** is lost.*</description></rule><rule id="115b-79dc-f723-d761" name="Extra Attacks" hidden="false" page="28"><description>Each time a unit containing one or more models with an **[EXTRA ATTACKS]** weapon fights, those models will make attacks with those weapons in addition to any others. In the Select Weapons step (04.01), for each of those models, you must select: 
- All of that models **[EXTRA ATTACKS]** weapons. 
- One of that models other melee weapons, if possible.</description></rule></rules><profiles><profile id="e4be-19e1-54b-d219" name="Arc Scourge" hidden="false" typeId="8a40-4aaa-c780-9046" typeName="Melee Weapons" from="entry"><characteristics><characteristic name="Range" typeId="914c-b413-91e3-a132">Melee</characteristic><characteristic name="A" typeId="2337-daa1-6682-b110">4</characteristic><characteristic name="WS" typeId="95d1-95f-45b4-11d6">2+</characteristic><characteristic name="S" typeId="ab33-d393-96ce-ccba">5</characteristic><characteristic name="AP" typeId="41a0-1301-112a-e2f2">-1</characteristic><characteristic name="D" typeId="3254-9fe6-d824-513e">1</characteristic><characteristic name="Keywords" typeId="893f-9000-ccf7-648e">Anti-Vehicle 4+, Devastating Wounds, Extra Attacks</characteristic></characteristics></profile></profiles><categories><category id="84c4-6d1e-e724-bd6e" name="Extra Attacks Weapon" entryId="84c4-6d1e-e724-bd6e" primary="false" /></categories></selection><selection id="ladirbc" name="Cawl's Omnissian axe" entryId="cb05-4e43-6776-3c51::4858-7bde-a8d4-e7da" number="1" type="upgrade" from="entry"><profiles><profile id="f04a-c6ee-6971-f7da" name="Cawl's Omnissian axe" hidden="false" typeId="8a40-4aaa-c780-9046" typeName="Melee Weapons" from="entry"><characteristics><characteristic name="Range" typeId="914c-b413-91e3-a132">Melee</characteristic><characteristic name="A" typeId="2337-daa1-6682-b110">4</characteristic><characteristic name="WS" typeId="95d1-95f-45b4-11d6">2+</characteristic><characteristic name="S" typeId="ab33-d393-96ce-ccba">8</characteristic><characteristic name="AP" typeId="41a0-1301-112a-e2f2">-2</characteristic><characteristic name="D" typeId="3254-9fe6-d824-513e">2</characteristic><characteristic name="Keywords" typeId="893f-9000-ccf7-648e">-</characteristic></characteristics></profile></profiles></selection><selection id="lafgy0s" name="Mechadendrite hive" entryId="cb05-4e43-6776-3c51::665-8af3-4b52-8ac6" number="1" type="upgrade" from="entry"><rules><rule id="115b-79dc-f723-d761" name="Extra Attacks" hidden="false" page="28"><description>Each time a unit containing one or more models with an **[EXTRA ATTACKS]** weapon fights, those models will make attacks with those weapons in addition to any others. In the Select Weapons step (04.01), for each of those models, you must select: 
- All of that models **[EXTRA ATTACKS]** weapons. 
- One of that models other melee weapons, if possible.</description></rule></rules><profiles><profile id="7a81-a490-ee0-ffdd" name="Mechadendrite hive" hidden="false" typeId="8a40-4aaa-c780-9046" typeName="Melee Weapons" from="entry"><characteristics><characteristic name="Range" typeId="914c-b413-91e3-a132">Melee</characteristic><characteristic name="A" typeId="2337-daa1-6682-b110">2D6</characteristic><characteristic name="WS" typeId="95d1-95f-45b4-11d6">3+</characteristic><characteristic name="S" typeId="ab33-d393-96ce-ccba">4</characteristic><characteristic name="AP" typeId="41a0-1301-112a-e2f2">0</characteristic><characteristic name="D" typeId="3254-9fe6-d824-513e">1</characteristic><characteristic name="Keywords" typeId="893f-9000-ccf7-648e">Extra Attacks</characteristic></characteristics></profile></profiles><categories><category id="84c4-6d1e-e724-bd6e" name="Extra Attacks Weapon" entryId="84c4-6d1e-e724-bd6e" primary="false" /></categories></selection><selection id="lac4b2c" name="Solar atomiser" entryId="cb05-4e43-6776-3c51::238d-d584-47ee-856d" number="1" type="upgrade" from="entry"><rules><rule id="6c1f-1cf7-ff25-c99e" name="Blast" hidden="false" page="26"><description>Each time you gather attack dice for a **[BLAST]** weapon, add one additional **attack dice** for every five models that were in the target unit in the Select Targets step (rounding down). If this ability takes the form **[BLAST X]**, each time you gather **attack dice** for such a weapon, add **X** additional **attack dice** for every five models that were in the target unit in the Select Targets step (rounding down) instead. 
***Example:** If a **[BLAST 2]** weapon with an **A** characteristic of 3 targets a unit containing 12 models, you would gather four additional **attack dice** for that weapon (for a total of seven for that weapon).*</description></rule><rule id="7cdb-fb99-44a9-8849" name="Melta" hidden="false" page="26"><description>This ability always takes the form **[MELTA X]**. Each time a model makes an attack with a **[MELTA]** weapon, if the target unit was within half range of that weapon in the Select Targets step, until the attacking units attacks have been resolved, add **X** to that weapons **D** characteristic. 
***Example:** A model targets a unit that is within half range of a **[MELTA 2]** weapon with a **D** characteristic of D6. While resolving those attacks, that weapon has a **D** characteristic of **D6+2**.</description></rule></rules><profiles><profile id="2fd0-1925-e091-75ef" name="Solar atomiser" hidden="false" typeId="f77d-b953-8fa4-b762" typeName="Ranged Weapons" from="entry"><characteristics><characteristic name="Range" typeId="9896-9419-16a1-92fc">18&quot;</characteristic><characteristic name="A" typeId="3bb-c35f-f54-fb08">3</characteristic><characteristic name="BS" typeId="94d-8a98-cf90-183e">2+</characteristic><characteristic name="S" typeId="2229-f494-25db-c5d3">14</characteristic><characteristic name="AP" typeId="9ead-8a10-520-de15">-4</characteristic><characteristic name="D" typeId="a354-c1c8-a745-f9e3">D6</characteristic><characteristic name="Keywords" typeId="7f1b-8591-2fcf-d01c">Melta 3</characteristic></characteristics></profile></profiles></selection><selection id="lanv51g" name="Warlord" entryId="cb05-4e43-6776-3c51::c097-c2fe-be86-de31::0580-79ca-da98-77c3" number="1" type="upgrade" from="entry"><categories><category id="5c0e-4c31-d51b-e470" name="Warlord" entryId="5c0e-4c31-d51b-e470" primary="false" /><category id="75fe-c657-43c6-7c09" name="Supreme Commander" entryId="75fe-c657-43c6-7c09" primary="false" /></categories></selection></selections><costs><cost name="pts" typeId="51b2-306e-1021-d207" value="220" /></costs><categories><category id="9106-4e44-7994-8859" name="Belisarius Cawl" entryId="9106-4e44-7994-8859" primary="false" /><category id="4f3a-f0f7-6647-348d" entryId="4f3a-f0f7-6647-348d" name="Epic Hero" primary="true" /><category id="9693-cf84-fe69-37a9" name="Monster" entryId="9693-cf84-fe69-37a9" primary="false" /><category id="9cfd-1c32-585f-7d5c" name="Character" entryId="9cfd-1c32-585f-7d5c" primary="false" /><category id="59a9-b5cc-7c11-aaad" name="Tech-Priest" entryId="59a9-b5cc-7c11-aaad" primary="false" /><category id="5418-f86b-6e76-c5a" name="Faction: Adeptus Mechanicus" entryId="5418-f86b-6e76-c5a" primary="false" /><category id="aff3-d6a3-2a95-9dc" name="Imperium" entryId="aff3-d6a3-2a95-9dc" primary="false" /><category id="6d46-7883-e5d1-45ee" name="Cult Mechanicus" entryId="6d46-7883-e5d1-45ee" primary="false" /></categories></selection></selections><categories><category name="Uncategorized" id="o5b8aw" primary="false" entryId="(No Category)" /><category name="Configuration" id="7z2e0hj" primary="false" entryId="4ac9-fd30-1e3d-b249" /><category name="Epic Hero" id="o5e736q" primary="false" entryId="4f3a-f0f7-6647-348d" /><category name="Character" id="o6rksa8" primary="false" entryId="9cfd-1c32-585f-7d5c" /><category name="Battleline" id="o6ay1he" primary="false" entryId="e338-111e-d0c6-b687" /><category name="Infantry" id="o6kppe6" primary="false" entryId="cf47-a0d7-7207-29dc" /><category name="Swarm" id="o6tx7a" primary="false" entryId="b00b-5bae-444f-964e" /><category name="Mounted" id="o6cf37m" primary="false" entryId="14a0-40c9-2748-ae6e" /><category name="Beast" id="o6cnr9" primary="false" entryId="4c3e-9310-a516-3590" /><category name="Monster" id="o60v22i" primary="false" entryId="9693-cf84-fe69-37a9" /><category name="Vehicle" id="o6d7b7r" primary="false" entryId="dbd4-63-af05-998" /><category name="Drone" id="o6reyz" primary="false" entryId="2471-e2e0-3f55-d6cb" /><category name="Dedicated Transport" id="o6t0vy" primary="false" entryId="ba07-411c-2832-1f79" /><category name="Fortification" id="o63ey3d" primary="false" entryId="19d7-9c74-2140-5851" /><category name="Unit" id="o6f99k" primary="false" entryId="1160-70ae-a862-b1a8" /><category name="Allies: Astra Militarum" id="o73fsgv" primary="false" entryId="8247-35f6-ec2a-7caa" /><category name="Allies: Chaos Knights" id="o7wqmgi" primary="false" entryId="8dd6-af11-abd3-55f1" /><category name="Allies: Heretic Astartes" id="o7fadd" primary="false" entryId="92e6-f82e-0a48-2276" /><category name="Allies: Imperial Agents" id="o7a694k" primary="false" entryId="742f-5727-1bc9-6da5" /><category name="Allies: Imperial Knights" id="o7xkohf" primary="false" entryId="5e03-9044-273d-e818" /><category name="Allies: Legiones Daemonica" id="o7h1wqa" primary="false" entryId="e1ea-71b9-f0ef-d788" /><category name="Allies: Titanicus Traitoris" id="o7131zt" primary="false" entryId="24fa-8caf-cd68-31fb" /><category name="Allies: Unaligned Forces" id="o7u4bvf" primary="false" entryId="07d9-ab1c-cf28-0939" /><category name="Reference" id="o7g3qsb" primary="false" entryId="eef1-be80-500a-edfc" /><category name="Allies: Adeptus Titanicus" id="o8ica9m" primary="false" entryId="9988-6b5e-660e-d973" /><category name="Illegal Units" id="o881cch" primary="false" entryId="(Illegal Units)" /></categories></force></forces></roster>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+60
View File
@@ -0,0 +1,60 @@
// Defaults for the roster transforms. These started life as module constants
// in the Python scripts this was ported from; the values are the same, but the app
// passes them in so they can be edited without touching the transform code.
export const defaultConfig = {
// --- mergeDuplicateUnits -------------------------------------------------
// Two force-level selections are the same unit when all of these attributes
// agree. ``entryId`` identifies the catalogue entry plus the option path
// that produced it, so it is the real key; ``name`` and ``type`` are there
// so a unit the user renamed by hand stays a unit of its own.
unitMatchAttrs: ["entryId", "name", "type"],
// Selections carrying one of these categories are roster bookkeeping
// (battle size, detachment, ...), not datasheets. Leave them alone.
skipCategories: ["Configuration"],
// --- removeLeaderAbilities ----------------------------------------------
// Abilities to strip, matched against the ``name`` attribute (exactly,
// case-sensitively). Both the Abilities profile and the same-named rule go.
abilitiesToStrip: ["Leader", "Support"],
// --- convertChoiceAbilities ---------------------------------------------
// Abilities to split into per-option profiles, matched against the
// profile's ``name`` attribute.
abilitiesToConvert: ["Canticles of the Omnissiah", "Battle Protocols", "Icon of War"],
};
// Which transforms run by default when a roster is loaded.
export const defaultToggles = {
mergeDuplicateUnits: true,
removeLeaderAbilities: true,
convertChoiceAbilities: true,
};
// typeName BattleScribe files datasheet abilities under. Only profiles of
// this type are matched, so a *weapon* that happened to be called "Support"
// stays, and a rule that happens to share an ability's name is still removed
// deliberately (the strip wants both halves).
export const ABILITY_TYPE_NAME = "Abilities";
// Name of the characteristic holding the rules text.
export const DESCRIPTION_CHARACTERISTIC = "Description";
// Child elements of a <selection> that get merged, in the order BattleScribe
// writes them. The order only matters when a container is missing from the
// unit we keep and has to be created.
export const CONTAINER_ORDER = [
"rules",
"profiles",
"selections",
"costs",
"categories",
];
// Containers that are removed once a strip empties them. A container that was
// *already* empty is left as it was.
export const PRUNABLE_CONTAINERS = ["profiles", "rules"];
+288
View File
@@ -0,0 +1,288 @@
// Split "choose one each turn" abilities into per-option profiles. Ported from
// convert_choice_abilities.py; see README.md on the fixtures.
//
// Abilities such as Belisarius Cawl's *Canticles of the Omnissiah* arrive from
// the catalogue as a single ``Abilities`` profile whose Description crams the
// intro sentence and every selectable option into one blob of text. Official
// datasheets instead print the options as their own titled block in the weapons
// column, one row per option.
//
// This rewrites the roster to match:
//
// * the original ``Abilities`` profile is kept, trimmed to the intro
// paragraph (the "select one of the following" rule), and
// * each option becomes its own profile under a new profile type named after
// the ability, in capitals, so the renderer groups them into a single table
// titled the way the datasheets title it.
//
// Running it twice is a no-op: a converted ability has only an intro paragraph
// left, so there is nothing further to split.
import { ABILITY_TYPE_NAME, DESCRIPTION_CHARACTERISTIC } from "./config.js";
import { childElement, createElement, descendants, makeId } from "./dom.js";
// Whitespace that shows up in these files, including the non-breaking spaces
// that GW's text is littered with.
const WS = " \t ";
// An option paragraph looks like "Some Name: rules text...". A label longer
// than this is assumed to be prose that merely contains a colon, not an option.
const MAX_OPTION_NAME_LEN = 60;
// Some abilities list their options as a bullet list inside a single paragraph
// instead of one paragraph per option (Battle Protocols does, Canticles of the
// Omnissiah does not). A line opening with one of these markers followed by a
// "Label:" starts an option there too.
const BULLET_CHARS = "-–—•■";
const PARAGRAPH_SPLIT_RE = new RegExp(`\n[${WS}]*\n`);
const OPTION_RE = new RegExp(
`^([^\n:]{1,${MAX_OPTION_NAME_LEN}}):[${WS}]*(.+)$`,
"s",
);
const BULLET_OPTION_RE = new RegExp(
`^[${WS}]*[${BULLET_CHARS}][${WS}]+[^\n:]{1,${MAX_OPTION_NAME_LEN}}:`,
"gm",
);
// ``^^Keyword^^``, tolerating the ``**`` GW likes to nest inside (or across)
// the carets; ``[^^]`` keeps a match from swallowing the next keyword.
const KEYWORD_RE = /\^\^([^^]+?)\^\^/g;
const LEFTOVER_MARKER_RE = /\*\*|\^\^/g;
/** Python's `str.strip(chars)`: trim any of `chars` from both ends. */
function trimChars(text, chars) {
let start = 0;
let end = text.length;
while (start < end && chars.includes(text[start])) start++;
while (end > start && chars.includes(text[end - 1])) end--;
return text.slice(start, end);
}
/** Python's `str.lstrip(chars)`. */
function trimStartChars(text, chars) {
let start = 0;
while (start < text.length && chars.includes(text[start])) start++;
return text.slice(start);
}
/** Trim whitespace and GW's markdown/keyword emphasis from an option label. */
const cleanOptionName = (name) =>
trimChars(trimChars(trimChars(name, `${WS}\n`), "*^"), WS);
/**
* Resolve GW's ^^keyword^^ / **emphasis** markers into plain text.
*
* Keywords are upper-cased, emphasis is simply dropped, and any marker left
* over from mis-nested pairs goes too - a stray ``**`` is punctuation the
* reader never wanted to see either way.
*
* Only the option *name* goes through this. It ends up as a profile ``name``
* attribute, which the renderer prints as plain text, so a marker left in there
* would show up as literal punctuation. The option text keeps its markers:
* ``makeKeywordsBold`` in 10th/Roster.jsx formats them into the bold uppercase
* keywords and bold emphasis the printed card uses, which is more than
* upper-casing here could manage. The intro profile keeps its original
* typeName, renders through the normal path, and was never touched either way.
*
* The Python original also stripped the option text, so this is the one place
* the port deliberately differs from it; transforms.test.js resolves markers on
* both sides when it compares against the fixtures.
*
* Unlike the Python original this needs no entity bookkeeping: the DOM hands us
* resolved text, so there is no ``&quot;`` here that upper-casing could turn
* into an entity no parser would recognise.
*/
export const stripMarkup = (text) =>
text
.replace(KEYWORD_RE, (_, keyword) =>
keyword.replaceAll("*", "").toUpperCase(),
)
.replace(LEFTOVER_MARKER_RE, "");
/**
* Cut a paragraph before each bulleted ``- Label:`` line, dropping the bullet.
*
* Anything ahead of the first such line stays as one block, and a bulleted
* option runs until the next one, so multi-line option text stays together.
* Paragraphs without bulleted options come back unchanged.
*/
function splitBullets(paragraph) {
const starts = Array.from(
paragraph.matchAll(BULLET_OPTION_RE),
(m) => m.index,
);
if (starts.length === 0) return [paragraph];
const bounds = [0, ...starts];
const ends = [...starts, paragraph.length];
return bounds.map((start, index) => {
const block = paragraph.slice(start, ends[index]);
// Everything but the lead-in opens with a bullet marker. Keyed on the
// index rather than the offset, so a paragraph whose very first
// character is a bullet still gets it stripped.
if (index === 0) return block;
return trimStartChars(
trimStartChars(trimStartChars(block, `${WS}\n`), BULLET_CHARS),
WS,
);
});
}
/**
* Split an ability description into `{ intro, options }`.
*
* Paragraphs are separated by blank lines, or by bulleted option lines within a
* paragraph. The first paragraph is the intro. Each later paragraph starting
* ``Label: text`` opens a new option; anything else is treated as a
* continuation of whatever came before.
*/
export function splitDescription(text) {
const paragraphs = text
.split(PARAGRAPH_SPLIT_RE)
.flatMap(splitBullets)
.filter((block) => trimChars(block, `${WS}\n`) !== "");
const introParts = [];
const options = [];
for (const paragraph of paragraphs) {
const match = OPTION_RE.exec(trimChars(paragraph, "\n"));
if (match) {
const name = cleanOptionName(match[1]);
if (name) {
options.push({ name, parts: [trimChars(match[2], `${WS}\n`)] });
continue;
}
}
// Not an option header: continuation of the previous block.
if (options.length > 0) {
options.at(-1).parts.push(trimChars(paragraph, "\n"));
} else {
introParts.push(trimChars(paragraph, "\n"));
}
}
return {
intro: trimChars(introParts.join("\n\n"), `${WS}\n`),
options: options.map(({ name, parts }) => ({
name,
text: parts.join("\n\n"),
})),
};
}
/** Build one `<profile>` with a single Description characteristic. */
function buildProfile(
doc,
template,
{ id, name, typeId, typeName, charTypeId, description },
) {
const profile = createElement(doc, "profile");
profile.setAttribute("id", id);
profile.setAttribute("name", name);
profile.setAttribute("hidden", "false");
profile.setAttribute("typeId", typeId);
profile.setAttribute("typeName", typeName);
// Preserve attributes we do not manage ourselves (page, publicationId, ...).
const managed = new Set(["id", "name", "hidden", "typeId", "typeName"]);
for (const attr of Array.from(template.attributes)) {
if (!managed.has(attr.name)) profile.setAttribute(attr.name, attr.value);
}
const characteristics = createElement(doc, "characteristics");
const characteristic = createElement(doc, "characteristic");
characteristic.setAttribute("name", DESCRIPTION_CHARACTERISTIC);
characteristic.setAttribute("typeId", charTypeId);
characteristic.textContent = description;
characteristics.appendChild(characteristic);
profile.appendChild(characteristics);
return profile;
}
/**
* Split the configured choice abilities in `doc`, in place.
*
* Returns a report of `[{ ability, options: [names] }]`; an empty report means
* nothing matched (already converted, or no such ability in the roster).
*/
export function convertChoiceAbilities(doc, config) {
const wanted = new Set(config.abilitiesToConvert);
const root = doc.documentElement;
if (!root) return [];
const report = [];
for (const profile of descendants(root, "profile")) {
const ability = profile.getAttribute("name");
if (
profile.getAttribute("typeName") !== ABILITY_TYPE_NAME ||
!wanted.has(ability)
) {
continue;
}
const characteristics = childElement(profile, "characteristics");
const characteristic = characteristics
? childElement(characteristics, "characteristic")
: null;
if (
!characteristic ||
characteristic.getAttribute("name") !== DESCRIPTION_CHARACTERISTIC
) {
// Not the shape this transform knows how to split; leave it be rather
// than throw, so one odd profile cannot stop the roster rendering.
continue;
}
const { intro, options } = splitDescription(
characteristic.textContent ?? "",
);
if (options.length === 0) continue; // Already converted, or a plain ability.
// The group's title becomes the generated profileType's typeName, which the
// renderer prints verbatim as the table heading - and the datasheets set
// that heading in capitals.
const groupTitle = ability.toUpperCase();
const typeId = makeId("profileType", groupTitle);
const charTypeId = makeId(
"characteristicType",
groupTitle,
DESCRIPTION_CHARACTERISTIC,
);
const replacements = [];
if (intro) {
// Keep the original ability profile - and its id - as the pointer text.
replacements.push(
buildProfile(doc, profile, {
id: profile.getAttribute("id"),
name: ability,
typeId: profile.getAttribute("typeId"),
typeName: profile.getAttribute("typeName"),
charTypeId: characteristic.getAttribute("typeId"),
description: intro,
}),
);
}
for (const option of options) {
replacements.push(
buildProfile(doc, profile, {
id: makeId("profile", profile.getAttribute("id"), option.name),
name: stripMarkup(option.name),
typeId,
typeName: groupTitle,
charTypeId,
description: option.text,
}),
);
}
profile.replaceWith(...replacements);
report.push({ ability, options: options.map((option) => option.name) });
}
return report;
}
+123
View File
@@ -0,0 +1,123 @@
// Small DOM helpers shared by the transforms.
//
// The Python scripts these were ported from parse the roster into a tree of
// text offsets and edit the file as text, so that everything they do not touch
// survives byte for byte. In the browser there is nothing to preserve: the
// document goes straight into the roster parser and is never written back out,
// so the transforms mutate the DOM directly and all of that machinery
// (Node/Edit/apply_edits/attr escaping/XML re-validation) is gone.
// Roster files declare a default namespace (the BattleScribe roster schema), so
// everything here matches on `localName` and creates elements in the document's
// own namespace. Matching on `tagName` would still work for these files, but
// only because nothing in them carries a prefix.
/** Element children of `node`, as a real array (safe to mutate while iterating). */
export const elementChildren = (node) => Array.from(node.children);
/** The first element child named `name`, or null. */
export const childElement = (node, name) =>
elementChildren(node).find((child) => child.localName === name) ?? null;
/** Every descendant element named `name`, in document order. */
export const descendants = (node, name) =>
Array.from(node.getElementsByTagNameNS("*", name));
/** Create an element in the same namespace as the document's root. */
export const createElement = (doc, name) =>
doc.createElementNS(doc.documentElement.namespaceURI, name);
/**
* Remove `node`, taking the whitespace that only separated it from its sibling.
*
* Rosters come out of BattleScribe on a single line, so usually there is no
* whitespace to take; this only keeps the result tidy for a roster that has
* been pretty-printed at some point.
*/
export function removeElement(node) {
const previous = node.previousSibling;
const TEXT_NODE = 3;
if (
previous &&
previous.nodeType === TEXT_NODE &&
previous.data.trim() === ""
) {
previous.remove();
}
node.remove();
}
/**
* Insert `child` into `parent`, keeping `order` among the containers listed in
* it. A container not mentioned in `order` is appended.
*/
export function insertOrdered(parent, child, order) {
const rank = order.indexOf(child.localName);
if (rank !== -1) {
const before = elementChildren(parent).find((existing) => {
const existingRank = order.indexOf(existing.localName);
return existingRank !== -1 && existingRank > rank;
});
if (before) {
parent.insertBefore(child, before);
return;
}
}
parent.appendChild(child);
}
/**
* Joins the parts of a key or a generated id, so that ("ab", "c") and
* ("a", "bc") cannot collide.
*
* ASCII unit separator: a control character cannot occur in a unit name, an
* ability name or an id, so nothing in a roster can contain it and no pair of
* different parts can ever join to the same string.
*/
export const KEY_SEPARATOR = String.fromCharCode(31);
/**
* A stable id derived from `parts`, shaped like BattleScribe's
* ``xxxx-xxxx-xxxx-xxxx``.
*
* Deterministic so that re-running against an updated roster produces the same
* ids for the same ability rather than churning them, which is what lets the
* merge recognise two generated profiles as the same catalogue object. This is
* a plain string hash rather than the SHA-1 the Python script uses: the ids only
* have to be unique and stable within one document, and `crypto.subtle` is
* async, which would push a promise through every call site for no gain.
*/
export function makeId(...parts) {
const input = parts.join(KEY_SEPARATOR);
// Two independent FNV-1a passes, giving 64 bits of output.
let h1 = 0x811c9dc5;
let h2 = 0x01000193;
for (let i = 0; i < input.length; i++) {
const code = input.charCodeAt(i);
h1 = Math.imul(h1 ^ code, 0x01000193) >>> 0;
h2 = Math.imul(h2 ^ code, 0x811c9dc5) >>> 0;
}
const hex =
h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0");
return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
}
/**
* The datasheet an element sits on, for the run report: the outermost
* `<selection>` above it, since that is the force-level unit.
*/
export function enclosingUnit(node) {
let unit = null;
for (
let current = node.parentElement;
current;
current = current.parentElement
) {
if (current.localName === "selection") {
unit = current.getAttribute("name") ?? "?";
} else if (current.localName === "force" && !unit) {
return current.getAttribute("name") ?? "Force";
}
}
return unit ?? "Roster";
}
+46
View File
@@ -0,0 +1,46 @@
// Roster transforms, run between parsing the .ros file and building the roster.
//
// The point of these is to print *generic* datasheets - every option a unit
// could take, the way the official cards read - rather than a record of the one
// list you happen to be playing.
import { defaultConfig, defaultToggles } from "./config.js";
import { convertChoiceAbilities } from "./convertChoiceAbilities.js";
import { mergeDuplicateUnits } from "./mergeDuplicateUnits.js";
import { removeLeaderAbilities } from "./removeLeaderAbilities.js";
export { defaultConfig, defaultToggles } from "./config.js";
export { convertChoiceAbilities } from "./convertChoiceAbilities.js";
export { mergeDuplicateUnits } from "./mergeDuplicateUnits.js";
export { removeLeaderAbilities } from "./removeLeaderAbilities.js";
/**
* Apply the enabled transforms to `doc`, in place.
*
* Order matters. The merge runs first because it folds duplicate copies of a
* unit together, deduplicating their shared ability profiles on the way; doing
* it before the splitter means the splitter sees one copy of each ability
* instead of several. The strip then removes what neither of the others needs
* to look at, leaving the least text for the splitter to walk.
*
* Returns a report per transform, for the console and the UI.
*/
export function applyTransforms(
doc,
toggles = defaultToggles,
config = defaultConfig,
) {
const report = {};
if (toggles.mergeDuplicateUnits) {
report.mergeDuplicateUnits = mergeDuplicateUnits(doc, config);
}
if (toggles.removeLeaderAbilities) {
report.removeLeaderAbilities = removeLeaderAbilities(doc, config);
}
if (toggles.convertChoiceAbilities) {
report.convertChoiceAbilities = convertChoiceAbilities(doc, config);
}
return report;
}
+111
View File
@@ -0,0 +1,111 @@
// The transforms have to leave a document the roster parser still understands,
// and the whole point is that what comes out the far end reads like the official
// card. These tests go through the real parser rather than inspecting the DOM.
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { Create40kRoster11th } from "../roster40k-11th.js";
import { applyTransforms } from "./index.js";
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__");
const GAME_TYPE = "Warhammer 40,000 11th Edition";
/** Parse a fixture, optionally transforming it, and build the roster. */
function build(name, { transform }) {
const xml = readFileSync(join(FIXTURES, `${name}.ros`), "utf8");
const doc = new DOMParser().parseFromString(xml, "text/xml");
if (transform) applyTransforms(doc);
return Create40kRoster11th(doc, GAME_TYPE);
}
const units = (roster) => roster.forces.flatMap((force) => force.units);
const unitNamed = (roster, name) =>
units(roster).find((unit) => unit.name.includes(name));
describe("the transformed roster still parses", () => {
it.each(["bellisarius-cawl", "datasmith", "rangers", "skatros"])(
"builds a roster from %s",
(example) => {
const roster = build(example, { transform: true });
expect(roster).toBeTruthy();
expect(roster.forces.length).toBeGreaterThan(0);
expect(units(roster).length).toBeGreaterThan(0);
},
);
});
describe("merging duplicates", () => {
it("turns two Skatros cards into one carrying both weapons", () => {
const before = build("skatros", { transform: false });
const after = build("skatros", { transform: true });
const skatrosBefore = units(before).filter((unit) =>
unit.name.includes("Skatros"),
);
const skatrosAfter = units(after).filter((unit) =>
unit.name.includes("Skatros"),
);
expect(skatrosBefore.length).toBe(2);
expect(skatrosAfter.length).toBe(1);
// The single card now lists the weapon each copy was taken for.
const weapons = [
...skatrosAfter[0].rangedWeapons,
...skatrosAfter[0].meleeWeapons,
]
.map((weapon) => weapon.name)
.join(" | ");
expect(weapons).toMatch(/jezzail/i);
expect(weapons).toMatch(/arquebus/i);
});
it("brings an option's own ability along with the model that carries it", () => {
const after = build("rangers", { transform: true });
const rangers = unitNamed(after, "Ranger");
const abilityNames = Object.values(rangers.abilities).flatMap((group) =>
Array.from(group.keys()),
);
expect(abilityNames.join(" | ")).toMatch(/data-tether/i);
});
});
describe("stripping Leader/Support", () => {
// The Datasmith is a Support character, not a Leader; the transform strips
// both, and this is the example that has one.
it("leaves no attachment ability on the card", () => {
const before = build("datasmith", { transform: false });
const after = build("datasmith", { transform: true });
const abilitiesOf = (roster) =>
units(roster)
.flatMap((unit) => Object.values(unit.abilities))
.flatMap((group) => Array.from(group.keys()))
.join(" | ");
expect(abilitiesOf(before)).toMatch(/^Support \|/);
expect(abilitiesOf(after)).not.toMatch(/Leader|Support/);
});
});
describe("splitting choice abilities", () => {
it("gives each Canticle its own row under one titled group", () => {
const after = build("bellisarius-cawl", { transform: true });
const cawl = unitNamed(after, "Cawl");
const group = cawl.abilities["CANTICLES OF THE OMNISSIAH"];
expect(group).toBeTruthy();
expect(group.size).toBeGreaterThan(1);
// The option text keeps GW's ^^keyword^^ / **emphasis** markers for the
// renderer to format; only the row titles are plain text, since those
// render as-is.
expect([...group.values()].join("\n")).toMatch(/\^\^/);
for (const name of group.keys()) {
expect(name).not.toMatch(/\^\^|\*\*/);
}
});
});
+208
View File
@@ -0,0 +1,208 @@
// Consolidate duplicate units into a single unit. Ported from
// python/merge_duplicate_units.py.
//
// Mutually exclusive wargear forces you to take the same datasheet twice: one
// Sydonian Skatros with a radium jezzail, another with a transuranic arquebus;
// one squad of Skitarii Rangers with an omnispex, another with a data-tether.
// Printing that gives you two near-identical cards that differ in one row.
//
// This folds those copies together. Duplicate units - same ``entryId``, same
// name, same type, in the same force - are merged into the first copy, and every
// selection the other copies contribute that the first one lacks is grafted on.
// The merge is recursive, so it works at whatever depth the models differ:
//
// * Skatros - the copies are single models, so the extra *weapon* selection is
// what gets added.
// * Rangers - the copies differ by a whole model (``Skitarii Ranger w/
// data-tether``), which is added with its weapons *and* its ``Enhanced
// data-tether`` ability underneath it, because the ability hangs off the
// model that brings it. Nothing special is needed for the ability: grafting
// the model on carries its whole subtree along.
//
// Profiles, rules and categories are merged the same way, so an option that
// contributes a unit-level ability or keyword brings it with it.
//
// Points are *not* summed. The consolidated unit keeps the highest cost of the
// copies it absorbed, since the card now shows the most expensive loadout, and
// the roster total in ``<roster><costs>`` is left alone - it still reports what
// the army actually costs.
import { CONTAINER_ORDER } from "./config.js";
import {
KEY_SEPARATOR,
childElement,
descendants,
elementChildren,
insertOrdered,
removeElement,
} from "./dom.js";
// Attribute a merged pair keeps the larger of. Two copies of a squad can list
// different model counts for the same entry; the consolidated card shows the
// largest, the same way it shows the largest cost.
const COUNT_ATTR = "number";
/**
* Identity of a child element within its container.
*
* Selections are keyed on ``entryId`` - the catalogue entry plus option path -
* because their ``id`` is per-instance and differs between two copies of the
* same squad. Profiles, rules and categories are shared catalogue objects, so
* their ``id`` matches across copies. Costs are keyed by name/typeId alone.
* Anything missing its key falls back to the name, which is all a renderer would
* tell apart anyway.
*/
function childKey(node) {
let key = null;
if (node.localName === "selection") {
key = node.getAttribute("entryId");
} else if (node.localName !== "cost") {
key = node.getAttribute("id");
}
return [
node.localName,
key,
node.getAttribute("name"),
node.getAttribute("typeId"),
node.getAttribute("typeName"),
].join(KEY_SEPARATOR);
}
const describe = (node) => {
const name = node.getAttribute("name") ?? `<${node.localName}>`;
const typeName = node.getAttribute("typeName");
return typeName ? `${name} (${typeName})` : name;
};
const isConfiguration = (unit, skipCategories) => {
const categories = childElement(unit, "categories");
if (!categories) return false;
return elementChildren(categories).some((category) =>
skipCategories.has(category.getAttribute("name")),
);
};
const unitKey = (unit, matchAttrs) =>
matchAttrs.map((attr) => unit.getAttribute(attr)).join(KEY_SEPARATOR);
/** Keep the larger of two numeric attributes on the unit we are keeping. */
function raiseNumericAttr(target, donor, attr) {
if (!target.hasAttribute(attr)) return;
const keep = Number(target.getAttribute(attr));
const other = donor.hasAttribute(attr)
? Number(donor.getAttribute(attr))
: keep;
if (Number.isNaN(keep) || Number.isNaN(other)) return;
if (other > keep) target.setAttribute(attr, donor.getAttribute(attr));
}
/**
* Log what the merge contributed, for the run report.
*
* Costs are skipped: a points line is not an option the reader was missing.
*/
function noteAdded(added, path, container, nodes) {
if (container === "costs") return;
for (const node of nodes) added.push([...path, describe(node)].join(" > "));
}
/** Fold everything `donor` has and `target` lacks into `target`. */
function mergeSelection(target, donor, path, added) {
raiseNumericAttr(target, donor, COUNT_ATTR);
for (const container of CONTAINER_ORDER) {
const donorContainer = childElement(donor, container);
if (!donorContainer || donorContainer.children.length === 0) continue;
const targetContainer = childElement(target, container);
if (!targetContainer) {
// The whole container is new - graft it on, keeping CONTAINER_ORDER.
insertOrdered(target, donorContainer.cloneNode(true), CONTAINER_ORDER);
noteAdded(added, path, container, elementChildren(donorContainer));
continue;
}
// Pair each donor child with an unclaimed target child of the same
// identity; a container may legitimately hold two entries with the same
// key, so matches are consumed rather than looked up.
const unclaimed = elementChildren(targetContainer);
const additions = [];
for (const donorChild of elementChildren(donorContainer)) {
const key = childKey(donorChild);
const index = unclaimed.findIndex((child) => childKey(child) === key);
if (index === -1) {
additions.push(donorChild);
continue;
}
const [match] = unclaimed.splice(index, 1);
if (container === "selections") {
mergeSelection(match, donorChild, [...path, describe(match)], added);
} else if (container === "costs") {
// Same cost type on both copies: show the pricier loadout.
raiseNumericAttr(match, donorChild, "value");
}
}
if (additions.length > 0) {
for (const addition of additions) {
targetContainer.appendChild(addition.cloneNode(true));
}
noteAdded(added, path, container, additions);
}
}
}
/**
* Merge the duplicate units in `doc`, in place.
*
* Returns a report of `[{ unit, copies, additions }]`, where `copies` counts the
* copies the consolidated unit now stands for (2 means one was absorbed). An
* empty report means there were no duplicates.
*
* Unlike the Python original this merges every duplicate in a single pass: that
* script re-parses the file after each merge because applying a text edit
* invalidates the offsets it works from, which a DOM does not have.
*/
export function mergeDuplicateUnits(doc, config) {
const root = doc.documentElement;
if (!root) return [];
const skipCategories = new Set(config.skipCategories);
const matchAttrs = config.unitMatchAttrs;
const report = [];
for (const force of descendants(root, "force")) {
const selections = childElement(force, "selections");
if (!selections) continue;
const groups = new Map();
for (const unit of elementChildren(selections)) {
if (
unit.localName !== "selection" ||
isConfiguration(unit, skipCategories)
) {
continue;
}
const key = unitKey(unit, matchAttrs);
groups.set(key, [...(groups.get(key) ?? []), unit]);
}
for (const units of groups.values()) {
if (units.length < 2) continue;
const [target, ...donors] = units;
const additions = [];
for (const donor of donors) {
mergeSelection(target, donor, [], additions);
removeElement(donor);
}
report.push({
unit: target.getAttribute("name") ?? "?",
copies: units.length,
additions,
});
}
}
return report;
}
+127
View File
@@ -0,0 +1,127 @@
// Strip Leader/Support abilities from a roster. Ported from
// remove_leader_abilities.py; see README.md on the fixtures.
//
// A printed datasheet is a reference card you hold during a game, and the
// Leader / Support abilities are not something you look up mid-turn: they only
// say which units a character may attach to. Once the army is built that
// decision is already made, and the block is long enough to push the rules you
// *do* need off the card.
//
// Each ability arrives from the catalogue in two places - an ``Abilities``
// profile holding the "This model can be attached to the following units"
// text, and a same-named ``rule`` that puts *Leader* on the datasheet's RULES
// line and its full text in the rules appendix - so both are taken out.
// Containers left empty by the removal (``<profiles>``, ``<rules>``) are
// dropped too, rather than left behind as empty elements.
//
// Points are deliberately left alone. Removing a ``<cost>`` only removes it
// from the roster; the renderer prints the title bar unconditionally, so a
// stripped unit would read ``0pts`` rather than nothing - worse than just
// leaving the cost in.
import { ABILITY_TYPE_NAME, PRUNABLE_CONTAINERS } from "./config.js";
import { elementChildren, enclosingUnit, removeElement } from "./dom.js";
/** Should this element be stripped in its own right? */
const isTarget = (node, strip) => {
const name = node.getAttribute("name");
if (node.localName === "rule") return strip.has(name);
if (node.localName === "profile") {
return (
node.getAttribute("typeName") === ABILITY_TYPE_NAME && strip.has(name)
);
}
return false;
};
const describe = (node) =>
`${node.localName === "profile" ? "ability" : "rule"}: ${
node.getAttribute("name") ?? "?"
}`;
/**
* Flag `node` and its descendants for removal, bottom-up, returning whether
* `node` itself is going.
*
* A container goes when everything it held is going: a unit whose only ability
* was *Leader* should not be left with an empty ``<profiles/>``.
*/
function markDoomed(node, strip, doomed) {
if (isTarget(node, strip)) {
doomed.add(node);
return true;
}
// Not `.every()` - every child has to be visited, not just until one stays.
const children = elementChildren(node);
const gone = children.map((child) => markDoomed(child, strip, doomed));
if (
PRUNABLE_CONTAINERS.includes(node.localName) &&
gone.length > 0 &&
gone.every(Boolean)
) {
doomed.add(node);
return true;
}
return false;
}
/** The strip targets inside a doomed subtree, for the run report. */
function* iterTargets(node, strip) {
if (isTarget(node, strip)) {
yield node;
return;
}
for (const child of elementChildren(node)) yield* iterTargets(child, strip);
}
/**
* The outermost doomed elements, in document order.
*
* Doomed-ness propagates downwards - a container is only doomed when every
* child is - so removing these removes everything, and each one is a single
* entry in the report rather than one per profile inside it.
*/
function collectOutermost(node, doomed, found) {
if (doomed.has(node)) {
found.push(node);
return;
}
for (const child of elementChildren(node)) {
collectOutermost(child, doomed, found);
}
}
/**
* Remove the Leader/Support abilities from `doc`, in place.
*
* Returns a report of `{ unit: [what was removed] }`; an empty report means
* there was nothing to strip (already stripped, or no such abilities).
*/
export function removeLeaderAbilities(doc, config) {
const strip = new Set(config.abilitiesToStrip);
const root = doc.documentElement;
if (!root) return {};
const doomed = new Set();
markDoomed(root, strip, doomed);
const outermost = [];
collectOutermost(root, doomed, outermost);
// The report is built before anything is removed, so `enclosingUnit` can
// still walk up to the datasheet the element sat on.
const report = {};
for (const node of outermost) {
const unit = enclosingUnit(node);
report[unit] = [
...(report[unit] ?? []),
...Array.from(iterTargets(node, strip), describe),
];
}
for (const node of outermost) removeElement(node);
return report;
}
+167
View File
@@ -0,0 +1,167 @@
// Parity tests: the transforms must produce the same roster as the Python
// scripts they were ported from, which are the version that had actually been
// used to print cards.
//
// The `.merge`/`.strip`/`.convert`/`.all` fixtures are that Python output,
// checked in - so this suite compares the code against an implementation that
// is genuinely independent of it, without needing a Python interpreter. See
// README.md and scripts/update-fixtures.mjs before regenerating any of them.
//
// Comparison is structural rather than textual. The Python scripts edit the
// file as text and preserve its byte-for-byte formatting; these transforms build
// DOM nodes, so attribute order and whitespace legitimately differ. What has to
// match is the tree: elements, nesting, attribute values, and text.
import { readFileSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { defaultConfig } from "./config.js";
import {
convertChoiceAbilities,
splitDescription,
stripMarkup,
} from "./convertChoiceAbilities.js";
import { applyTransforms } from "./index.js";
import { mergeDuplicateUnits } from "./mergeDuplicateUnits.js";
import { removeLeaderAbilities } from "./removeLeaderAbilities.js";
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "__fixtures__");
const examples = readdirSync(FIXTURES)
.filter((name) => name.endsWith(".ros") && name.split(".").length === 2)
.map((name) => name.replace(/\.ros$/, ""));
const parse = (xml) => {
const doc = new DOMParser().parseFromString(xml, "text/xml");
const failure = doc.querySelector("parsererror");
if (failure) throw new Error(`fixture did not parse: ${failure.textContent}`);
return doc;
};
const load = (name) =>
parse(readFileSync(join(FIXTURES, `${name}.ros`), "utf8"));
/**
* A stable text rendering of a document's tree, for comparison.
*
* `id` and `typeId` are replaced with a token per distinct value, in
* first-appearance order: the splitter mints ids for the profiles it generates,
* and the JS port hashes them differently from the Python original. What
* matters is that ids are shared and distinct in the same *pattern*, which the
* tokens preserve, not that the two implementations agree on a hash function.
* Every other attribute - including `entryId` and `publicationId`, which are
* never generated - has to match exactly.
*
* Text goes through `stripMarkup` for the same reason. Python resolved GW's
* ^^keyword^^ / **emphasis** markers in the option text it split out, because
* the renderer printed that text verbatim; the renderer now formats the markers
* itself, so the transform leaves them in place. Resolving them on both sides
* keeps the fixtures an oracle for everything else about the split.
*/
function canonicalize(doc) {
const tokens = new Map();
const token = (value) => {
if (!tokens.has(value)) tokens.set(value, `#${tokens.size}`);
return tokens.get(value);
};
const generated = new Set(["id", "typeId"]);
const lines = [];
const walk = (element, depth) => {
const attributes = Array.from(element.attributes)
.map((attr) => [
attr.name,
generated.has(attr.name) ? token(attr.value) : attr.value,
])
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([name, value]) => `${name}=${JSON.stringify(value)}`)
.join(" ");
const text = stripMarkup(
Array.from(element.childNodes)
.filter((node) => node.nodeType === 3 /* Text */)
.map((node) => node.data)
.join("")
.trim(),
);
lines.push(
`${" ".repeat(depth)}<${element.localName} ${attributes}>` +
(text ? ` ${JSON.stringify(text)}` : ""),
);
for (const child of element.children) walk(child, depth + 1);
};
walk(doc.documentElement, 0);
return lines.join("\n");
}
const cases = [
["merge", (doc) => mergeDuplicateUnits(doc, defaultConfig)],
["strip", (doc) => removeLeaderAbilities(doc, defaultConfig)],
["convert", (doc) => convertChoiceAbilities(doc, defaultConfig)],
["all", (doc) => applyTransforms(doc)],
];
describe.each(cases)("%s", (label, run) => {
it.each(examples)("matches the python output for %s", (example) => {
const doc = load(example);
run(doc);
expect(canonicalize(doc)).toBe(canonicalize(load(`${example}.${label}`)));
});
it.each(examples)("is a no-op on second run for %s", (example) => {
const doc = load(example);
run(doc);
const once = canonicalize(doc);
run(doc);
expect(canonicalize(doc)).toBe(once);
});
});
describe("splitDescription", () => {
it("keeps a plain ability whole", () => {
const { intro, options } = splitDescription(
"While this model is leading a unit, add 1 to the Objective Control characteristic of models in that unit.",
);
expect(options).toEqual([]);
expect(intro).toMatch(/^While this model/);
});
it("splits blank-line separated options", () => {
const { intro, options } = splitDescription(
"At the start of your Command phase, select one:\n\n" +
"Shroudpsalm: Models in this army have the Stealth ability.\n\n" +
"Chant of the Remorseless Fist: Melee weapons have the [LANCE] ability.",
);
expect(intro).toBe("At the start of your Command phase, select one:");
expect(options.map((option) => option.name)).toEqual([
"Shroudpsalm",
"Chant of the Remorseless Fist",
]);
expect(options[0].text).toBe(
"Models in this army have the Stealth ability.",
);
});
it("splits a bulleted list inside one paragraph", () => {
const { intro, options } = splitDescription(
"Select one protocol:\n" +
"- Protocol of the Eternal Guardian: Models have Feel No Pain 6+.\n" +
"- Protocol of the Sudden Storm: Models have the Assault ability.",
);
expect(intro).toBe("Select one protocol:");
expect(options.map((option) => option.name)).toEqual([
"Protocol of the Eternal Guardian",
"Protocol of the Sudden Storm",
]);
});
it("treats a long prose colon as prose, not an option", () => {
const sentence = `${"a".repeat(70)}: still the same paragraph.`;
const { options } = splitDescription(`Intro paragraph.\n\n${sentence}`);
expect(options).toEqual([]);
});
});
+2 -1
View File
@@ -10,5 +10,6 @@ export default defineConfig({
},
}),
],
base: "/fancyscribe",
// Served at the root of scribe.luxick.de, not from a GitHub Pages subpath.
base: "/",
});
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
// Kept separate from vite.config.js: the tests exercise plain modules, so they
// need a DOM but none of the React/build plugins.
//
export default defineConfig({
test: {
environment: "jsdom",
include: ["src/**/*.test.js"],
},
});