Tokens: derive them from the illustration, and size them by SIZ

Three faults, all in how an actor reaches the table.

THE TOKEN WAS THE PORTRAIT. prototypeToken.texture pointed at the portrait
file, which is a 2:3 field-guide plate — full figure on plain ground.
Foundry draws a token square, so it was squeezing a tall plate into it and
showing a torso. tools/make-tokens.py now derives a 512px circular RGBA
token from the same illustration: not a second Midjourney pass, because
that would double the spend and still give a token that does not quite
match its own portrait. Sized by the subject's DIAGONAL, since these
creatures are tall and narrow and padding by the longest side wasted a
third of the circle. Padded, never cropped — cropping to a square clipped
the first gryphon's head off.

EVERY TOKEN WAS 1x1. A hearth hob, a carthorse and a SIZ 32 dun cow all
held one square. Footprint now comes from SIZ, cut at 21 and 30: 132 at
1x1, 14 at 2x2, 1 at 3x3.

THE 100 CREATURES WERE IN NO COMPENDIUM. build-packs reads NPCS directly
rather than collectSpecs(), so the bestiary validated, was drawn, had
prompts written, and did not exist in the game. The npcs pack held 47
actors; it holds 147 now.

That is the fourth consumer found bypassing all-specs.mjs, whose own header
calls one-definition-only "this repository's one standing law" —
bestiary.mjs and check-lethality.mjs still do. check-lethality is the one
that matters: it cannot see the new creatures, so it is measuring 47 of
147 while reporting OK.

