Files
RingBRP/tools/make-portraits.mjs
T
slaguru666andClaude Opus 5 00f6a1e30f Art pipeline: the prompt asked for a person, and got one
Three fail-open classifiers, each reading a field that is not where the
bestiary keeps the answer. Found by generating one creature before
spending the allowance on forty-eight; art/creatures/gryphon-u1.png is a
WINGLESS gryphon, from a spec whose bodyPlan is "winged".

1. anatomyWords() read `species`. Shape lives in `bodyPlan` — that is the
   field selecting the hit-location table. 44 of the 100 new creatures are
   species "baseline" with bodyPlan quadruped or winged, so every one fell
   to the silent branch and asked for no shape at all.

2. isPersonSpec() ended `if (tag) return true` — an unrecognised role tag
   means person. MONSTER_TAGS listed three tags; the bestiary uses twelve,
   so 43 creatures were prompted as "portrait of X, waist-up portrait".
   Now bodyPlan decides first, structurally, because a tag list is free
   text that grows and bodyPlan is enforced by the engine.

3. ROLE_CLASS knew the same three tags, so HERALDRY fell through to
   "carries nothing manufactured and armour >= 4 -> anomalous". Six
   centuries of carved stone were classed as far-side machinery, and the
   armour clause duly asked for a mech. ROLE_CLASS now carries all twelve,
   and the heavy-armour wording follows era: machinery for future and
   anomalous, "hide and scale thick as carved stone" for the rest.

Also in this commit: all 100 creatures re-pointed onto the natural attacks
added in 3feac39 — 41 of 100 changed, the rest keeping grasp or punch
because that is genuinely what they do. Three manoeuvres stay as prose and
say so explicitly: dun_cow's and bull_azure's charge, gryphon's stoop and
drop, tup's blind side. Portraits redrawn, since era decides their hue.

All 23 guards pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 22:55:43 +01:00

425 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 100-creature bestiary uses nine more tags. Unlisted tags fall through to the
heuristic below, which reads "carries nothing manufactured and naturalArmour >= 4"
as far-side — so the guildhall gryphon, six centuries of carved stone, was classed
anomalous and prompted as plated machinery. Era answers "when", and these know when
they are from. */
HERALDRY: "antique", // came down off a coat of arms
BEAST: "antique", LIVESTOCK: "antique", MENAGERIE: "antique", HYBRID: "antique",
ANOMALOUS: "anomalous", REVENANT: "anomalous", ECHO: "anomalous",
"FAR-SIDE": "future",
// 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. */
/* Widened for the 100-creature bestiary. These are the shouted role tags that mean "not
a person"; anything not listed is read as one, so an unlisted monster tag is a monster
drawn as a human. bodyPlan now catches the non-human bodies structurally (see
isPersonSpec), and this list catches the monsters that DO have a humanoid body. */
export const MONSTER_TAGS = ["FOLKLORE", "FORWARD", "HORROR", "ANOMALOUS", "REVENANT",
"FAR-SIDE", "ECHO", "HERALDRY", "HYBRID", "BEAST", "MENAGERIE", "LIVESTOCK"];
/* 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 `<ellipse cx="${shape.cx}" cy="${shape.cy}" rx="${shape.rx}" ry="${shape.ry}" ${attrs}/>`;
}
if (shape.t === "rect") {
return `<rect x="${shape.x}" y="${shape.y}" width="${shape.w}" height="${shape.h}" rx="${shape.rx ?? 0}" ${attrs}/>`;
}
return `<path d="${shape.d}" ${attrs}/>`;
}
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 = `<rect x="6" y="6" width="${S - 12}" height="${S - 12}" rx="26" fill="none" stroke="${rim}" stroke-width="5"/>`;
if (era === "antique") corner = `<path d="M0 0h52L0 52z" fill="${bg2}"/>`;
if (era === "future") {
corner = `<path d="M${S - 56} 0H${S}v56z" fill="${rim}" opacity="0.55"/>`;
scan = `<path d="M18 ${S - 44}h${S - 36}" stroke="${rim}" stroke-width="2" opacity="0.65"/>`;
}
if (era === "anomalous") {
rimPath = `<rect x="6" y="6" width="${S - 12}" height="${S - 12}" rx="26" fill="none"
stroke="${rim}" stroke-width="5" stroke-dasharray="42 21" stroke-dashoffset="11"/>`;
}
/* --- 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 = `<ellipse cx="${(S / 2).toFixed(1)}" cy="${GROUND.toFixed(1)}" `
+ `rx="${groundR.toFixed(1)}" ry="${(groundR * 0.14).toFixed(1)}" fill="${bg2}" opacity="0.8"/>`;
/* --- weapon mark, bottom right --- */
const mk = weaponMark(spec);
const mark = !mk ? "" :
`<g transform="translate(${S - 62} ${S - 58}) scale(1.15)" opacity="0.92">`
+ (MARK_STROKE.has(mk)
? `<path d="${MARKS[mk]}" fill="none" stroke="${mid}" stroke-width="4" stroke-linecap="round"/>`
: `<path d="${MARKS[mk]}" fill="${mid}"/>`)
+ `</g>`;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${S} ${S}" width="${S}" height="${S}">
<title>${esc(spec.name)}</title>
<desc>${esc(spec.name)} — ${species}, SIZ ${spec.ch?.siz ?? "?"}, `
+ `${table.locations.length} hit locations, natural armour ${nat}, ${era}. `
+ `Generated by tools/make-portraits.mjs; do not edit by hand.</desc>
<rect width="${S}" height="${S}" rx="30" fill="${bg1}"/>
${corner}
${ground}
<g transform="translate(${tx.toFixed(2)} ${ty.toFixed(2)}) scale(${sx.toFixed(4)} ${sy.toFixed(4)})">
${body}
${plating}
</g>
${mark}
${rimPath}
${scan}
</svg>
`;
}
const esc = s => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
/* ------------------------------------------------------------------ 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}.`);
}