Files
RingBRP/tools/make-portraits.mjs
T
slaguru666andClaude Opus 5 9ddea43720 make-portraits: every creature gets art it cannot disagree with
Sixty of the seventy-six actors had no portrait. The sixteen that did are the
hand-made duty roster; the whole bestiary and every scenario NPC fell back to a
generic silhouette.

The point is not that this is free. A portrait composed from anatomy.mjs cannot
contradict the statblock, because it is drawn from the numbers the hit-location roll
uses: silhouette from BODY_ART, height from SIZ, build from STR against SIZ, plating
from naturalArmour, a mark for the primary weapon's class. A Cadence would get five
bodies because its table has five. Nothing has to be kept in step.

Colour carries what a GM wants off a thumbnail — whether the thing is a person,
folklore, recovered machinery, or the far side. That reads the shouted category the
role lines already carry (FOLKLORE, HORROR, FORWARD), which nothing was using, and
falls back to structure for the ones with no tag.

build-packs resolves portraits by stem and extension rather than an exact .webp
filename, so a creature gains art without an edit to content.mjs and a hand-made
raster always beats the generated diagram. A spec that NAMES a portrait and has none
is still a hard build failure.

These read as diagrammatic field-guide plates and will not match the Midjourney agent
portraits. That split is deliberate: the agents are the cast, the bestiary is case
material.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:32:53 +01:00

411 lines
18 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 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 `<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}.`);
}