Compare commits

...

10 Commits

Author SHA1 Message Date
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
46 changed files with 4848 additions and 235 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
+32
View File
@@ -0,0 +1,32 @@
# Lint, test and build on every push.
#
# This needs a Gitea Actions runner registered against the instance. If there
# isn't one, delete this file - nothing else depends on it, and `npm test &&
# npm run build` locally covers the same ground.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci
# Asserts the transforms still produce the rosters the Python scripts they
# were ported from produced; see README.md.
- run: npm test
- run: npm run build
-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
+152
View File
@@ -0,0 +1,152 @@
# BrevyScribe
A fork of [FancyScribe](https://github.com/NilsUeter/fancyscribe) that prints
**generic datasheets** rather than a record of one particular army list.
FancyScribe renders a BattleScribe or New Recruit roster as 10th-edition-style
datacards, showing exactly the wargear you picked. That is the right thing for a
list you are about to play, but the wrong thing for a reference card you want to
keep: the official cards show *every* option a unit could take. BrevyScribe
rewrites the roster on the way in so the printed cards read like the official
ones.
Three transforms do the work, all of them toggleable in the UI:
| Transform | What it does |
| --- | --- |
| **Merge duplicates** | Mutually exclusive wargear forces you to take a datasheet twice - one Skatros with a radium jezzail, another with a transuranic arquebus. Copies of the same unit are folded into one card carrying every option. Points are not summed; the card keeps the highest cost of the copies it absorbed. |
| **Drop Leader/Support** | Removes the attachment rules. Once the army is built they say nothing you need mid-game, and they are long enough to push the rules you *do* need off the card. |
| **Split choice abilities** | An ability like *Canticles of the Omnissiah* arrives as one blob of text. This splits it into the intro rule plus one titled row per option, which is how the datasheets print it. |
Everything still runs in the browser. There is no server component, no upload,
no account, and no analytics; rosters are held in `localStorage` and never leave
the machine.
## Running it
```sh
npm install
npm run dev # http://localhost:5173
npm test # the parity suite, see below
npm run build # static files into dist/
```
`npm run lint` reports pre-existing findings inherited from upstream (mostly
`a11y/useButtonType`), so it is deliberately not part of CI. Compare against
`git show upstream/main:<file>` before treating any of them as new.
## Deploying to scribe.luxick.de
The build output is **static files** - no application server, no socket, nothing
for nginx to proxy to. The app does all its work in the browser, which is why
upstream can live on GitHub Pages.
One-time setup on the server:
```sh
sudo mkdir -p /var/www/brevyscribe
sudo chown "$USER" /var/www/brevyscribe
sudo cp deploy/nginx-scribe.luxick.de.conf /etc/nginx/sites-available/scribe.luxick.de
sudo ln -s /etc/nginx/sites-available/scribe.luxick.de /etc/nginx/sites-enabled/
sudo certbot --nginx -d scribe.luxick.de
sudo nginx -t && sudo systemctl reload nginx
```
Then every deploy is one command from a checkout on your own machine - it runs
the tests, builds, and rsyncs `dist/` over. The server needs no Node.
```sh
./deploy/deploy.sh
```
`--delete` is deliberate: asset filenames are content-hashed, so without it old
bundles would pile up forever. There is no state on the server and nothing to
back up; every roster lives in the browser's `localStorage`.
`vite.config.js` sets `base: "/"`, because this is served at a domain root -
upstream sets `/fancyscribe` for GitHub Pages. If you ever serve it from a
subpath, that is the setting to change.
Note that `index.html` loads Noto Sans from Google Fonts, so a page load reaches
out to `fonts.googleapis.com`. If you would rather it did not, drop the
`<link>` tags and self-host the font next to the ones already in `public/fonts/`.
## How the transforms fit in
```
upload/.rosz -> unzip -> DOMParser -> applyTransforms -> Create40kRoster* -> render
^
src/transforms/index.js
```
`src/App.jsx` runs them between parsing the XML and building the roster, so
flipping a toggle rebuilds from the original XML with no re-upload. That is also
why saved rosters hold the raw roster XML rather than the parsed object.
Which abilities get stripped and split is configured in
[`src/transforms/config.js`](src/transforms/config.js) - add to
`abilitiesToStrip` and `abilitiesToConvert` as you meet more of them.
## The test suite
The transforms began as three standalone Python scripts (`merge_duplicate_units`,
`remove_leader_abilities`, `convert_choice_abilities`) that rewrote the `.ros`
file before it was uploaded to FancyScribe. They are gone now, but they are what
the tests measure against.
`src/transforms/__fixtures__/` holds four 11th-edition rosters plus, for each,
the output those scripts produced: `<name>.{merge,strip,convert,all}.ros`. The
suite runs each transform over the input and asserts it produces the same roster.
That is worth more than a snapshot of the current code, because the expected
output came from a **different implementation** that was verified by actually
printing the cards.
That property is easy to destroy and hard to notice: regenerate a fixture from
the JS and the test compares the code against itself, passing no matter what it
does. So `scripts/update-fixtures.mjs` will not overwrite an existing fixture
without `--force`. Use it when you add a new example roster, and read the diff:
```sh
# drop a new roster in src/transforms/__fixtures__/, then
npm run fixtures
```
Comparison is structural rather than textual. The scripts edited the file as text
to keep its formatting byte for byte; these transforms build DOM nodes, so
attribute order and whitespace legitimately differ. Generated `id`/`typeId`
values are compared as *tokens*, so what has to match is that ids are shared and
distinct in the same pattern, not that both implementations hash alike.
`integration.test.js` goes further and pushes the transformed document through
the real roster parser, checking what lands on the card: one Skitarii card
carrying both weapons, the data-tether ability travelling with the model that
brings it, no Support ability left, and each Canticle its own row.
One wrinkle: the tests run under jsdom, which does not implement scoped selectors
the way browsers do. `force.querySelectorAll("force>selections>…")` finds nothing
there, where a browser matches the selector against the whole tree and then keeps
the descendants of `force` - so under jsdom the parser found no units at all. The
two call sites that relied on it now say `:scope>…`, which means the same thing in
a browser and works in both.
## Staying current with upstream
FancyScribe is actively developed - 11th edition support landed recently - and
this fork touches little of it. `upstream` is wired up:
```sh
git fetch upstream
git merge upstream/main
```
Conflicts should be confined to `src/App.jsx`, `index.html`, `vite.config.js` and
`package.json`. `src/transforms/` is entirely new and will never conflict.
## Credit
All the hard parts - the parsing, the card layout, the print CSS - are
[Nils Ueter's](https://github.com/NilsUeter/fancyscribe), with parsing logic
descended in turn from
[PrettyScribe](https://github.com/rweyrauch/PrettyScribe). Upstream ships no
licence file, so treat this fork as a private, personal deployment rather than
something to redistribute.
+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"
+62
View File
@@ -0,0 +1,62 @@
# 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 {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
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;
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> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fancyscribe</title> <title>BrevyScribe</title>
<meta <meta
name="description" 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.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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" href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700;800;900&display=swap"
rel="stylesheet" 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> </head>
<body> <body>
<div id="root"></div> <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, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
@@ -7,7 +7,10 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "biome check ." "lint": "biome check .",
"test": "vitest run",
"test:watch": "vitest",
"fixtures": "node scripts/update-fixtures.mjs"
}, },
"dependencies": { "dependencies": {
"chart.js": "^4.4.8", "chart.js": "^4.4.8",
@@ -17,18 +20,20 @@
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "^19.0.0-beta-e993439-20250328", "babel-plugin-react-compiler": "^19.0.0-beta-e993439-20250328",
"eslint-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", "postcss": "^8.5.3",
"prettier": "^3.5.2", "prettier": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"vite": "^6.2.5", "vite": "^6.2.5",
"@biomejs/biome": "^1.9.4" "vitest": "^4.1.10"
}, },
"prettier": { "prettier": {
"useTabs": true, "useTabs": true,
+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.");
}
+83 -30
View File
@@ -22,6 +22,25 @@ import { ImgEditor } from "./ImgEditor";
import { trySettingLocalStorage } from "../helpers/useLocalStorage"; import { trySettingLocalStorage } from "../helpers/useLocalStorage";
import { ShortSummaryTable } from "./ShortSummaryTable"; import { ShortSummaryTable } from "./ShortSummaryTable";
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 = ({ export const Roster = ({
roster, roster,
onePerPage, onePerPage,
@@ -43,6 +62,7 @@ export const Roster = ({
<ShortSummaryTable <ShortSummaryTable
force={force} force={force}
name={name} name={name}
subtitle={getShortSummarySubtitle(force)}
points={cost.points} points={cost.points}
primaryColor={primaryColor} primaryColor={primaryColor}
/> />
@@ -83,11 +103,11 @@ const Force = ({ force, onePerPage, colorUserChoice }) => {
> >
{unitOrder.map((unit, index) => ( {unitOrder.map((unit, index) => (
<div key={unit.name + index} className="relative"> <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 <button
disabled={index === 0} disabled={index === 0}
type="button" type="button"
className="py-0.5 px-1.5 pl-1" className="px-1.5 py-0.5 pl-1"
onClick={() => onClick={() =>
// move unit up in the order // move unit up in the order
setUnitOrder((prevOrder) => { setUnitOrder((prevOrder) => {
@@ -118,7 +138,7 @@ const Force = ({ force, onePerPage, colorUserChoice }) => {
<button <button
disabled={index === unitOrder.length - 1} disabled={index === unitOrder.length - 1}
type="button" type="button"
className="py-0.5 px-1.5 pl-1" className="px-1.5 py-0.5 pl-1"
onClick={() => onClick={() =>
// move unit down in the order // move unit down in the order
setUnitOrder((prevOrder) => { setUnitOrder((prevOrder) => {
@@ -310,7 +330,7 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
type="checkbox" type="checkbox"
onChange={(e) => setHideModelCount(e.target.checked)} onChange={(e) => setHideModelCount(e.target.checked)}
/> />
<span className="print-display-none">Hide model selection</span> <span className="print-display-none">Hide Unit Composition</span>
</label> </label>
<label <label
className="print-display-none" className="print-display-none"
@@ -428,21 +448,6 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
/> />
))} ))}
</div> </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>
</div> </div>
<div <div
@@ -506,9 +511,6 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
className="print-display-none" className="print-display-none"
accept=".jpg,.png,.jpeg,.gif,.bmp,.tif,.tiff,.webp,.svg,.jfif,.pjpeg,.pjp,.avif,.apng,.ico,.cur,.ani" accept=".jpg,.png,.jpeg,.gif,.bmp,.tif,.tiff,.webp,.svg,.jfif,.pjpeg,.pjp,.avif,.apng,.ico,.cur,.ani"
onChange={(e) => { onChange={(e) => {
posthog?.capture?.("user_uploaded_image", {
unit_name: name,
});
if (e.target.files?.[0]) { if (e.target.files?.[0]) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = ((ev) => { reader.onload = ((ev) => {
@@ -584,6 +586,11 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
forceRules={forceRules} forceRules={forceRules}
/> />
<OtherAbilities abilities={abilities} /> <OtherAbilities abilities={abilities} />
<UnitComposition
hideModelCount={hideModelCount}
modelStats={modelStats}
modelList={modelList}
/>
</table> </table>
<div style={{ flex: "1" }} /> <div style={{ flex: "1" }} />
<table <table
@@ -648,6 +655,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 = { const backgrounds = {
"Imperium - Adeptus Astartes - Dark Angels": imperiumBackground, "Imperium - Adeptus Astartes - Dark Angels": imperiumBackground,
"Imperium - Adeptus Astartes - Space Wolves": imperiumBackground, "Imperium - Adeptus Astartes - Space Wolves": imperiumBackground,
@@ -793,7 +839,7 @@ const ModelStats = ({ modelStat, index, showName, abilities }) => {
wounds = "/"; wounds = "/";
} }
const hasInvul = checkAbilitiesForInvul(abilities, name); const hasInvul = modelStat.invulnerableSave || checkAbilitiesForInvul(abilities, name);
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<div style={{ display: "flex", gap: "1.2rem" }}> <div style={{ display: "flex", gap: "1.2rem" }}>
@@ -848,7 +894,7 @@ const InvulRow = ({ hasInvul }) => {
marginTop: -2, marginTop: -2,
marginLeft: "-6rem", marginLeft: "-6rem",
paddingLeft: "6.4rem", paddingLeft: "6.4rem",
paddingRight: "1.2rem", paddingRight: "0.4rem",
paddingTop: 2, paddingTop: 2,
paddingBottom: 1, paddingBottom: 1,
backgroundColor: "var(--primary-color)", backgroundColor: "var(--primary-color)",
@@ -1034,11 +1080,13 @@ const removeKeywordsSet = new Set([
"Melee Weapon", "Melee Weapon",
"Ranged Weapon", "Ranged Weapon",
"Attacks Dx Weapon", "Attacks Dx Weapon",
"Extra Attacks Weapon" "Extra Attacks Weapon",
]); ]);
const Keywords = ({ keywords }) => { const Keywords = ({ keywords }) => {
const joinedKeywords = [...keywords].filter(key => !removeKeywordsSet.has(key)).join(", ") const joinedKeywords = [...keywords]
.filter((key) => !removeKeywordsSet.has(key))
.join("; ");
return ( return (
<div <div
style={{ style={{
@@ -1058,12 +1106,14 @@ const Keywords = ({ keywords }) => {
gap: 3, gap: 3,
}} }}
> >
<span style={{ fontSize: "1.1em" }}>KEYWORDS:</span> <span className="font-normal" style={{ fontSize: "1em" }}>
KEYWORDS:
</span>
<span <span
className="uppercase"
style={{ style={{
fontSize: joinedKeywords.length > 70 ? ".8em" : "1em", fontSize: joinedKeywords.length > 70 ? ".8em" : "1em",
fontWeight: 800, fontWeight: 800,
marginTop: 1,
}} }}
> >
{joinedKeywords} {joinedKeywords}
@@ -1093,10 +1143,13 @@ const Factions = ({ factions }) => {
minHeight: 54, minHeight: 54,
}} }}
> >
<span style={{ fontSize: ".9em", lineHeight: 1.3 }}> <span
className="font-normal"
style={{ fontSize: ".9em", lineHeight: 1.3 }}
>
FACTION KEYWORDS: FACTION KEYWORDS:
</span> </span>
<span style={{ fontSize: ".9em", fontWeight: 600 }}> <span className="uppercase" style={{ fontSize: ".9em", fontWeight: 600 }}>
{[...factions].join(", ")} {[...factions].join(", ")}
</span> </span>
</div> </div>
+238 -9
View File
@@ -67,7 +67,10 @@ const getNameMatchScore = (statName = "", modelName = "") => {
} }
let prefixMatches = 0; 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++) { for (let i = 0; i < maxPrefixLength; i++) {
if (normalizedStat[i] !== normalizedModel[i]) { if (normalizedStat[i] !== normalizedModel[i]) {
break; break;
@@ -118,8 +121,121 @@ const getUnitTotalOc = (unit) =>
0, 0,
) || 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 [hide, setHide] = useState(false);
const [hideSkewMeter, setHideSkewMeter] = useState(false);
const { units, factionRules, rules, catalog } = force; const { units, factionRules, rules, catalog } = force;
const sortedUnits = units.slice().sort((a, b) => { const sortedUnits = units.slice().sort((a, b) => {
@@ -196,10 +312,36 @@ export const ShortSummaryTable = ({ force, primaryColor, name, points }) => {
(sum, unit) => sum + getUnitTotalWounds(unit), (sum, unit) => sum + getUnitTotalWounds(unit),
0, 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 ( return (
<> <>
<div className="flex justify-end gap-3"> <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 <label
className="print-display-none" className="print-display-none"
style={{ style={{
@@ -230,7 +372,23 @@ export const ShortSummaryTable = ({ force, primaryColor, name, points }) => {
textTransform: "uppercase", textTransform: "uppercase",
}} }}
> >
{subtitle ? (
<span className="flex flex-col leading-none">
<span>{name}</span> <span>{name}</span>
<span
style={{
fontSize: ".55em",
fontWeight: 700,
lineHeight: 1.15,
marginTop: 2,
}}
>
{subtitle}
</span>
</span>
) : (
<span>{name}</span>
)}
<span>{points} pts</span> <span>{points} pts</span>
</div> </div>
<div <div
@@ -349,7 +507,9 @@ export const ShortSummaryTable = ({ force, primaryColor, name, points }) => {
{getUnitTotalOc(unit)} {getUnitTotalOc(unit)}
</div> </div>
<div className="table-cell border border-dotted border-[#9e9fa1] px-4 py-1 text-right"> <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>
<div className="table-cell border border-dotted border-[#9e9fa1] px-4 py-1 text-right"> <div className="table-cell border border-dotted border-[#9e9fa1] px-4 py-1 text-right">
{cost.points} pts {cost.points} pts
@@ -370,23 +530,39 @@ export const ShortSummaryTable = ({ force, primaryColor, name, points }) => {
</div> </div>
<div className="table-cell border border-[var(--primary-color)] px-4 py-1 text-right"> <div className="table-cell border border-[var(--primary-color)] px-4 py-1 text-right">
{/* Sum of all OC values */} {/* Sum of all OC values */}
{sortedUnits.reduce((sum, unit) => sum + getUnitTotalOc(unit), 0)} {sortedUnits.reduce(
(sum, unit) => sum + getUnitTotalOc(unit),
0,
)}
</div> </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) / sortedUnits.reduce(
totalArmyWounds (sum, unit) => sum + unit.cost.points,
0,
) / totalArmyWounds
).toFixed(1) ).toFixed(1)
: "—"} : "—"}
</div> </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 pts
</div> </div>
</div> </div>
</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 <ChartComponent
data={{ data={{
labels: groupedChartDataMovement.map( 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 }) => { const ChartComponent = ({ data, title }) => {
return ( return (
<div className="relative w-full" style={{ height: "150px" }}> <div className="relative w-full" style={{ height: "150px" }}>
+161 -103
View File
@@ -1,7 +1,7 @@
import JSZip from "jszip"; import JSZip from "jszip";
import { Create40kRoster } from "./roster40k"; import { Create40kRoster } from "./roster40k";
import { Create40kRoster10th } from "./roster40k-10th"; 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 Demo0 from "./assets/Demo0.png";
import Demo1 from "./assets/Demo1.png"; import Demo1 from "./assets/Demo1.png";
@@ -9,6 +9,35 @@ import { Roster } from "./9th/Roster";
import { Roster as Roster10th } from "./10th/Roster"; import { Roster as Roster10th } from "./10th/Roster";
import { useLocalStorage } from "./helpers/useLocalStorage"; import { useLocalStorage } from "./helpers/useLocalStorage";
import { parseJSON, stringifyJSON } from "./helpers/json"; 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) => { const throttle = (func, limit) => {
let lastFunc; let lastFunc;
@@ -33,11 +62,27 @@ const throttle = (func, limit) => {
}; };
function App() { 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 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 [error, setError] = useState();
const [roster, setRoster] = 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 [onePerPage, setOnePerPage] = useState(false);
const [primaryColor, setPrimaryColor] = useState("#536766"); const [primaryColor, setPrimaryColor] = useState("#536766");
const [colorUserChoice, setColorUserChoice] = useState(false); const [colorUserChoice, setColorUserChoice] = useState(false);
@@ -70,8 +115,7 @@ function App() {
}; };
reader.onloadend = async () => { reader.onloadend = async () => {
const content = reader.result; const content = reader.result;
const xmldata = await unzip(content); setSource({ xml: await unzip(content), save: true });
parseXML(xmldata, true);
}; };
reader.readAsBinaryString(files[0]); reader.readAsBinaryString(files[0]);
} else { } else {
@@ -92,32 +136,18 @@ function App() {
default: default:
break; break;
} }
// load example if (!example) return;
posthog?.capture?.("user_loaded_example", {
roster_faction: event,
});
const arrayBuffer = await example.arrayBuffer(); const arrayBuffer = await example.arrayBuffer();
// Create a new Blob object from the zip file contents // Create a new Blob object from the zip file contents
const zipBlob = new Blob([arrayBuffer], { type: "application/zip" }); const zipBlob = new Blob([arrayBuffer], { type: "application/zip" });
const xmldata = await unzip(zipBlob); setSource({ xml: await unzip(zipBlob), save: false });
parseXML(xmldata, false, true);
} }
} }
const loadFromLocalStorage = (roster) => {
if (roster.gameType == "Warhammer 40,000 9th Edition") { // Already in the list, so there is nothing to save; the stored XML goes
setRoster(roster); // through the same rebuild as a fresh upload.
setEdition(9); const loadFromLocalStorage = (saved) =>
setError(""); setSource({ xml: saved.xml, save: false });
} 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,
});
};
const unzip = async (file) => { const unzip = async (file) => {
if (file?.charAt && file.charAt(0) !== "P") { if (file?.charAt && file.charAt(0) !== "P") {
@@ -133,54 +163,52 @@ function App() {
} }
}; };
function parseXML(xmldata, addToLocalStorage, isExample = false) { // Rebuild whenever a new roster arrives or a transform is toggled. The
const parser = new DOMParser(); // transforms run here rather than over the uploaded file, so flipping a
const doc = parser.parseFromString(xmldata, "text/xml"); // toggle rebuilds from the original XML and nothing has to be re-uploaded.
if (!doc) return; //
// 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 doc = new DOMParser().parseFromString(source.xml, "text/xml");
const info = doc.querySelector("roster"); const info = doc?.querySelector("roster");
if (!info) return; if (!info) return;
// Determine roster type (game system).
const gameType = info.getAttribute("gameSystemName"); const gameType = info.getAttribute("gameSystemName");
if (!gameType) return; if (!gameType) return;
const parser = PARSERS[gameType];
const rosterName = info.getAttribute("name"); if (!parser) {
if (rosterName) { setError(`No support for game type '${gameType}'.`);
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) {
return; return;
} }
if (addToLocalStorage) {
console.log(roster); const report = applyTransforms(doc, toggles);
posthog?.capture?.("user_uploaded_roster", { const [createRoster, rosterEdition] = parser;
roster_faction: roster.forces[0].catalog, const built = createRoster(doc, gameType);
roster_type: gameType, if (!built || built.forces.length === 0) return;
name: rosterName,
}); setRoster(built);
setRosters(stringifyJSON([roster, ...rostersJSON])); 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(() => { useEffect(() => {
// Check if the browser is Safari, and if so, remove the accept attribute // Check if the browser is Safari, and if so, remove the accept attribute
@@ -227,7 +255,7 @@ function App() {
> >
<div className="header print-display-none"> <div className="header print-display-none">
<a <a
href="/fancyscribe" href="/"
style={{ style={{
color: "#fff", color: "#fff",
fontWeight: 800, fontWeight: 800,
@@ -235,18 +263,18 @@ function App() {
flexDirection: "column", flexDirection: "column",
}} }}
> >
FancyScribe{" "} BrevyScribe{" "}
<span <span
style={{ style={{
fontSize: "0.8rem", fontSize: "0.8rem",
fontWeight: 400, fontWeight: 400,
}} }}
> >
Now with 10th edition support! Generic datasheets, not list-specific ones
</span> </span>
</a> </a>
<div className="subheader"> <div className="subheader">
A fancy way to view your Warhammer 40k BattleScribe rosters A fancy way to print your Warhammer 40k datasheets
</div> </div>
</div> </div>
@@ -342,18 +370,47 @@ function App() {
<button <button
style={{ display: roster ? "" : "none" }} style={{ display: roster ? "" : "none" }}
onClick={() => { onClick={() => window.print()}
posthog?.capture?.("user_printed_roster", {
roster_faction: roster.forces[0].catalog,
roster_type: roster.gameType,
});
window.print();
}}
> >
Print roster Print roster
</button> </button>
</div> </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 <div
className="print-display-none max-w-[95vw]" className="print-display-none max-w-[95vw]"
style={{ display: roster ? "flex" : "none", width: "100%", gap: 16 }} style={{ display: roster ? "flex" : "none", width: "100%", gap: 16 }}
@@ -375,8 +432,8 @@ function App() {
</span> </span>
</label> </label>
{ {
// only show when 10th edition // only show when 10th or 11th edition
edition === 10 && ( (edition === 10 || edition === 11) && (
<label <label
style={{ style={{
display: "flex", display: "flex",
@@ -394,7 +451,7 @@ function App() {
}} }}
className="hide-model-selection" className="hide-model-selection"
/> />
<span className="select-none">Hide all model selections</span> <span className="select-none">Hide all Unit Compositions</span>
</label> </label>
) )
} }
@@ -437,7 +494,7 @@ function App() {
{error} {error}
</div> </div>
{edition === 9 && <Roster roster={roster} onePerPage={onePerPage} />} {edition === 9 && <Roster roster={roster} onePerPage={onePerPage} />}
{edition === 10 && ( {(edition === 10 || edition === 11) && (
<Roster10th <Roster10th
roster={roster} roster={roster}
onePerPage={onePerPage} onePerPage={onePerPage}
@@ -500,7 +557,7 @@ function App() {
> >
<div style={{ padding: "8px 0", fontSize: "1.7rem" }}>About</div> <div style={{ padding: "8px 0", fontSize: "1.7rem" }}>About</div>
<div style={{ fontSize: "1.2rem" }}> <div style={{ fontSize: "1.2rem" }}>
FancyScribe is a website that renders{" "} BrevyScribe renders{" "}
<a <a
href="https://www.battlescribe.net/" href="https://www.battlescribe.net/"
target="_blank" target="_blank"
@@ -509,9 +566,28 @@ function App() {
BattleScribe BattleScribe
</a>{" "} </a>{" "}
or <a href="https://www.newrecruit.eu/">New Recruit</a> roster files or <a href="https://www.newrecruit.eu/">New Recruit</a> roster files
in an opinionated format inspired by the new 10th edition datacards. in an opinionated format inspired by the 10th edition datacards.
Additional inspiration and large parts of the parsing logic come </div>
from the{" "} <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 <a
href="https://rweyrauch.github.io/PrettyScribe" href="https://rweyrauch.github.io/PrettyScribe"
target="_blank" target="_blank"
@@ -521,24 +597,6 @@ function App() {
</a>{" "} </a>{" "}
website. website.
</div> </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" }}> <div style={{ padding: "8px 0", fontSize: "1.7rem" }}>
Output Examples Output Examples
</div> </div>
+20 -13
View File
@@ -101,6 +101,7 @@ export class Model extends BaseNotes {
attacks = ""; attacks = "";
leadership = 7; leadership = 7;
save = ""; save = "";
invulnerableSave = "";
rangedWeapons = []; rangedWeapons = [];
meleeWeapons = []; meleeWeapons = [];
@@ -434,7 +435,7 @@ function ParseForces(doc, roster) {
// Only include the allegiance rules once. // Only include the allegiance rules once.
if (!DuplicateForce(f, roster)) { if (!DuplicateForce(f, roster)) {
const rules = root.querySelectorAll("force>rules>rule"); const rules = root.querySelectorAll(":scope>rules>rule");
for (let rule of rules) { for (let rule of rules) {
ExtractRuleDescription(rule, f.rules); ExtractRuleDescription(rule, f.rules);
} }
@@ -448,7 +449,7 @@ function ParseForces(doc, roster) {
} }
function ParseSelections(root, force) { function ParseSelections(root, force) {
let selections = root.querySelectorAll("force>selections>selection"); let selections = root.querySelectorAll(":scope>selections>selection");
for (let selection of selections) { for (let selection of selections) {
// What kind of selection is this // What kind of selection is this
@@ -870,37 +871,43 @@ function ParseModelStatsProfiles(profiles, unit, unitName) {
const charName = char.getAttribute("name"); const charName = char.getAttribute("name");
if (!charName) continue; if (!charName) continue;
if (char.textContent) { const charValue = char.textContent?.trim();
if (charValue) {
switch (charName) { switch (charName) {
case "M": case "M":
model.move = char.textContent; model.move = charValue;
break; break;
case "WS": case "WS":
model.ws = char.textContent; model.ws = charValue;
break; break;
case "BS": case "BS":
model.bs = char.textContent; model.bs = charValue;
break; break;
case "S": case "S":
model.str = +char.textContent; model.str = +charValue;
break; break;
case "T": case "T":
model.toughness = +char.textContent; model.toughness = +charValue;
break; break;
case "W": case "W":
model.wounds = +char.textContent; model.wounds = +charValue;
break; break;
case "A": case "A":
model.attacks = char.textContent; model.attacks = charValue;
break; break;
case "LD": case "LD":
model.leadership = char.textContent; model.leadership = charValue;
break; break;
case "SV": case "SV":
model.save = char.textContent; case "Sv":
model.save = charValue;
break;
case "InSv":
case "Invulnerable Save":
if (charValue !== "-") model.invulnerableSave = charValue;
break; break;
case "OC": case "OC":
model.oc = char.textContent; model.oc = charValue;
break; 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. // Only include the allegiance rules once.
if (!DuplicateForce(f, roster)) { if (!DuplicateForce(f, roster)) {
const rules = root.querySelectorAll("force>rules>rule"); const rules = root.querySelectorAll(":scope>rules>rule");
for (let rule of rules) { for (let rule of rules) {
ExtractRuleDescription(rule, f.rules); ExtractRuleDescription(rule, f.rules);
} }
@@ -463,7 +463,7 @@ function ParseForces(doc, roster) {
} }
function ParseSelections(root, force) { function ParseSelections(root, force) {
let selections = root.querySelectorAll("force>selections>selection"); let selections = root.querySelectorAll(":scope>selections>selection");
for (let selection of selections) { for (let selection of selections) {
// What kind of selection is this // 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
+67
View File
@@ -0,0 +1,67 @@
// 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"],
// Title of the generated profile group, per ability. Anything not listed
// here uses the ability's own name, which is what the datasheets do.
groupTitleOverrides: {
"Canticles of the Omnissiah": "CANTICLES OF THE OMNISSIAH",
"Battle Protocols": "BATTLE PROTOCOLS",
},
};
// 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"];
+278
View File
@@ -0,0 +1,278 @@
// 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, so the renderer groups them into a single titled table.
//
// 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.
*
* The generated group renders through the renderer's generic ability table,
* which prints its text verbatim, so the markers would otherwise show up as
* literal punctuation. The intro profile keeps its original typeName, renders
* through the normal path, and is therefore left alone.
*
* 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.
*/
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 overrides = config.groupTitleOverrides ?? {};
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.
const groupTitle = overrides[ability] ?? ability;
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: stripMarkup(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;
}
+109
View File
@@ -0,0 +1,109 @@
// 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);
// Options print as plain text: GW's ^^keyword^^ / **emphasis** markers
// are resolved, not passed through as punctuation.
for (const text of group.values()) {
expect(JSON.stringify(text)).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;
}
+158
View File
@@ -0,0 +1,158 @@
// 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,
} 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.
*/
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 = 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"],
},
});