diff --git a/art/portraits/auditor.svg b/art/portraits/auditor.svg
new file mode 100644
index 0000000..9ed2fff
--- /dev/null
+++ b/art/portraits/auditor.svg
@@ -0,0 +1,26 @@
+
diff --git a/art/portraits/barghest.svg b/art/portraits/barghest.svg
new file mode 100644
index 0000000..16ca5c2
--- /dev/null
+++ b/art/portraits/barghest.svg
@@ -0,0 +1,26 @@
+
diff --git a/art/portraits/boggart.svg b/art/portraits/boggart.svg
new file mode 100644
index 0000000..22cff69
--- /dev/null
+++ b/art/portraits/boggart.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/bp_callum.svg b/art/portraits/bp_callum.svg
new file mode 100644
index 0000000..6ffef46
--- /dev/null
+++ b/art/portraits/bp_callum.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/bp_conductor.svg b/art/portraits/bp_conductor.svg
new file mode 100644
index 0000000..521e36d
--- /dev/null
+++ b/art/portraits/bp_conductor.svg
@@ -0,0 +1,26 @@
+
diff --git a/art/portraits/bp_elin.svg b/art/portraits/bp_elin.svg
new file mode 100644
index 0000000..cb6517b
--- /dev/null
+++ b/art/portraits/bp_elin.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/bp_inspector.svg b/art/portraits/bp_inspector.svg
new file mode 100644
index 0000000..5d9a290
--- /dev/null
+++ b/art/portraits/bp_inspector.svg
@@ -0,0 +1,26 @@
+
diff --git a/art/portraits/bp_leah.svg b/art/portraits/bp_leah.svg
new file mode 100644
index 0000000..bd7837e
--- /dev/null
+++ b/art/portraits/bp_leah.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/bp_mara.svg b/art/portraits/bp_mara.svg
new file mode 100644
index 0000000..2b9a932
--- /dev/null
+++ b/art/portraits/bp_mara.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/bp_martin.svg b/art/portraits/bp_martin.svg
new file mode 100644
index 0000000..e37388a
--- /dev/null
+++ b/art/portraits/bp_martin.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/bp_nia.svg b/art/portraits/bp_nia.svg
new file mode 100644
index 0000000..2533361
--- /dev/null
+++ b/art/portraits/bp_nia.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/bp_owen.svg b/art/portraits/bp_owen.svg
new file mode 100644
index 0000000..a601893
--- /dev/null
+++ b/art/portraits/bp_owen.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/bp_sennen.svg b/art/portraits/bp_sennen.svg
new file mode 100644
index 0000000..6f1a6bb
--- /dev/null
+++ b/art/portraits/bp_sennen.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/carrion_file.svg b/art/portraits/carrion_file.svg
new file mode 100644
index 0000000..a11906b
--- /dev/null
+++ b/art/portraits/carrion_file.svg
@@ -0,0 +1,27 @@
+
diff --git a/art/portraits/ceyla.svg b/art/portraits/ceyla.svg
new file mode 100644
index 0000000..2d612f1
--- /dev/null
+++ b/art/portraits/ceyla.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/churchgrim.svg b/art/portraits/churchgrim.svg
new file mode 100644
index 0000000..1895dfe
--- /dev/null
+++ b/art/portraits/churchgrim.svg
@@ -0,0 +1,27 @@
+
diff --git a/art/portraits/cleaner.svg b/art/portraits/cleaner.svg
new file mode 100644
index 0000000..c8de275
--- /dev/null
+++ b/art/portraits/cleaner.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/cleaner_marksman.svg b/art/portraits/cleaner_marksman.svg
new file mode 100644
index 0000000..845c88b
--- /dev/null
+++ b/art/portraits/cleaner_marksman.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/cuckoo_array.svg b/art/portraits/cuckoo_array.svg
new file mode 100644
index 0000000..207beac
--- /dev/null
+++ b/art/portraits/cuckoo_array.svg
@@ -0,0 +1,27 @@
+
diff --git a/art/portraits/dunn.svg b/art/portraits/dunn.svg
new file mode 100644
index 0000000..139df54
--- /dev/null
+++ b/art/portraits/dunn.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/duplicate.svg b/art/portraits/duplicate.svg
new file mode 100644
index 0000000..6b0ae8f
--- /dev/null
+++ b/art/portraits/duplicate.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/hollow_man.svg b/art/portraits/hollow_man.svg
new file mode 100644
index 0000000..655693a
--- /dev/null
+++ b/art/portraits/hollow_man.svg
@@ -0,0 +1,26 @@
+
diff --git a/art/portraits/keepers.svg b/art/portraits/keepers.svg
new file mode 100644
index 0000000..7d48454
--- /dev/null
+++ b/art/portraits/keepers.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/kelpie.svg b/art/portraits/kelpie.svg
new file mode 100644
index 0000000..6e57fc6
--- /dev/null
+++ b/art/portraits/kelpie.svg
@@ -0,0 +1,26 @@
+
diff --git a/art/portraits/la_edith.svg b/art/portraits/la_edith.svg
new file mode 100644
index 0000000..cf64d74
--- /dev/null
+++ b/art/portraits/la_edith.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/la_enderby.svg b/art/portraits/la_enderby.svg
new file mode 100644
index 0000000..7f141d1
--- /dev/null
+++ b/art/portraits/la_enderby.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/la_marrow.svg b/art/portraits/la_marrow.svg
new file mode 100644
index 0000000..d94ca98
--- /dev/null
+++ b/art/portraits/la_marrow.svg
@@ -0,0 +1,26 @@
+
diff --git a/art/portraits/la_nowak.svg b/art/portraits/la_nowak.svg
new file mode 100644
index 0000000..2d2d7ae
--- /dev/null
+++ b/art/portraits/la_nowak.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/la_sammy.svg b/art/portraits/la_sammy.svg
new file mode 100644
index 0000000..95b68f4
--- /dev/null
+++ b/art/portraits/la_sammy.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/la_tunnicliffe.svg b/art/portraits/la_tunnicliffe.svg
new file mode 100644
index 0000000..27cd1f9
--- /dev/null
+++ b/art/portraits/la_tunnicliffe.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/long_walker.svg b/art/portraits/long_walker.svg
new file mode 100644
index 0000000..c4c57e2
--- /dev/null
+++ b/art/portraits/long_walker.svg
@@ -0,0 +1,27 @@
+
diff --git a/art/portraits/od_bewley.svg b/art/portraits/od_bewley.svg
new file mode 100644
index 0000000..665882b
--- /dev/null
+++ b/art/portraits/od_bewley.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/od_ferrand.svg b/art/portraits/od_ferrand.svg
new file mode 100644
index 0000000..ba44092
--- /dev/null
+++ b/art/portraits/od_ferrand.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/od_kearsley.svg b/art/portraits/od_kearsley.svg
new file mode 100644
index 0000000..de0730a
--- /dev/null
+++ b/art/portraits/od_kearsley.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/od_odame.svg b/art/portraits/od_odame.svg
new file mode 100644
index 0000000..cef077b
--- /dev/null
+++ b/art/portraits/od_odame.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/od_vasey.svg b/art/portraits/od_vasey.svg
new file mode 100644
index 0000000..3466f0b
--- /dev/null
+++ b/art/portraits/od_vasey.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/quarantine_unit.svg b/art/portraits/quarantine_unit.svg
new file mode 100644
index 0000000..53e5f74
--- /dev/null
+++ b/art/portraits/quarantine_unit.svg
@@ -0,0 +1,26 @@
+
diff --git a/art/portraits/quiet_neighbours_npc.svg b/art/portraits/quiet_neighbours_npc.svg
new file mode 100644
index 0000000..236f4e3
--- /dev/null
+++ b/art/portraits/quiet_neighbours_npc.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/rakhi.svg b/art/portraits/rakhi.svg
new file mode 100644
index 0000000..0c7cab0
--- /dev/null
+++ b/art/portraits/rakhi.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/redcap.svg b/art/portraits/redcap.svg
new file mode 100644
index 0000000..ecd2bdb
--- /dev/null
+++ b/art/portraits/redcap.svg
@@ -0,0 +1,26 @@
+
diff --git a/art/portraits/revenant.svg b/art/portraits/revenant.svg
new file mode 100644
index 0000000..5bcfb8a
--- /dev/null
+++ b/art/portraits/revenant.svg
@@ -0,0 +1,27 @@
+
diff --git a/art/portraits/surveyor.svg b/art/portraits/surveyor.svg
new file mode 100644
index 0000000..5f89b9b
--- /dev/null
+++ b/art/portraits/surveyor.svg
@@ -0,0 +1,26 @@
+
diff --git a/art/portraits/the_choir.svg b/art/portraits/the_choir.svg
new file mode 100644
index 0000000..c4d2f36
--- /dev/null
+++ b/art/portraits/the_choir.svg
@@ -0,0 +1,26 @@
+
diff --git a/art/portraits/the_tenant.svg b/art/portraits/the_tenant.svg
new file mode 100644
index 0000000..fee525e
--- /dev/null
+++ b/art/portraits/the_tenant.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/the_understudy.svg b/art/portraits/the_understudy.svg
new file mode 100644
index 0000000..a820a3b
--- /dev/null
+++ b/art/portraits/the_understudy.svg
@@ -0,0 +1,26 @@
+
diff --git a/art/portraits/tomas.svg b/art/portraits/tomas.svg
new file mode 100644
index 0000000..bf56ab8
--- /dev/null
+++ b/art/portraits/tomas.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/tt_ambrose.svg b/art/portraits/tt_ambrose.svg
new file mode 100644
index 0000000..5ef7b51
--- /dev/null
+++ b/art/portraits/tt_ambrose.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/tt_bewley.svg b/art/portraits/tt_bewley.svg
new file mode 100644
index 0000000..a204aae
--- /dev/null
+++ b/art/portraits/tt_bewley.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/tt_bligh.svg b/art/portraits/tt_bligh.svg
new file mode 100644
index 0000000..c519792
--- /dev/null
+++ b/art/portraits/tt_bligh.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/tt_coat.svg b/art/portraits/tt_coat.svg
new file mode 100644
index 0000000..213956c
--- /dev/null
+++ b/art/portraits/tt_coat.svg
@@ -0,0 +1,26 @@
+
diff --git a/art/portraits/tt_cordera_man.svg b/art/portraits/tt_cordera_man.svg
new file mode 100644
index 0000000..c8edb35
--- /dev/null
+++ b/art/portraits/tt_cordera_man.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/tt_kell.svg b/art/portraits/tt_kell.svg
new file mode 100644
index 0000000..8065940
--- /dev/null
+++ b/art/portraits/tt_kell.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/tt_merritt.svg b/art/portraits/tt_merritt.svg
new file mode 100644
index 0000000..043411b
--- /dev/null
+++ b/art/portraits/tt_merritt.svg
@@ -0,0 +1,26 @@
+
diff --git a/art/portraits/tt_prosser.svg b/art/portraits/tt_prosser.svg
new file mode 100644
index 0000000..77b4a14
--- /dev/null
+++ b/art/portraits/tt_prosser.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/tt_rance.svg b/art/portraits/tt_rance.svg
new file mode 100644
index 0000000..8e1e4d5
--- /dev/null
+++ b/art/portraits/tt_rance.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/tt_reyes.svg b/art/portraits/tt_reyes.svg
new file mode 100644
index 0000000..c41a2ce
--- /dev/null
+++ b/art/portraits/tt_reyes.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/tt_shard.svg b/art/portraits/tt_shard.svg
new file mode 100644
index 0000000..610653b
--- /dev/null
+++ b/art/portraits/tt_shard.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/tt_tom_doyle.svg b/art/portraits/tt_tom_doyle.svg
new file mode 100644
index 0000000..80d8dd1
--- /dev/null
+++ b/art/portraits/tt_tom_doyle.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/tt_vane.svg b/art/portraits/tt_vane.svg
new file mode 100644
index 0000000..1197a53
--- /dev/null
+++ b/art/portraits/tt_vane.svg
@@ -0,0 +1,20 @@
+
diff --git a/art/portraits/tt_writman.svg b/art/portraits/tt_writman.svg
new file mode 100644
index 0000000..07e3ec4
--- /dev/null
+++ b/art/portraits/tt_writman.svg
@@ -0,0 +1,20 @@
+
diff --git a/package.json b/package.json
index b7f3421..b26ad0f 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,8 @@
"scripts": {
"build": "node tools/build-packs.mjs && node tools/update-readme.mjs",
"icons": "node tools/make-icons.mjs",
+ "portraits": "node tools/make-portraits.mjs",
+ "mj": "node tools/mj-queue.mjs",
"simulate": "node tools/simulate.mjs",
"check": "node tools/check-rules.mjs && node tools/check-kits.mjs && node tools/check-lang.mjs && node tools/check-templates.mjs && node tools/check-behaviour.mjs && node tools/check-scenarios.mjs && node tools/check-creatures.mjs",
"test": "npm run check",
diff --git a/tools/build-packs.mjs b/tools/build-packs.mjs
index 0549292..fa2467f 100644
--- a/tools/build-packs.mjs
+++ b/tools/build-packs.mjs
@@ -308,14 +308,35 @@ function buildActor(spec, type) {
// A generated portrait if the spec names one, the generic silhouette otherwise.
// The token gets the same face: a pregen you can recognise on the map is worth
// more at a convention table than a correct one you cannot tell apart.
+ /* Resolved by stem and extension rather than by an exact filename.
+ *
+ * Two things changed. Portraits are no longer only .webp — tools/make-portraits.mjs
+ * writes .svg for every creature, and tools/mj-queue.mjs files .png when one is
+ * illustrated by hand — and a creature no longer has to NAME its portrait to get one:
+ * art found under its own key is used. That is what lets sixty creatures gain art
+ * without sixty edits to content.mjs.
+ *
+ * Order matters. A hand-made raster always beats the generated diagram, so upgrading a
+ * creature is a matter of dropping the file in; nothing has to be un-said in source. */
+ const PORTRAIT_EXT = ["webp", "png", "jpg", "svg"];
+ const findPortrait = stem => {
+ for (const ext of PORTRAIT_EXT) {
+ const rel = `art/portraits/${stem}.${ext}`;
+ if (existsSync(path.join(ROOT, rel))) return rel;
+ }
+ return null;
+ };
+
let face = ICON.actor;
- if (spec.portrait) {
- const rel = `art/portraits/${spec.portrait}.webp`;
- // A missing portrait is a broken image on a pregen sheet that nobody notices
- // until it is on a projector at a table. Fail the build instead.
- if (!existsSync(path.join(ROOT, rel))) throw new Error(`${spec.name}: no portrait at ${rel}`);
- face = `systems/ringbrp/${rel}`;
+ const found = findPortrait(spec.portrait ?? spec.key);
+ // A spec that NAMES a portrait and has none is still a hard failure: that is a broken
+ // image on a pregen sheet, and nobody notices until it is on a projector at a table.
+ // A spec that names none and has none simply keeps the generic silhouette.
+ if (spec.portrait && !found) {
+ throw new Error(`${spec.name}: no portrait for "${spec.portrait}" `
+ + `(looked for ${PORTRAIT_EXT.map(e => `.${e}`).join(", ")} in art/portraits/)`);
}
+ if (found) face = `systems/ringbrp/${found}`;
const actorDoc = {
_id: actorId,
diff --git a/tools/make-portraits.mjs b/tools/make-portraits.mjs
new file mode 100644
index 0000000..a6a7d13
--- /dev/null
+++ b/tools/make-portraits.mjs
@@ -0,0 +1,410 @@
+/**
+ * Procedural creature portraits, drawn from the creature's own anatomy.
+ *
+ * Sixty of the seventy-six actors in this repository have no art at all. The sixteen
+ * that do are hand-made Midjourney plates for the duty roster; every monster in the
+ * bestiary and every scenario NPC falls back to a generic silhouette that tells a GM
+ * nothing and tells a player less.
+ *
+ * THE POINT IS NOT THAT IT IS FREE. It is that a portrait composed from
+ * anatomy.mjs CANNOT CONTRADICT THE STATBLOCK, because it is drawn from the same
+ * numbers the hit-location roll uses. A Cadence gets five bodies because its location
+ * table has five entries. A Vesh gets a sensory ridge and no head because it has no
+ * head to draw. Nobody has to remember to keep the art in step; there is nothing to
+ * keep in step.
+ *
+ * Everything visible is derived:
+ *
+ * silhouette BODY_ART[species].parts — 7 for a baseline, 6 for a vesh, 5 for a cadence
+ * height SIZ 5 (a boggart) to SIZ 22 (a long walker), planted on a groundline
+ * build STR against SIZ, the only build information a BRP statblock carries
+ * plating naturalArmour, drawn over every part, legible at 32px
+ * weapon mark the class of the creature's primary weapon, not its name
+ * treatment whether it is a person, folklore, recovered technology, or the far side
+ * hue anchored on that, so a wall of the bestiary sorts itself by eye
+ *
+ * Deterministic from the creature key: same creature, same file, so art does not churn
+ * in git. Output is SVG for the same reasons icons/ is — scalable, tiny, diffable, and
+ * no image toolchain on any of the three machines this repo is checked out on.
+ *
+ * THE HONEST TRADE. These read as diagrammatic: a species-accurate figure on a treated
+ * tile, not an illustration. Put one beside pc_holloway.webp and you will see it. That
+ * is the deal — every creature has correct art today, and tools/mj-queue.mjs upgrades
+ * any individual one to a drawn portrait when it earns the attention.
+ *
+ * node tools/make-portraits.mjs write art for every creature that has none
+ * node tools/make-portraits.mjs --force redraw everything, including existing SVGs
+ * node tools/make-portraits.mjs --only keepers,barghest
+ * node tools/make-portraits.mjs --only tt_ prefix match
+ * node tools/make-portraits.mjs --list what has art, what does not, and why
+ *
+ * --force never touches a .webp or .png. Hand-made art is never overwritten by this
+ * tool, whatever you ask it to do.
+ */
+import { writeFile, mkdir } from "node:fs/promises";
+import { existsSync } from "node:fs";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+import { BODY_ART, LOCATION_TABLES } from "../anatomy.mjs";
+import { WEAPONS, ARMOURS, GEAR } from "./content.mjs";
+import { collectSpecs } from "./all-specs.mjs";
+
+const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), "..");
+const OUT = path.join(ROOT, "art", "portraits");
+
+const WEAPON = new Map(WEAPONS.map(w => [w.key, w]));
+const ARMOUR = new Map(ARMOURS.map(a => [a.key, a]));
+const GEARM = new Map(GEAR.map(g => [g.key, g]));
+
+/* ------------------------------------------------------------------ palette */
+
+/* Hue anchors, taken from the icon set's own families.
+ *
+ * The first cut of this tool coloured by actor-vs-npc — hue 330 against 355 — and drew
+ * sixty tiles in one indistinguishable shade of maroon. Colour has to carry information
+ * or it is decoration, so it carries the one thing a GM most wants off a thumbnail:
+ * WHAT KIND OF THING IS THIS. Agency steel for people, brass for folklore, violet for
+ * recovered technology, red for the far side. A wall of the bestiary then sorts itself
+ * by eye before a single name is read. */
+const ERA_HUE = { modern: 205, antique: 35, future: 275, anomalous: 355 };
+const S = 256;
+const hsl = (h, s, l) => `hsl(${h} ${s}% ${l}%)`;
+
+/** FNV-1a. Small, stable across node versions, and that is the whole requirement. */
+function hash(str) {
+ let h = 0x811c9dc5;
+ for (let i = 0; i < str.length; i++) {
+ h ^= str.charCodeAt(i);
+ h = Math.imul(h, 0x01000193) >>> 0;
+ }
+ return h >>> 0;
+}
+
+/* ------------------------------------------------------------------ derivation */
+
+/** Rank of era by how far from ordinary it is; the creature is drawn as its most exotic. */
+const ERA_RANK = { modern: 0, antique: 1, future: 2, anomalous: 3 };
+
+/**
+ * Which tile treatment a creature gets.
+ *
+ * The icon set uses era to mean "which century did this object come from", because for
+ * an object that is the interesting question. For a creature it is not: the bestiary's
+ * natural weapons — grasp, punch — carry no era at all, so reading kit alone paints the
+ * entire far side "modern agency issue" and the whole wall of portraits goes flat.
+ *
+ * The question a GM actually has is whether the thing in front of them is a person. That
+ * one IS answerable from the spec, structurally rather than by keyword:
+ *
+ * a non-baseline species is anomalous by definition
+ * only natural weapons, well armoured is not a person — the far side, horror
+ * only natural weapons, lightly armoured is folklore — old, and older than the agency
+ * carrying manufactured kit is a person, and the kit dates them
+ *
+ * So a cleaner reads modern, a redcap reads antique, and a long walker's tile does not
+ * close.
+ */
+/**
+ * The author's own classification, if the spec carries one. Thirty of the seventy-six
+ * role lines open with a shouted category — "FOLKLORE. The black dog on the road out of
+ * the village" — and those are the three groupings the README already advertises. An
+ * authored label beats anything inferred from kit, so it is read first and the
+ * structural rules below only decide the ones that have none.
+ */
+export const ROLE_CLASS = {
+ FOLKLORE: "antique", // older than the agency
+ FORWARD: "future", // the far side, and the machinery it sends
+ HORROR: "anomalous", // should not exist, and does
+ // The rest are people, in various relationships to the department.
+ CIVILIAN: "modern", DEPARTMENT: "modern", BUREAU: "modern",
+ ENTRY: "modern", REAL: "modern"
+};
+
+/** Categories that are not people. Everything else in ROLE_CLASS is somebody. */
+export const MONSTER_TAGS = ["FOLKLORE", "FORWARD", "HORROR"];
+
+/* The tag is not always followed by a full stop — "BUREAU, present day. Registry." — so
+ this matches the shouted word itself and stops at the first word boundary. Anchoring on
+ the period missed both Bureau staff and read them as monsters. */
+export const roleTagOf = spec =>
+ (String(spec.role ?? "").match(/^([A-Z]{3,})\b/) ?? [, null])[1];
+
+export const roleClassOf = spec => ROLE_CLASS[roleTagOf(spec)] ?? null;
+
+/**
+ * True when nothing this creature carries was made by anybody: a natural weapon weighs
+ * nothing and belongs to the family "brawl", and there is no gear and no armour. A
+ * barghest passes; a miner in 1881 does not, because a pick is a manufactured thing.
+ */
+export function carriesNothingManufactured(spec) {
+ const gearKeys = [
+ ...(spec.gear ?? []).map(g => Array.isArray(g) ? g[0] : g),
+ ...(spec.extraGear ?? []).map(g => Array.isArray(g) ? g[0] : g)
+ ];
+ if (gearKeys.length || (spec.armour ?? []).length) return false;
+ const weapons = [...(spec.weapons ?? []), ...(spec.extraWeapons ?? [])];
+ // At least one, deliberately: [].every() is vacuously true, so an empty list once made
+ // this answer "yes" for anybody carrying nothing at all — which read Callum the bouncer,
+ // who has no kit listed, as a monster. The test is HAVING natural weapons, not lacking
+ // manufactured ones.
+ return weapons.length > 0
+ && weapons.every(k => { const w = WEAPON.get(k); return w && w.fam === "brawl" && !w.mass; });
+}
+
+export function eraOf(spec) {
+ if ((spec.species ?? "baseline") !== "baseline") return "anomalous";
+
+ const authored = roleClassOf(spec);
+ if (authored) return authored;
+
+ const gearKeys = [
+ ...(spec.gear ?? []).map(g => Array.isArray(g) ? g[0] : g),
+ ...(spec.extraGear ?? []).map(g => Array.isArray(g) ? g[0] : g)
+ ];
+ const weaponKeys = [...(spec.weapons ?? []), ...(spec.extraWeapons ?? [])];
+ const armourKeys = spec.armour ?? [];
+
+ const carriesNothingMade = carriesNothingManufactured(spec);
+
+ if (carriesNothingMade) {
+ return (Number(spec.naturalArmour) || 0) >= 4 ? "anomalous" : "antique";
+ }
+
+ let best = "modern";
+ for (const k of [...weaponKeys, ...armourKeys, ...gearKeys]) {
+ const era = (WEAPON.get(k) ?? ARMOUR.get(k) ?? GEARM.get(k))?.era;
+ if (era && (ERA_RANK[era] ?? 0) > ERA_RANK[best]) best = era;
+ }
+ return best;
+}
+
+/**
+ * How tall to draw it. SIZ runs 5 (a boggart) to 22 (a long walker) across the repo, and
+ * the first cut compressed that into 0.68–1.14, where nothing read as bigger than
+ * anything else. A boggart should be visibly a thing you could kick.
+ */
+function heightScale(siz) {
+ const n = Number(siz) || 13;
+ const t = (Math.min(24, Math.max(4, n)) - 4) / 20; // 0..1
+ return 0.46 + Math.pow(t, 0.75) * 0.54; // 0.46..1.00 of the well
+}
+
+/**
+ * How broad to draw it, from STR against SIZ — the only build information a BRP
+ * statblock actually carries. The repo spans 0.73 (Old Marrow, who is eighty) to 2.83
+ * (Sammy, who is a child and much stronger than he has any business being), so this is
+ * real signal rather than noise dressed up as signal. Normalised around 1.05, which is
+ * roughly where the duty roster sits, so an ordinary adult draws at ordinary width.
+ */
+function buildScale(ch) {
+ const str = Number(ch?.str) || 12, siz = Number(ch?.siz) || 12;
+ const r = Math.pow((str / siz) / 1.05, 0.55);
+ return Math.min(1.32, Math.max(0.78, r));
+}
+
+/** The creature's primary weapon, reduced to how it is held rather than what it is. */
+function weaponMark(spec) {
+ const key = (spec.weapons ?? [])[0] ?? (spec.extraWeapons ?? [])[0];
+ const w = key ? WEAPON.get(key) : null;
+ if (!w) return null;
+ if (w.cls === "firearm" || w.cls === "energy") return "firearm";
+ if (w.cls === "bow" || w.cls === "thrown") return "thrown";
+ // A natural weapon weighs nothing and is part of the creature: claws, not a knife.
+ if (w.fam === "brawl" && !w.mass) return "natural";
+ return "melee";
+}
+
+const MARKS = {
+ firearm: `M6 20h26v7H16l-4 9H6z`,
+ melee: `M4 34L30 8l5 5L9 39z`,
+ thrown: `M4 34a30 30 0 0 1 32-26`,
+ natural: `M6 8c8 6 12 16 12 30M18 6c8 6 12 16 12 30M30 8c6 6 9 14 9 26`
+};
+const MARK_STROKE = new Set(["thrown", "natural"]);
+
+/* ------------------------------------------------------------------ drawing */
+
+/** One anatomical part, in whichever primitive body-figure.hbs would have drawn it. */
+function part(shape, attrs) {
+ if (shape.t === "ellipse") {
+ return ``;
+ }
+ if (shape.t === "rect") {
+ return ``;
+ }
+ return ``;
+}
+
+export function portraitSvg(spec, kind = "npc") {
+ const species = spec.species ?? "baseline";
+ const art = BODY_ART[species] ?? BODY_ART.baseline;
+ const table = LOCATION_TABLES[species] ?? LOCATION_TABLES.baseline;
+ const era = eraOf(spec);
+ const muted = era === "antique";
+
+ // Hue: the era anchor, nudged by the key so a row of cleaners is not one flat colour,
+ // but never far enough to leave its family.
+ const hue = (ERA_HUE[era] + ((hash(spec.key) % 29) - 14) + 360) % 360;
+
+ const bg1 = hsl(hue, muted ? 38 : 58, muted ? 27 : 30);
+ const bg2 = hsl(hue, muted ? 44 : 66, 17);
+ const ink = hsl(hue, muted ? 50 : 78, muted ? 78 : 84);
+ const mid = hsl(hue, muted ? 38 : 62, 48);
+ const rim = hsl(hue, 78, 62);
+
+ /* --- era treatment, matching tools/make-icons.mjs --- */
+ let corner = "", scan = "";
+ let rimPath = ``;
+ if (era === "antique") corner = ``;
+ if (era === "future") {
+ corner = ``;
+ scan = ``;
+ }
+ if (era === "anomalous") {
+ rimPath = ``;
+ }
+
+ /* --- the figure --- */
+ /* Feet planted on a common groundline rather than centred, so a boggart is short
+ instead of merely small and floating, and height reads directly against the tile.
+ The well is the space between that groundline and the top margin: the largest
+ creature in the game fills it exactly and NOTHING is ever drawn outside it. The
+ long walker is SIZ 22 and was cropped at the scalp before this was a ratio of the
+ well rather than of the tile. */
+ const GROUND = S * 0.90;
+ const WELL = GROUND - S * 0.07;
+
+ const [, , vbW, vbH] = String(art.viewBox).split(/\s+/).map(Number);
+ const sy = (WELL / vbH) * heightScale(spec.ch?.siz);
+ const sx = sy * buildScale(spec.ch);
+ const tx = (S - vbW * sx) / 2;
+ const ty = GROUND - vbH * sy;
+
+ const nat = Number(spec.naturalArmour) || 0;
+ const shapes = Object.entries(art.parts);
+
+ const body = shapes
+ .map(([, shape]) => part(shape, `fill="${ink}" stroke="${bg2}" stroke-width="3" stroke-linejoin="round"`))
+ .join("\n ");
+
+ /* Natural armour, as plating over every part. Drawn in the figure's own coordinate
+ space so it tracks the anatomy — a Cadence gets five plated bodies, not one plated
+ blob. Scaled to be legible at 32px in an actor list, which is the size this is
+ actually looked at: nothing at 0, unmistakable at 10. */
+ const plating = nat <= 0 ? "" : shapes
+ .map(([, shape]) => part(shape,
+ `fill="none" stroke="${rim}" stroke-width="${(2 + Math.min(nat, 12) * 0.85).toFixed(2)}" `
+ + `opacity="${Math.min(0.92, 0.34 + nat * 0.06).toFixed(2)}" stroke-linejoin="round"`))
+ .join("\n ");
+
+ // Something to stand on, scaled with the figure, so size reads as size rather than as
+ // the picture being zoomed.
+ const groundR = vbW * sx * 0.52;
+ const ground = ``;
+
+ /* --- weapon mark, bottom right --- */
+ const mk = weaponMark(spec);
+ const mark = !mk ? "" :
+ ``
+ + (MARK_STROKE.has(mk)
+ ? ``
+ : ``)
+ + ``;
+
+ return `
+`;
+}
+
+const esc = s => String(s).replace(/&/g, "&").replace(//g, ">");
+
+/* ------------------------------------------------------------------ run */
+
+/** Art already on disk for this stem, in any format, or null. */
+function existingArt(stem) {
+ for (const ext of ["webp", "png", "jpg", "svg"]) {
+ if (existsSync(path.join(OUT, `${stem}.${ext}`))) return `${stem}.${ext}`;
+ }
+ return null;
+}
+
+/* Only when run directly. portraitSvg() is importable so that a species with no creature
+ in it yet can still be drawn and looked at — vesh and cadence have full geometry and
+ nothing has ever used either, so the first creature written in one should not also be
+ the first time anybody sees what it looks like. A module that writes files and exits
+ on import is no use for that. */
+const invokedDirectly = process.argv[1]
+ && import.meta.url === pathToFileURL(process.argv[1]).href;
+
+if (invokedDirectly) {
+
+const argv = process.argv.slice(2);
+const flag = n => argv.includes(n);
+const opt = (n, d) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : d; };
+const FORCE = flag("--force");
+const LIST = flag("--list");
+const ONLY = (opt("--only", "") || "").split(",").map(s => s.trim()).filter(Boolean);
+
+const all = await collectSpecs();
+const selected = all.filter(({ spec }) =>
+ !ONLY.length || ONLY.some(o => spec.key === o || spec.key.startsWith(o)));
+
+if (!selected.length) {
+ console.error(`nothing matched --only ${ONLY.join(",")}`);
+ process.exit(1);
+}
+
+if (LIST) {
+ let has = 0, none = 0;
+ for (const { spec, kind } of selected) {
+ const stem = spec.portrait ?? spec.key;
+ const found = existingArt(stem);
+ if (found) has++; else none++;
+ console.log(`${found ? "art " : " "} ${spec.key.padEnd(24)} ${(found ?? "—").padEnd(24)}`
+ + `${(spec.species ?? "baseline").padEnd(9)} siz ${String(spec.ch?.siz ?? "?").padStart(2)} ${eraOf(spec)} ${kind}`);
+ }
+ console.log(`\n${has} with art, ${none} without, of ${selected.length}.`);
+ process.exit(0);
+}
+
+await mkdir(OUT, { recursive: true });
+
+let wrote = 0, skipped = 0, kept = 0;
+for (const { spec, kind } of selected) {
+ const stem = spec.portrait ?? spec.key;
+ const found = existingArt(stem);
+
+ // Hand-made art is never overwritten, --force or not. Regenerating a Midjourney plate
+ // into a diagram because a flag was passed is not a mistake anybody recovers from
+ // without a git checkout, and the tool should not be able to make it.
+ if (found && !found.endsWith(".svg")) {
+ if (ONLY.length) console.log(`keep ${spec.key.padEnd(24)} ${found} is hand-made — not replaced`);
+ kept++;
+ continue;
+ }
+ if (found && !FORCE) { skipped++; continue; }
+
+ await writeFile(path.join(OUT, `${stem}.svg`), portraitSvg(spec, kind), "utf8");
+ wrote++;
+}
+
+console.log(`make-portraits: wrote ${wrote}, kept ${kept} hand-made, skipped ${skipped} existing`
+ + `${FORCE ? "" : " (use --force to redraw generated ones)"} — of ${selected.length}.`);
+
+}