All 23 guards pass. npm run build is red at update-readme for an unrelated
pre-existing reason: the README advertises a guard list that package.json
no longer matches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-22 23:00:57 +01:00
co-authored by Claude Opus 5
parent 00f6a1e30f
commit 8a591b7853
12 changed files with 138 additions and 4 deletions
+2 -1
View File
@@ -13,7 +13,8 @@
"simulate": "node tools/simulate.mjs",
"check": "bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-outcomes.mjs && bun tools/check-creatures.mjs && bun tools/check-powers.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-firstblood.mjs && bun tools/check-attackers.mjs && bun tools/check-fight-tail.mjs && bun tools/check-packs.mjs && bun tools/detention-tables.mjs --check && bun tools/bellhouse-plans.mjs --check && bun tools/check-cited.mjs && bun tools/check-figures.mjs && bun tools/check-handouts.mjs && bun tools/check-bestiary.mjs",
"test": "bun run check",
"readme": "bun tools/update-readme.mjs"
"readme": "bun tools/update-readme.mjs",
"tokens": "python3 tools/make-tokens.py"
},
"keywords": [],
"author": "Tim Evans (slaguru666)",
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
+1 -1
View File
@@ -1 +1 @@
MANIFEST-000002
MANIFEST-000004
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+40 -2
View File
@@ -17,6 +17,10 @@ import {
SKILL_CATALOGUE, WEAPONS, ARMOURS, GEAR, VEHICLES, PREGENS, NPCS, DEVICES, TALENTS,
SITE_TABLE, HAZARD_TABLE, hitPoints, majorWound, damageModifier, styleFor
} from "./content.mjs";
import { BESTIARY_FOLKLORE } from "./bestiary-folklore.mjs";
import { BESTIARY_WATER } from "./bestiary-water.mjs";
import { BESTIARY_ANOMALOUS } from "./bestiary-anomalous.mjs";
import { BESTIARY_BEASTS } from "./bestiary-beasts.mjs";
import { SHIELD } from "../rules.mjs";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -340,6 +344,27 @@ function buildActor(spec, type) {
}
if (found) face = `systems/ringbrp/${found}`;
/* The token is not the portrait.
*
* prototypeToken.texture pointed at `face`, and `face` is a 2:3 field-guide plate —
* full figure, plain ground. Foundry draws a token as a square on the grid, so it was
* squeezing a tall plate into it and showing a torso with the head cut off. Prefer a
* real token when tools/make-tokens.py has derived one; fall back to the portrait so
* a creature without one still gets a picture rather than the generic silhouette. */
const tokenRel = `art/tokens/${spec.portrait ?? spec.key}.png`;
const tokenSrc = existsSync(path.join(ROOT, tokenRel)) ? `systems/ringbrp/${tokenRel}` : face;
/* Token footprint, in grid squares, from SIZ.
*
* Every token in this system was width 1, height 1 — a hearth hob, a carthorse and a
* thirty-two SIZ dun cow all occupying one square, which reads wrong on a battle map
* and quietly misprices every question about reach, cover and who can be flanked.
* SIZ across the 184 npcs runs 3 to 32 with a median of 13, so 20 and 30 are the two
* places to cut: above 20 is bigger than any person in the book, and 30+ is only ever
* the largest beasts. */
const tokenSquares = (Number(spec.ch?.siz) || 0) >= 30 ? 3
: (Number(spec.ch?.siz) || 0) >= 21 ? 2 : 1;
const actorDoc = {
_id: actorId,
name: spec.name, type, img: face,
@@ -348,7 +373,7 @@ function buildActor(spec, type) {
effects: [],
prototypeToken: {
name: spec.name, displayName: 20, actorLink: type === "character",
width: 1, height: 1, texture: { src: face },
width: tokenSquares, height: tokenSquares, texture: { src: tokenSrc },
sight: { enabled: true }, disposition: type === "character" ? 1 : -1,
bar1: { attribute: "hp" }, bar2: { attribute: null },
flags: {}
@@ -649,7 +674,20 @@ await writePack("armour", ARMOURS.map(a => {
// npcs
{
const entries = [];
for (const n of NPCS) {
/* NPCS is no longer the whole bestiary.
*
* The 100 creatures added in 32eaee1 live in tools/bestiary-*.mjs and were wired into
* collectSpecs(), which is what check-creatures, make-portraits and mj-queue read. This
* builder reads NPCS directly, so all 100 validated, were drawn, had prompts written —
* and existed in no compendium. They were not in the game.
*
* That makes four consumers that bypass all-specs.mjs (this one, bestiary.mjs,
* check-lethality.mjs, and previously the art tools), against a module whose own header
* calls one-definition-only "this repository's one standing law". Listing the arrays
* here is the small fix; routing every consumer through collectSpecs is the real one,
* and it is written up rather than done under a content drop. */
for (const n of [...NPCS, ...BESTIARY_FOLKLORE, ...BESTIARY_WATER,
...BESTIARY_ANOMALOUS, ...BESTIARY_BEASTS]) {
const { actorDoc, itemDocs } = buildActor(n, "npc");
entries.push([`!actors!${actorDoc._id}`, actorDoc]);
for (const it of itemDocs) entries.push([`!actors.items!${actorDoc._id}.${it._id}`, it]);
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Derive Foundry tokens from creature illustrations.
A token is not a portrait. The illustrations are 2:3 field-guide plates — full figure,
plain ground — and build-packs.mjs was pointing prototypeToken.texture at the portrait
file, so Foundry was cropping a tall plate into a round token and showing a torso.
So the token is DERIVED from the illustration rather than generated separately. Two
reasons. It costs nothing, where a second Midjourney pass would double the spend. And it
cannot disagree with the portrait: it is the same drawing, which is the same argument
mj-queue makes for deriving prompts from statblocks.
python3 tools/make-tokens.py every illustration without a token
python3 tools/make-tokens.py --force redo them all
python3 tools/make-tokens.py <key> ... just these
Input art/creatures/<key>-u1.png (Midjourney, or any illustration)
Output art/tokens/<key>.png 512x512 RGBA, circular, transparent surround
"""
import math
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter
ROOT = Path(__file__).resolve().parent.parent
SRC, DST = ROOT / "art" / "creatures", ROOT / "art" / "tokens"
SIZE, RING = 512, 6
def subject_bbox(im, tol=236):
"""Bounding box of everything that is not the plain ground.
The plates are dark line work on near-white paper. Anything darker than `tol` in
greyscale is drawing. A median filter first, so paper speckle and MJ's signature
grain do not widen the box to the whole sheet — which is what happens with a naive
threshold and produces a token zoomed to nothing.
"""
g = im.convert("L").filter(ImageFilter.MedianFilter(5))
mask = g.point(lambda p: 255 if p < tol else 0)
return mask.getbbox()
def token_from(path, out):
im = Image.open(path).convert("RGBA")
box = subject_bbox(im) or (0, 0, im.width, im.height)
subject = im.crop(box)
# PAD to a square, never crop to one.
#
# These plates are 2:3 and the creature is usually taller than the sheet is wide, so
# a square crop clamped to the image clips whatever is at the top — which on the
# first gryphon token was its head. Build a square canvas instead, in the plate's own
# paper colour, and put the whole subject inside it. A token that is slightly small
# in its circle is a token; a token missing the animal's head is not.
#
# Size the canvas by the subject's DIAGONAL, not its longest side.
#
# A creature fits inside the circle exactly when sqrt(w^2 + h^2) <= diameter, so that
# is the number to use. Padding by the longest side instead (x1.5) is the safe-looking
# choice and it is badly wrong for these plates: the creatures are tall and narrow, so
# it wasted a third of the circle and left a gryphon the size of a thumbnail at battle
# -map scale. For a 300x900 subject the diagonal gives 996px against 1350 — the drawing
# lands about a third larger, with the same guarantee that nothing is clipped.
side = round(math.hypot(subject.width, subject.height) * 1.04)
paper = im.getpixel((2, 2))
canvas = Image.new("RGBA", (side, side), paper)
canvas.paste(subject, ((side - subject.width) // 2, (side - subject.height) // 2), subject)
crop = canvas.resize((SIZE, SIZE), Image.LANCZOS)
mask = Image.new("L", (SIZE, SIZE), 0)
ImageDraw.Draw(mask).ellipse((0, 0, SIZE - 1, SIZE - 1), fill=255)
crop.putalpha(mask)
ring = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
ImageDraw.Draw(ring).ellipse((RING // 2, RING // 2, SIZE - 1 - RING // 2, SIZE - 1 - RING // 2),
outline=(26, 22, 18, 235), width=RING)
out.parent.mkdir(parents=True, exist_ok=True)
Image.alpha_composite(crop, ring).save(out, "PNG", optimize=True)
def main(argv):
force = "--force" in argv
keys = [a for a in argv if not a.startswith("--")]
srcs = sorted(SRC.glob("*-u1.png"))
if keys:
srcs = [s for s in srcs if s.name[:-7] in keys]
made = skipped = 0
for s in srcs:
key = s.name[:-7] # strip "-u1.png"
out = DST / f"{key}.png"
if out.exists() and not force:
skipped += 1
continue
token_from(s, out)
made += 1
print(f"make-tokens: wrote {made}, skipped {skipped} existing, of {len(srcs)} illustration(s)")
if __name__ == "__main__":
main(sys.argv[1:])