portraitSvg read `spec.species` for both the silhouette and the hit-location table. Every creature with a non-human body carries `species: "baseline"` and says what it is in `bodyPlan`, so the guildhall gryphon's own generated portrait described it as "baseline, 7 hit locations" and drew a human figure for an eagle-lion with wings. 48 creatures were affected — 32 quadrupeds and 16 winged. It is the same defect, in the same shape, as the anatomy clause in mj-queue.mjs: that one read `species` where the shape lives in `bodyPlan`, and drew a wingless gryphon. I fixed it there earlier tonight and did not look one file over, in the other tool that draws a creature from its statblock. R-264 settled this rule for the harness — `bodyPlan ?? species` — and build-packs:294 writes it to the actor's speciesProfile. Only two files change on disk, because 46 of the 48 already have hand-drawn plates and are correctly skipped. Those two are martlet and moor_cat, whose Midjourney art has now failed twice, so they are exactly the creatures falling back on the generated figure: martlet is winged with 10 locations and moor_cat quadruped with 8, where both were human before. The other 139 rewrote byte-identically, which is what a deterministic generator should do and is worth knowing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
436 lines
20 KiB
JavaScript
436 lines
20 KiB
JavaScript
/**
|
||
* 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") {
|
||
/* THE BODY PLAN, not the species — `spec.bodyPlan ?? spec.species`, which is the rule
|
||
R-264 settled for the harness and build-packs:294 writes to the actor's
|
||
speciesProfile. This read `species` alone, and every creature with a non-baseline
|
||
body has `species: "baseline"`: the guildhall gryphon's own generated portrait
|
||
described it as "baseline, 7 hit locations" and drew a human silhouette for an
|
||
eagle-lion with wings. 48 creatures were affected, 32 quadrupeds and 16 winged.
|
||
|
||
The same defect, in the same shape, as the one in mj-queue's anatomy clause — fixed
|
||
there earlier and not looked for here, one file away, in the other tool that draws a
|
||
creature from its statblock. */
|
||
const species = spec.species ?? "baseline";
|
||
const plan = spec.bodyPlan ?? species;
|
||
const art = BODY_ART[plan] ?? BODY_ART[species] ?? BODY_ART.baseline;
|
||
const table = LOCATION_TABLES[plan] ?? 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)} — ${plan}, 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, "&").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}.`);
|
||
|
||
}
|