Files
RingBRP/tools/make-icons.mjs
T
slaguru666andClaude Opus 5 d0144f744e The Custodians v0.6.1 — full system
A Foundry VTT game system: Basic Roleplaying d100 in a drab British agency that
deals with crossings, where reality keeps a record of what belongs where and can be
made to get it wrong.

Contents:
- rules.mjs as the single authority for every rule formula, with 40 rules and 132
  spot-checked values. Nothing may restate a formula it defines.
- 25 postings, each with a key characteristic, trained skills, talents and its own
  kit; a 175-item catalogue across four eras; 231 generated icons.
- Reaction Lanes initiative, re-rolled every round including the first.
- Coherence, and the Quiet Arts, which only ever arrive from a crossing gone wrong.
- Graded defences: only a critical stops a critical.
- Borrowed Authority — access as a record rather than a hacking roll.
- A case generator, and a rules journal generated from rules.mjs so the book cannot
  drift from the code.

Built compendia are committed so a remote Foundry needs only the files.
node_modules is not; `npm install` restores it for rebuilding content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:09:07 +01:00

265 lines
16 KiB
JavaScript

/**
* The Custodians — icon set, v3.
*
* Flat, bold, saturated. Not photorealistic and not trying to be. What changed in
* v3 is DEPTH: a glyph is no longer a single silhouette but up to three layers —
*
* base the mass of the object, in near-white ink
* detail the parts that tell you WHICH object it is, in a darker tone:
* the grip and sight on a pistol, the fuller on a blade, the lens
* housing on a scanner. This is the whole point of the revision.
* mark a small accent in the rim colour — a muzzle flash, a lit indicator
*
* and ERA, because the agency crosses to other times and comes back with the
* shopping. Era is a corner tab plus a treatment of the tile, so a glance at an
* inventory tells you what century a thing is from before you read its name:
*
* antique notched corner, muted ground, no glow — flint, brass, oil
* modern plain tile — agency issue
* future cut corner and a hairline scan across it — recovered, elsewhere
* anomalous a broken rim: the tile does not close — should not exist
*
* Readable at 32px in a Foundry list, still clean at 256px on a sheet.
*/
import { writeFile, mkdir } from "node:fs/promises";
import path from "node:path";
const OUT = path.resolve("icons");
const S = 128;
/* Category hue families. Deliberately far apart so a mixed inventory reads fast. */
const FAMILY = {
weapon: { h: 8 }, melee: { h: 24 }, armour: { h: 205 },
gear: { h: 150 }, device: { h: 275 }, skill: { h: 45 },
talent: { h: 185 }, actor: { h: 330 }, threat: { h: 355 },
case: { h: 220 }, power: { h: 288 }
};
const hsl = (h, s, l) => `hsl(${h} ${s}% ${l}%)`;
/**
* Glyphs. A string is a plain silhouette (v2 behaviour, kept so nothing regresses).
* An object may carry { base, detail, mark, stroke } — `stroke` lists which of
* those layers are drawn as lines rather than filled.
*/
const G = {
/* ---------------- firearms: era told by the furniture ---------------- */
pistol: { base: `M22 62h52l10-10h16v14l-12 6v10H60l-6 12H34l6-12H22z`,
detail: `M30 70h18v8H30z M60 56h14v6H60z`, mark: `M112 60h10v8h-10z` },
revolver: { base: `M20 60h48l8-8h18v12l-10 6v10H56l-8 14H26l8-14H20z`,
detail: `M44 56a10 10 0 1 1 0 20 10 10 0 0 1 0-20z M28 70h14v8H28z`,
mark: `M110 58h10v10h-10z` },
flintlock: { base: `M18 64h44l40-16v10L70 74v8H56l-10 16H22l10-16H18z`,
detail: `M56 54l10-8 6 8-8 8z M26 72h16v8H26z` },
rifle: { base: `M12 60h84l20-8v12l-14 6v8H60l-8 14H32l8-14H12z`,
detail: `M46 66h30v6H46z M84 52h10v8H84z`, mark: `M116 58h6v10h-6z` },
musket: { base: `M10 66h96l14-6v8l-14 6v6H62l-10 14H24l10-14H10z`,
detail: `M40 70h26v6H40z M66 56l8-8 6 6-8 8z` },
smg: { base: `M18 54h60v14H18z M30 68h16v30H30z M78 56h30v10H78z`,
detail: `M52 68h12v22H52z M22 58h8v6h-8z`, mark: `M108 56h10v10h-10z` },
shotgun: { base: `M12 46h58v10H12z M12 60h58v10H12z M70 44l30 16-30 16z`,
detail: `M100 52h16v16h-16z M20 48h14v4H20z` },
gauss: { base: `M14 58h72v14H14z M86 50h26v30H86z`,
detail: `M24 62h48v6H24z M92 58h14v14H92z`,
mark: `M112 54h10v22h-10z`, stroke: ["mark"] },
/* ---------------- blades and hafted ---------------- */
blade: { base: `M78 12l12 12-44 44-12-12z M40 60l12 12-8 12-16-4z M34 84l10 10-16 16-10-10z`,
detail: `M74 22l6 6-38 38-6-6z` },
sword: { base: `M64 8l10 10v58H54V18z M40 76h48v10H40z M58 86h12v34H58z`,
detail: `M62 20h4v52h-4z M60 108h8v8h-8z` },
axe: { base: `M52 14h14v106H52z M66 20c22 0 34 14 34 26s-12 24-34 24z`,
detail: `M72 30c12 2 18 10 18 16s-6 14-18 16z` },
spear: { base: `M62 8l10 26-10 12-10-12z M58 46h12v74H58z`,
detail: `M60 20h8v12h-8z M56 92h16v6H56z` },
club: { base: `M44 100l40-72 14 8-40 72z M84 18a12 12 0 1 1 18 10 12 12 0 0 1-18-10z`,
detail: `M52 96l32-58` , stroke: ["detail"] },
bar: { base: `M52 12h24v92H52z M44 104h40v14H44z`,
detail: `M58 24h12v70H58z M52 12l-8-6h40l-8 6z` },
claw: { base: `M30 18c14 22 14 62 6 90M56 12c10 26 10 68 2 96M82 18c8 24 6 64-2 88`,
stroke: ["base"] },
bow: { base: `M40 16c30 14 30 82 0 96M36 12v104`, stroke: ["base"],
detail: `M40 16l52 48-52 48`, },
crossbow: { base: `M14 58h100v12H14z M52 40h12v46H52z`,
detail: `M20 46c18 8 18 28 0 36M108 46c-18 8-18 28 0 36`, stroke: ["detail"] },
/* ---------------- crossing tech ---------------- */
beam: { base: `M18 58h48v12H18z M66 46l30 18-30 18z`,
detail: `M26 62h30v4H26z`, mark: `M96 56h16v16H96z` },
bolt: { base: `M70 8L28 72h28l-10 48 46-68H62z`, detail: `M64 26L44 62h16z` },
lattice: { base: `M64 10l46 26v56l-46 26-46-26V36z`,
detail: `M64 34l24 14v28l-24 14-24-14V48z`, mark: `M64 58a8 8 0 1 1 0 16 8 8 0 0 1 0-16z` },
aperture: { base: `M64 14a50 50 0 1 1 0 100 50 50 0 0 1 0-100z`,
detail: `M64 38a26 26 0 1 1 0 52 26 26 0 0 1 0-52z`,
mark: `M64 54a10 10 0 1 1 0 20 10 10 0 0 1 0-20z` },
/* ---------------- armour ---------------- */
vest: { base: `M40 20h48l10 18v56a10 10 0 0 1-10 10H40a10 10 0 0 1-10-10V38z`,
detail: `M64 20v84 M38 46h20M74 46h20` , stroke: ["detail"] },
plate: { base: `M64 12l40 14v40c0 28-18 44-40 50-22-6-40-22-40-50V26z`,
detail: `M64 26l26 10v30c0 18-12 28-26 32-14-4-26-14-26-32V36z` },
jacket: { base: `M42 22h44l12 16v62H30V38z`, detail: `M64 22v78 M30 54h68`, stroke: ["detail"] },
suit: { base: `M64 8a16 16 0 1 1 0 32 16 16 0 0 1 0-32z M40 44h48l10 20v56H30V64z`,
detail: `M52 20h24v10H52z M44 72h40v12H44z` },
mail: { base: `M40 20h48l10 18v56a10 10 0 0 1-10 10H40a10 10 0 0 1-10-10V38z`,
detail: `M40 40h12v12H40zM60 40h12v12H60zM80 40h10v12H80zM50 56h12v12H50zM70 56h12v12H70zM40 72h12v12H40zM60 72h12v12H60zM80 72h10v12H80z` },
/* ---------------- gear, all eras ---------------- */
kit: { base: `M26 40h76v66H26z M52 40V26h24v14`, detail: `M64 56v34 M47 73h34`, stroke: ["detail"] },
pack: { base: `M34 38h60v70H34z M50 38V22h28v16`, detail: `M34 66h60 M50 78h28`, stroke: ["detail"] },
cell: { base: `M42 22h44v18h10v66H32V40h10z`, detail: `M52 58h24v30H52z`, mark: `M56 64h16v6H56z` },
key: { base: `M34 46a20 20 0 1 1 0 36 20 20 0 0 1 0-36z M54 60h56v10H88v14H78V70h-8v18H60V70z`,
detail: `M34 58a8 8 0 1 1 0 12 8 8 0 0 1 0-12z` },
disc: { base: `M64 20a44 44 0 1 1 0 88 44 44 0 0 1 0-88z`, detail: `M64 50a14 14 0 1 1 0 28 14 14 0 0 1 0-28z` },
scanner: { base: `M24 34h80v52H24z M38 96h52v10H38z`, detail: `M34 44h60v34H34z`,
mark: `M40 50h48M40 62h34M40 72h44`, stroke: ["mark"] },
lens: { base: `M56 20a34 34 0 1 1 0 68 34 34 0 0 1 0-68z M80 82l28 28`,
detail: `M56 34a20 20 0 1 1 0 40 20 20 0 0 1 0-40z` },
cog: { base: `M64 34a30 30 0 1 1 0 60 30 30 0 0 1 0-60z M58 8h12v18H58z M58 102h12v18H58z M8 58h18v12H8z M102 58h18v12h-18z`,
detail: `M64 52a12 12 0 1 1 0 24 12 12 0 0 1 0-24z` },
regulator: { base: `M20 40h88v48H20z`, detail: `M34 54h20v20H34z M74 54h20v20H74z`,
mark: `M54 64h20v4H54z M44 88v14M84 88v14`, stroke: ["mark"] },
radio: { base: `M28 52h72v56H28z M44 52V26h8v26`, detail: `M44 66a10 10 0 1 1 0 20 10 10 0 0 1 0-20z`,
mark: `M64 66h20M64 80h20`, stroke: ["mark"] },
flask: { base: `M52 14h24v34l24 50a12 12 0 0 1-10 18H38a12 12 0 0 1-10-18l24-50z`,
detail: `M40 78h48 M50 20h28`, stroke: ["detail"] },
lantern: { base: `M44 30h40v10H44z M38 40h52v58H38z M56 98h16v10H56z`,
detail: `M50 50h28v40H50z`, mark: `M58 60h12v20H58z` },
candle: { base: `M54 46h20v58H54z M44 104h40v10H44z`, detail: `M60 30c8 6 8 14 4 16-6-2-8-10-4-16z` },
compass: { base: `M64 16a48 48 0 1 1 0 96 48 48 0 0 1 0-96z`,
detail: `M64 34l12 30-12 30-12-30z`, mark: `M64 56a8 8 0 1 1 0 16 8 8 0 0 1 0-16z` },
spectacles: { base: `M20 56a20 20 0 1 1 0 20 20 20 0 0 1 0-20z M88 56a20 20 0 1 1 0 20 20 20 0 0 1 0-20z`,
detail: `M40 64h48`, stroke: ["detail"] },
camera: { base: `M18 44h92v56H18z M44 44l8-12h24l8 12`, detail: `M64 56a18 18 0 1 1 0 36 18 18 0 0 1 0-36z`,
mark: `M96 52h10v8H96z` },
binocs: { base: `M22 40h34v66H22z M72 40h34v66H72z`, detail: `M56 58h16v12H56z`,
mark: `M28 50h22v10H28z M78 50h22v10H78z` },
rope: { base: `M32 20c40 0 24 44 64 44M32 64c40 0 24 44 64 44`, stroke: ["base"] },
book: { base: `M24 20h38a14 14 0 0 1 14 14v74a14 14 0 0 0-14-12H24z M104 20H66a14 14 0 0 0-14 14v74a14 14 0 0 1 14-12h38z`,
detail: `M32 40h30M32 56h30M66 40h30M66 56h30`, stroke: ["detail"] },
folder: { base: `M14 30h40l10 14h50v66H14z`, detail: `M14 62h100 M28 78h60`, stroke: ["detail"] },
vial: { base: `M50 12h28v12l-8 6v72a10 10 0 0 1-12 0V30l-8-6z`, detail: `M58 70h12v24H58z` },
syringe: { base: `M18 92l40-40 18 18-40 40z M62 44l22-22 18 18-22 22z`,
detail: `M96 16l16 16 M30 96l-12 12`, stroke: ["detail"] },
mask_gas: { base: `M64 16c26 0 40 14 40 34 0 22-18 34-24 44-4 8-28 8-32 0-6-10-24-22-24-44 0-20 14-34 40-34z`,
detail: `M44 46a12 12 0 1 1 0 20 12 12 0 0 1 0-20z M84 46a12 12 0 1 1 0 20 12 12 0 0 1 0-20z`,
mark: `M54 84h20v14H54z` },
geiger: { base: `M26 46h58v50H26z M84 58h20v26H84z`, detail: `M36 56h38v14H36z`,
mark: `M40 80h30`, stroke: ["mark"] },
tape: { base: `M64 22a42 42 0 1 1 0 84 42 42 0 0 1 0-84z`, detail: `M64 50a14 14 0 1 1 0 28 14 14 0 0 1 0-28z`,
mark: `M22 64h84`, stroke: ["mark"] },
pocketwatch: { base: `M64 26a42 42 0 1 1 0 84 42 42 0 0 1 0-84z M56 8h16v18H56z`,
detail: `M64 44v24l16 10`, stroke: ["detail"] },
handaxe_tool: { base: `M40 92l40-56 12 8-40 56z`, detail: `M76 28l18-8 6 16-18 8z` },
chip: { base: `M34 34h60v60H34z`, detail: `M48 48h32v32H48z`,
mark: `M34 46H20M34 64H20M34 82H20M94 46h14M94 64h14M94 82h14`, stroke: ["mark"] },
drone: { base: `M52 52h24v24H52z`, detail: `M20 28a14 14 0 1 1 0 4zM94 28a14 14 0 1 1 0 4zM20 96a14 14 0 1 1 0 4zM94 96a14 14 0 1 1 0 4z`,
mark: `M34 40l18 12M94 40L76 52M34 88l18-12M94 88L76 76`, stroke: ["mark"] },
visor: { base: `M18 48h92v26a16 16 0 0 1-16 16H34a16 16 0 0 1-16-16z`,
detail: `M28 56h72v14H28z`, mark: `M36 60h20v6H36z` },
cannister: { base: `M44 20h40v88H44z`, detail: `M52 34h24v18H52z`, mark: `M44 62h40M44 78h40`, stroke: ["mark"] },
net: { base: `M20 20h88v88H20z`, detail: `M20 48h88M20 76h88M48 20v88M76 20v88`, stroke: ["detail"] },
manacles: { base: `M36 44a22 22 0 1 1 0 40 22 22 0 0 1 0-40z M92 44a22 22 0 1 1 0 40 22 22 0 0 1 0-40z`,
detail: `M58 62h12`, stroke: ["detail"] },
/* ---------------- powers: the Quiet Arts ---------------- */
thread: { base: `M20 100c30-70 58 40 88-30`, stroke: ["base"],
detail: `M20 100a6 6 0 1 1 0 2zM108 70a6 6 0 1 1 0 2z` },
echo: { base: `M64 64a10 10 0 1 1 0 2z`, detail: `M64 40a24 24 0 1 1 0 48 24 24 0 0 1 0-48zM64 22a42 42 0 1 1 0 84 42 42 0 0 1 0-84z`,
stroke: ["detail"] },
hollow: { base: `M64 12l48 28v48l-48 28-48-28V40z`, detail: `M64 40l22 13v26L64 92 42 79V53z` },
witness: { base: `M8 64c26-32 82-32 112 0-30 32-86 32-112 0z`, detail: `M64 44a20 20 0 1 1 0 40 20 20 0 0 1 0-40z`,
mark: `M64 56a8 8 0 1 1 0 16 8 8 0 0 1 0-16z` },
unmake: { base: `M28 28l72 72M100 28l-72 72`, stroke: ["base"],
detail: `M64 44a20 20 0 1 1 0 40 20 20 0 0 1 0-40z` },
ledger: { base: `M28 16h72v96H28z`, detail: `M40 36h48M40 52h48M40 68h30`, stroke: ["detail"],
mark: `M76 76l10 10 18-22`, },
/* ---------------- the rest, carried over ---------------- */
eye: { base: `M8 64c26-32 82-32 112 0-30 32-86 32-112 0z`, detail: `M64 44a20 20 0 1 1 0 40 20 20 0 0 1 0-40z` },
ear: { base: `M46 20c30-8 46 12 44 34-2 24-22 24-24 40s-16 20-26 12c-12-10-6-24 2-30 10-8-16-46 4-56z`, stroke: ["base"] },
brain: { base: `M40 28c-14 6-18 22-10 32-8 12-2 28 12 30 4 12 20 16 28 8 10 8 24 2 26-10 12-8 10-26-2-32 2-14-12-26-26-22-6-8-18-10-28-6z`,
detail: `M64 24v78`, stroke: ["base", "detail"] },
hand: { base: `M40 60V28a8 8 0 0 1 16 0v26 M56 54V20a8 8 0 0 1 16 0v34 M72 56V28a8 8 0 0 1 16 0v34c0 30-12 46-32 46s-32-14-32-34v-8a8 8 0 0 1 16 0`, stroke: ["base"] },
boot: { base: `M36 16h26v50l34 20v26H36z`, detail: `M36 76h26`, stroke: ["detail"] },
mask: { base: `M22 34h84v34c0 26-20 46-42 52-22-6-42-26-42-52z`, detail: `M44 56h14M70 56h14`, stroke: ["detail"] },
ring: { base: `M64 16a48 48 0 1 1 0 96 48 48 0 0 1 0-96z`, detail: `M64 42a22 22 0 1 1 0 44 22 22 0 0 1 0-44z` },
triune: { base: `M34 22a14 14 0 1 1 0 28 14 14 0 0 1 0-28z M94 22a14 14 0 1 1 0 28 14 14 0 0 1 0-28z M30 68h68v18H30z`,
detail: `M34 50l14 18h32l14-18 M38 86v30M64 86v30M90 86v30`, stroke: ["detail"] },
figure: { base: `M64 14a18 18 0 1 1 0 36 18 18 0 0 1 0-36z M40 56h48v40H40z`,
detail: `M46 96l-6 24M82 96l6 24`, stroke: ["detail"] },
target: { base: `M64 12a52 52 0 1 1 0 104 52 52 0 0 1 0-104z`,
detail: `M64 40a24 24 0 1 1 0 48 24 24 0 0 1 0-48z`, mark: `M64 58a6 6 0 1 1 0 12 6 6 0 0 1 0-12z` },
wave: { base: `M10 64c14-26 28-26 42 0s28 26 42 0 22-14 24-6`, stroke: ["base"] },
door: { base: `M30 16h68v96H30z`, detail: `M84 60a5 5 0 1 1 0 10 5 5 0 0 1 0-10z` }
};
const ERAS = { antique: 1, modern: 1, future: 1, anomalous: 1 };
function layers(glyph, ink, mid, rim) {
const g = typeof G[glyph] === "string" ? { base: G[glyph] } : (G[glyph] ?? G.disc);
const stroke = new Set(g.stroke ?? []);
const draw = (d, fill, isStroke, w = 9) => !d ? "" : isStroke
? `<path d="${d}" fill="none" stroke="${fill}" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"/>`
: `<path d="${d}" fill="${fill}" stroke="none"/>`;
return [
draw(g.base, ink, stroke.has("base")),
draw(g.detail, mid, stroke.has("detail"), 6),
draw(g.mark, rim, stroke.has("mark"), 6)
].join("\n");
}
function svg(glyph, hue, { dark = false, era = "modern" } = {}) {
const muted = era === "antique";
const bg1 = hsl(hue, muted ? 38 : 62, dark ? 22 : (muted ? 30 : 34));
const bg2 = hsl(hue, muted ? 44 : 68, dark ? 14 : 22);
const ink = hsl(hue, muted ? 55 : 90, muted ? 82 : 88);
const mid = hsl(hue, muted ? 40 : 70, muted ? 52 : 46);
const rim = hsl(hue, 80, 64);
// era tab: a corner treatment, so the century reads before the name does
let corner = "", scan = "", rimPath =
`<rect x="5" y="5" width="${S - 10}" height="${S - 10}" rx="16" fill="none" stroke="${rim}" stroke-width="4"/>`;
if (era === "antique") corner = `<path d="M0 0h30L0 30z" fill="${bg2}"/>`;
if (era === "future") {
corner = `<path d="M${S - 32} 0H${S}v32z" fill="${rim}" opacity="0.55"/>`;
scan = `<path d="M12 ${S - 26}h${S - 24}" stroke="${rim}" stroke-width="2" opacity="0.7"/>`;
}
if (era === "anomalous") {
rimPath = `<rect x="5" y="5" width="${S - 10}" height="${S - 10}" rx="16" fill="none"
stroke="${rim}" stroke-width="4" stroke-dasharray="26 13" stroke-dashoffset="7"/>`;
}
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${S} ${S}" width="${S}" height="${S}">
<rect width="${S}" height="${S}" rx="20" fill="${bg1}"/>
${corner}
${rimPath}
${layers(glyph, ink, mid, rim)}
${scan}
</svg>`;
}
export async function writeIcon(name, glyph, family, opts) {
const fam = FAMILY[family] ?? FAMILY.gear;
await mkdir(OUT, { recursive: true });
const hue = (fam.h + (opts?.shift ?? 0) + 360) % 360;
await writeFile(path.join(OUT, `${name}.svg`), svg(glyph, hue, opts), "utf8");
return `systems/ringbrp/icons/${name}.svg`;
}
export { FAMILY, G, ERAS };
/* ---------------- main ---------------- */
import { ICON_MAP } from "./icons.mjs";
if (import.meta.url === `file://${process.argv[1]}`) {
let n = 0, byEra = {};
for (const [name, spec] of Object.entries(ICON_MAP)) {
const [glyph, family, shift, era = "modern"] = spec;
await writeIcon(name, glyph, family, { shift, era });
byEra[era] = (byEra[era] ?? 0) + 1;
n++;
}
console.log(`Custodians | ${n} icons written to icons/`);
console.log(" by era: " + Object.entries(byEra).map(([k, v]) => `${k} ${v}`).join(" · "));
}