From 9ddea4372031a3055dc373d324fa0b0aea322249 Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:32:53 +0100 Subject: [PATCH] make-portraits: every creature gets art it cannot disagree with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- art/portraits/auditor.svg | 26 ++ art/portraits/barghest.svg | 26 ++ art/portraits/boggart.svg | 20 ++ art/portraits/bp_callum.svg | 20 ++ art/portraits/bp_conductor.svg | 26 ++ art/portraits/bp_elin.svg | 20 ++ art/portraits/bp_inspector.svg | 26 ++ art/portraits/bp_leah.svg | 20 ++ art/portraits/bp_mara.svg | 20 ++ art/portraits/bp_martin.svg | 20 ++ art/portraits/bp_nia.svg | 20 ++ art/portraits/bp_owen.svg | 20 ++ art/portraits/bp_sennen.svg | 20 ++ art/portraits/carrion_file.svg | 27 ++ art/portraits/ceyla.svg | 20 ++ art/portraits/churchgrim.svg | 27 ++ art/portraits/cleaner.svg | 20 ++ art/portraits/cleaner_marksman.svg | 20 ++ art/portraits/cuckoo_array.svg | 27 ++ art/portraits/dunn.svg | 20 ++ art/portraits/duplicate.svg | 20 ++ art/portraits/hollow_man.svg | 26 ++ art/portraits/keepers.svg | 20 ++ art/portraits/kelpie.svg | 26 ++ art/portraits/la_edith.svg | 20 ++ art/portraits/la_enderby.svg | 20 ++ art/portraits/la_marrow.svg | 26 ++ art/portraits/la_nowak.svg | 20 ++ art/portraits/la_sammy.svg | 20 ++ art/portraits/la_tunnicliffe.svg | 20 ++ art/portraits/long_walker.svg | 27 ++ art/portraits/od_bewley.svg | 20 ++ art/portraits/od_ferrand.svg | 20 ++ art/portraits/od_kearsley.svg | 20 ++ art/portraits/od_odame.svg | 20 ++ art/portraits/od_vasey.svg | 20 ++ art/portraits/quarantine_unit.svg | 26 ++ art/portraits/quiet_neighbours_npc.svg | 20 ++ art/portraits/rakhi.svg | 20 ++ art/portraits/redcap.svg | 26 ++ art/portraits/revenant.svg | 27 ++ art/portraits/surveyor.svg | 26 ++ art/portraits/the_choir.svg | 26 ++ art/portraits/the_tenant.svg | 20 ++ art/portraits/the_understudy.svg | 26 ++ art/portraits/tomas.svg | 20 ++ art/portraits/tt_ambrose.svg | 20 ++ art/portraits/tt_bewley.svg | 20 ++ art/portraits/tt_bligh.svg | 20 ++ art/portraits/tt_coat.svg | 26 ++ art/portraits/tt_cordera_man.svg | 20 ++ art/portraits/tt_kell.svg | 20 ++ art/portraits/tt_merritt.svg | 26 ++ art/portraits/tt_prosser.svg | 20 ++ art/portraits/tt_rance.svg | 20 ++ art/portraits/tt_reyes.svg | 20 ++ art/portraits/tt_shard.svg | 20 ++ art/portraits/tt_tom_doyle.svg | 20 ++ art/portraits/tt_vane.svg | 20 ++ art/portraits/tt_writman.svg | 20 ++ package.json | 2 + tools/build-packs.mjs | 33 +- tools/make-portraits.mjs | 410 +++++++++++++++++++++++++ 63 files changed, 1758 insertions(+), 6 deletions(-) create mode 100644 art/portraits/auditor.svg create mode 100644 art/portraits/barghest.svg create mode 100644 art/portraits/boggart.svg create mode 100644 art/portraits/bp_callum.svg create mode 100644 art/portraits/bp_conductor.svg create mode 100644 art/portraits/bp_elin.svg create mode 100644 art/portraits/bp_inspector.svg create mode 100644 art/portraits/bp_leah.svg create mode 100644 art/portraits/bp_mara.svg create mode 100644 art/portraits/bp_martin.svg create mode 100644 art/portraits/bp_nia.svg create mode 100644 art/portraits/bp_owen.svg create mode 100644 art/portraits/bp_sennen.svg create mode 100644 art/portraits/carrion_file.svg create mode 100644 art/portraits/ceyla.svg create mode 100644 art/portraits/churchgrim.svg create mode 100644 art/portraits/cleaner.svg create mode 100644 art/portraits/cleaner_marksman.svg create mode 100644 art/portraits/cuckoo_array.svg create mode 100644 art/portraits/dunn.svg create mode 100644 art/portraits/duplicate.svg create mode 100644 art/portraits/hollow_man.svg create mode 100644 art/portraits/keepers.svg create mode 100644 art/portraits/kelpie.svg create mode 100644 art/portraits/la_edith.svg create mode 100644 art/portraits/la_enderby.svg create mode 100644 art/portraits/la_marrow.svg create mode 100644 art/portraits/la_nowak.svg create mode 100644 art/portraits/la_sammy.svg create mode 100644 art/portraits/la_tunnicliffe.svg create mode 100644 art/portraits/long_walker.svg create mode 100644 art/portraits/od_bewley.svg create mode 100644 art/portraits/od_ferrand.svg create mode 100644 art/portraits/od_kearsley.svg create mode 100644 art/portraits/od_odame.svg create mode 100644 art/portraits/od_vasey.svg create mode 100644 art/portraits/quarantine_unit.svg create mode 100644 art/portraits/quiet_neighbours_npc.svg create mode 100644 art/portraits/rakhi.svg create mode 100644 art/portraits/redcap.svg create mode 100644 art/portraits/revenant.svg create mode 100644 art/portraits/surveyor.svg create mode 100644 art/portraits/the_choir.svg create mode 100644 art/portraits/the_tenant.svg create mode 100644 art/portraits/the_understudy.svg create mode 100644 art/portraits/tomas.svg create mode 100644 art/portraits/tt_ambrose.svg create mode 100644 art/portraits/tt_bewley.svg create mode 100644 art/portraits/tt_bligh.svg create mode 100644 art/portraits/tt_coat.svg create mode 100644 art/portraits/tt_cordera_man.svg create mode 100644 art/portraits/tt_kell.svg create mode 100644 art/portraits/tt_merritt.svg create mode 100644 art/portraits/tt_prosser.svg create mode 100644 art/portraits/tt_rance.svg create mode 100644 art/portraits/tt_reyes.svg create mode 100644 art/portraits/tt_shard.svg create mode 100644 art/portraits/tt_tom_doyle.svg create mode 100644 art/portraits/tt_vane.svg create mode 100644 art/portraits/tt_writman.svg create mode 100644 tools/make-portraits.mjs diff --git a/art/portraits/auditor.svg b/art/portraits/auditor.svg new file mode 100644 index 0000000..9ed2fff --- /dev/null +++ b/art/portraits/auditor.svg @@ -0,0 +1,26 @@ + + The auditor + The auditor — baseline, SIZ 11, 7 hit locations, natural armour 4, modern. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/art/portraits/barghest.svg b/art/portraits/barghest.svg new file mode 100644 index 0000000..16ca5c2 --- /dev/null +++ b/art/portraits/barghest.svg @@ -0,0 +1,26 @@ + + Barghest + Barghest — baseline, SIZ 15, 7 hit locations, natural armour 3, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/art/portraits/boggart.svg b/art/portraits/boggart.svg new file mode 100644 index 0000000..22cff69 --- /dev/null +++ b/art/portraits/boggart.svg @@ -0,0 +1,20 @@ + + Boggart + Boggart — baseline, SIZ 5, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/bp_callum.svg b/art/portraits/bp_callum.svg new file mode 100644 index 0000000..6ffef46 --- /dev/null +++ b/art/portraits/bp_callum.svg @@ -0,0 +1,20 @@ + + Callum Pike + Callum Pike — baseline, SIZ 15, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/bp_conductor.svg b/art/portraits/bp_conductor.svg new file mode 100644 index 0000000..521e36d --- /dev/null +++ b/art/portraits/bp_conductor.svg @@ -0,0 +1,26 @@ + + The Conductor + The Conductor — baseline, SIZ 14, 7 hit locations, natural armour 4, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/art/portraits/bp_elin.svg b/art/portraits/bp_elin.svg new file mode 100644 index 0000000..cb6517b --- /dev/null +++ b/art/portraits/bp_elin.svg @@ -0,0 +1,20 @@ + + Dr Elin Shaw + Dr Elin Shaw — baseline, SIZ 11, 7 hit locations, natural armour 0, modern. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/bp_inspector.svg b/art/portraits/bp_inspector.svg new file mode 100644 index 0000000..5d9a290 --- /dev/null +++ b/art/portraits/bp_inspector.svg @@ -0,0 +1,26 @@ + + Ticket inspector + Ticket inspector — baseline, SIZ 12, 7 hit locations, natural armour 2, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/art/portraits/bp_leah.svg b/art/portraits/bp_leah.svg new file mode 100644 index 0000000..bd7837e --- /dev/null +++ b/art/portraits/bp_leah.svg @@ -0,0 +1,20 @@ + + Leah Hall + Leah Hall — baseline, SIZ 11, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/bp_mara.svg b/art/portraits/bp_mara.svg new file mode 100644 index 0000000..2b9a932 --- /dev/null +++ b/art/portraits/bp_mara.svg @@ -0,0 +1,20 @@ + + Mara Venn + Mara Venn — baseline, SIZ 12, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/bp_martin.svg b/art/portraits/bp_martin.svg new file mode 100644 index 0000000..e37388a --- /dev/null +++ b/art/portraits/bp_martin.svg @@ -0,0 +1,20 @@ + + Martin Cole + Martin Cole — baseline, SIZ 12, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/bp_nia.svg b/art/portraits/bp_nia.svg new file mode 100644 index 0000000..2533361 --- /dev/null +++ b/art/portraits/bp_nia.svg @@ -0,0 +1,20 @@ + + Nia Mercer + Nia Mercer — baseline, SIZ 12, 7 hit locations, natural armour 0, modern. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/bp_owen.svg b/art/portraits/bp_owen.svg new file mode 100644 index 0000000..a601893 --- /dev/null +++ b/art/portraits/bp_owen.svg @@ -0,0 +1,20 @@ + + Owen Dey + Owen Dey — baseline, SIZ 12, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/bp_sennen.svg b/art/portraits/bp_sennen.svg new file mode 100644 index 0000000..6f1a6bb --- /dev/null +++ b/art/portraits/bp_sennen.svg @@ -0,0 +1,20 @@ + + Sennen Roake + Sennen Roake — baseline, SIZ 10, 7 hit locations, natural armour 0, modern. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/carrion_file.svg b/art/portraits/carrion_file.svg new file mode 100644 index 0000000..a11906b --- /dev/null +++ b/art/portraits/carrion_file.svg @@ -0,0 +1,27 @@ + + The carrion file + The carrion file — baseline, SIZ 9, 7 hit locations, natural armour 6, anomalous. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/art/portraits/ceyla.svg b/art/portraits/ceyla.svg new file mode 100644 index 0000000..2d612f1 --- /dev/null +++ b/art/portraits/ceyla.svg @@ -0,0 +1,20 @@ + + Ceyla Marspont + Ceyla Marspont — baseline, SIZ 13, 7 hit locations, natural armour 0, modern. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/churchgrim.svg b/art/portraits/churchgrim.svg new file mode 100644 index 0000000..1895dfe --- /dev/null +++ b/art/portraits/churchgrim.svg @@ -0,0 +1,27 @@ + + Church grim + Church grim — baseline, SIZ 14, 7 hit locations, natural armour 5, anomalous. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/art/portraits/cleaner.svg b/art/portraits/cleaner.svg new file mode 100644 index 0000000..c8de275 --- /dev/null +++ b/art/portraits/cleaner.svg @@ -0,0 +1,20 @@ + + Cleaner (contractor) + Cleaner (contractor) — baseline, SIZ 12, 7 hit locations, natural armour 0, modern. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/cleaner_marksman.svg b/art/portraits/cleaner_marksman.svg new file mode 100644 index 0000000..845c88b --- /dev/null +++ b/art/portraits/cleaner_marksman.svg @@ -0,0 +1,20 @@ + + Cleaner (marksman) + Cleaner (marksman) — baseline, SIZ 12, 7 hit locations, natural armour 0, modern. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/cuckoo_array.svg b/art/portraits/cuckoo_array.svg new file mode 100644 index 0000000..207beac --- /dev/null +++ b/art/portraits/cuckoo_array.svg @@ -0,0 +1,27 @@ + + Cuckoo array + Cuckoo array — baseline, SIZ 20, 7 hit locations, natural armour 7, anomalous. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/art/portraits/dunn.svg b/art/portraits/dunn.svg new file mode 100644 index 0000000..139df54 --- /dev/null +++ b/art/portraits/dunn.svg @@ -0,0 +1,20 @@ + + Dunn Halvorsen + Dunn Halvorsen — baseline, SIZ 10, 7 hit locations, natural armour 0, modern. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/duplicate.svg b/art/portraits/duplicate.svg new file mode 100644 index 0000000..6b0ae8f --- /dev/null +++ b/art/portraits/duplicate.svg @@ -0,0 +1,20 @@ + + Duplicate (of an agent) + Duplicate (of an agent) — baseline, SIZ 12, 7 hit locations, natural armour 0, modern. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/hollow_man.svg b/art/portraits/hollow_man.svg new file mode 100644 index 0000000..655693a --- /dev/null +++ b/art/portraits/hollow_man.svg @@ -0,0 +1,26 @@ + + Hollow man + Hollow man — baseline, SIZ 12, 7 hit locations, natural armour 1, modern. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/art/portraits/keepers.svg b/art/portraits/keepers.svg new file mode 100644 index 0000000..7d48454 --- /dev/null +++ b/art/portraits/keepers.svg @@ -0,0 +1,20 @@ + + The Keepers (waystation) + The Keepers (waystation) — baseline, SIZ 12, 7 hit locations, natural armour 0, modern. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/kelpie.svg b/art/portraits/kelpie.svg new file mode 100644 index 0000000..6e57fc6 --- /dev/null +++ b/art/portraits/kelpie.svg @@ -0,0 +1,26 @@ + + Kelpie + Kelpie — baseline, SIZ 18, 7 hit locations, natural armour 2, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/art/portraits/la_edith.svg b/art/portraits/la_edith.svg new file mode 100644 index 0000000..cf64d74 --- /dev/null +++ b/art/portraits/la_edith.svg @@ -0,0 +1,20 @@ + + Edith Crane + Edith Crane — baseline, SIZ 10, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/la_enderby.svg b/art/portraits/la_enderby.svg new file mode 100644 index 0000000..7f141d1 --- /dev/null +++ b/art/portraits/la_enderby.svg @@ -0,0 +1,20 @@ + + Sister Enderby + Sister Enderby — baseline, SIZ 11, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/la_marrow.svg b/art/portraits/la_marrow.svg new file mode 100644 index 0000000..d94ca98 --- /dev/null +++ b/art/portraits/la_marrow.svg @@ -0,0 +1,26 @@ + + Old Marrow + Old Marrow — baseline, SIZ 22, 7 hit locations, natural armour 2, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/art/portraits/la_nowak.svg b/art/portraits/la_nowak.svg new file mode 100644 index 0000000..2d2d7ae --- /dev/null +++ b/art/portraits/la_nowak.svg @@ -0,0 +1,20 @@ + + Beata Nowak + Beata Nowak — baseline, SIZ 11, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/la_sammy.svg b/art/portraits/la_sammy.svg new file mode 100644 index 0000000..95b68f4 --- /dev/null +++ b/art/portraits/la_sammy.svg @@ -0,0 +1,20 @@ + + Sammy, the boy in the wall + Sammy, the boy in the wall — baseline, SIZ 6, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/la_tunnicliffe.svg b/art/portraits/la_tunnicliffe.svg new file mode 100644 index 0000000..27cd1f9 --- /dev/null +++ b/art/portraits/la_tunnicliffe.svg @@ -0,0 +1,20 @@ + + Vic Tunnicliffe + Vic Tunnicliffe — baseline, SIZ 13, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/long_walker.svg b/art/portraits/long_walker.svg new file mode 100644 index 0000000..c4c57e2 --- /dev/null +++ b/art/portraits/long_walker.svg @@ -0,0 +1,27 @@ + + Long walker + Long walker — baseline, SIZ 22, 7 hit locations, natural armour 10, anomalous. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/art/portraits/od_bewley.svg b/art/portraits/od_bewley.svg new file mode 100644 index 0000000..665882b --- /dev/null +++ b/art/portraits/od_bewley.svg @@ -0,0 +1,20 @@ + + Mr Bewley + Mr Bewley — baseline, SIZ 12, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/od_ferrand.svg b/art/portraits/od_ferrand.svg new file mode 100644 index 0000000..ba44092 --- /dev/null +++ b/art/portraits/od_ferrand.svg @@ -0,0 +1,20 @@ + + Mrs Ivy Ferrand + Mrs Ivy Ferrand — baseline, SIZ 11, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/od_kearsley.svg b/art/portraits/od_kearsley.svg new file mode 100644 index 0000000..de0730a --- /dev/null +++ b/art/portraits/od_kearsley.svg @@ -0,0 +1,20 @@ + + Douglas Kearsley + Douglas Kearsley — baseline, SIZ 11, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/od_odame.svg b/art/portraits/od_odame.svg new file mode 100644 index 0000000..cef077b --- /dev/null +++ b/art/portraits/od_odame.svg @@ -0,0 +1,20 @@ + + Councillor Hazel Odame + Councillor Hazel Odame — baseline, SIZ 11, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/od_vasey.svg b/art/portraits/od_vasey.svg new file mode 100644 index 0000000..3466f0b --- /dev/null +++ b/art/portraits/od_vasey.svg @@ -0,0 +1,20 @@ + + Tony Vasey + Tony Vasey — baseline, SIZ 13, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/quarantine_unit.svg b/art/portraits/quarantine_unit.svg new file mode 100644 index 0000000..53e5f74 --- /dev/null +++ b/art/portraits/quarantine_unit.svg @@ -0,0 +1,26 @@ + + Quarantine unit + Quarantine unit — baseline, SIZ 14, 7 hit locations, natural armour 9, modern. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/art/portraits/quiet_neighbours_npc.svg b/art/portraits/quiet_neighbours_npc.svg new file mode 100644 index 0000000..236f4e3 --- /dev/null +++ b/art/portraits/quiet_neighbours_npc.svg @@ -0,0 +1,20 @@ + + The Quiet Neighbours + The Quiet Neighbours — baseline, SIZ 8, 7 hit locations, natural armour 0, modern. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/rakhi.svg b/art/portraits/rakhi.svg new file mode 100644 index 0000000..0c7cab0 --- /dev/null +++ b/art/portraits/rakhi.svg @@ -0,0 +1,20 @@ + + Rakhi Oyelaran-Vost + Rakhi Oyelaran-Vost — baseline, SIZ 19, 7 hit locations, natural armour 0, modern. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/redcap.svg b/art/portraits/redcap.svg new file mode 100644 index 0000000..ecd2bdb --- /dev/null +++ b/art/portraits/redcap.svg @@ -0,0 +1,26 @@ + + Redcap + Redcap — baseline, SIZ 7, 7 hit locations, natural armour 2, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/art/portraits/revenant.svg b/art/portraits/revenant.svg new file mode 100644 index 0000000..5bcfb8a --- /dev/null +++ b/art/portraits/revenant.svg @@ -0,0 +1,27 @@ + + Revenant + Revenant — baseline, SIZ 13, 7 hit locations, natural armour 4, anomalous. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/art/portraits/surveyor.svg b/art/portraits/surveyor.svg new file mode 100644 index 0000000..5f89b9b --- /dev/null +++ b/art/portraits/surveyor.svg @@ -0,0 +1,26 @@ + + Surveyor + Surveyor — baseline, SIZ 13, 7 hit locations, natural armour 8, modern. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/art/portraits/the_choir.svg b/art/portraits/the_choir.svg new file mode 100644 index 0000000..c4d2f36 --- /dev/null +++ b/art/portraits/the_choir.svg @@ -0,0 +1,26 @@ + + The choir + The choir — baseline, SIZ 11, 7 hit locations, natural armour 1, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/art/portraits/the_tenant.svg b/art/portraits/the_tenant.svg new file mode 100644 index 0000000..fee525e --- /dev/null +++ b/art/portraits/the_tenant.svg @@ -0,0 +1,20 @@ + + The tenant + The tenant — baseline, SIZ 10, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/the_understudy.svg b/art/portraits/the_understudy.svg new file mode 100644 index 0000000..a820a3b --- /dev/null +++ b/art/portraits/the_understudy.svg @@ -0,0 +1,26 @@ + + The understudy + The understudy — baseline, SIZ 12, 7 hit locations, natural armour 2, modern. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/art/portraits/tomas.svg b/art/portraits/tomas.svg new file mode 100644 index 0000000..bf56ab8 --- /dev/null +++ b/art/portraits/tomas.svg @@ -0,0 +1,20 @@ + + Tomas Ekwueme + Tomas Ekwueme — baseline, SIZ 12, 7 hit locations, natural armour 0, modern. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/tt_ambrose.svg b/art/portraits/tt_ambrose.svg new file mode 100644 index 0000000..5ef7b51 --- /dev/null +++ b/art/portraits/tt_ambrose.svg @@ -0,0 +1,20 @@ + + Josiah Ambrose + Josiah Ambrose — baseline, SIZ 14, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/tt_bewley.svg b/art/portraits/tt_bewley.svg new file mode 100644 index 0000000..a204aae --- /dev/null +++ b/art/portraits/tt_bewley.svg @@ -0,0 +1,20 @@ + + Mr Bewley + Mr Bewley — baseline, SIZ 11, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/tt_bligh.svg b/art/portraits/tt_bligh.svg new file mode 100644 index 0000000..c519792 --- /dev/null +++ b/art/portraits/tt_bligh.svg @@ -0,0 +1,20 @@ + + Hester Bligh + Hester Bligh — baseline, SIZ 11, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/tt_coat.svg b/art/portraits/tt_coat.svg new file mode 100644 index 0000000..213956c --- /dev/null +++ b/art/portraits/tt_coat.svg @@ -0,0 +1,26 @@ + + Company guard + Company guard — baseline, SIZ 12, 7 hit locations, natural armour 1, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/art/portraits/tt_cordera_man.svg b/art/portraits/tt_cordera_man.svg new file mode 100644 index 0000000..c8edb35 --- /dev/null +++ b/art/portraits/tt_cordera_man.svg @@ -0,0 +1,20 @@ + + Cordera man (town) + Cordera man (town) — baseline, SIZ 13, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/tt_kell.svg b/art/portraits/tt_kell.svg new file mode 100644 index 0000000..8065940 --- /dev/null +++ b/art/portraits/tt_kell.svg @@ -0,0 +1,20 @@ + + Marius Kell + Marius Kell — baseline, SIZ 13, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/tt_merritt.svg b/art/portraits/tt_merritt.svg new file mode 100644 index 0000000..043411b --- /dev/null +++ b/art/portraits/tt_merritt.svg @@ -0,0 +1,26 @@ + + Cass Merritt + Cass Merritt — baseline, SIZ 13, 7 hit locations, natural armour 1, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + + + + + + + diff --git a/art/portraits/tt_prosser.svg b/art/portraits/tt_prosser.svg new file mode 100644 index 0000000..77b4a14 --- /dev/null +++ b/art/portraits/tt_prosser.svg @@ -0,0 +1,20 @@ + + Mr Prosser + Mr Prosser — baseline, SIZ 12, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/tt_rance.svg b/art/portraits/tt_rance.svg new file mode 100644 index 0000000..8e1e4d5 --- /dev/null +++ b/art/portraits/tt_rance.svg @@ -0,0 +1,20 @@ + + Rance Doyle + Rance Doyle — baseline, SIZ 14, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/tt_reyes.svg b/art/portraits/tt_reyes.svg new file mode 100644 index 0000000..c41a2ce --- /dev/null +++ b/art/portraits/tt_reyes.svg @@ -0,0 +1,20 @@ + + Ada Reyes + Ada Reyes — baseline, SIZ 10, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/tt_shard.svg b/art/portraits/tt_shard.svg new file mode 100644 index 0000000..610653b --- /dev/null +++ b/art/portraits/tt_shard.svg @@ -0,0 +1,20 @@ + + Mr Shard + Mr Shard — baseline, SIZ 12, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/tt_tom_doyle.svg b/art/portraits/tt_tom_doyle.svg new file mode 100644 index 0000000..80d8dd1 --- /dev/null +++ b/art/portraits/tt_tom_doyle.svg @@ -0,0 +1,20 @@ + + Tom Doyle + Tom Doyle — baseline, SIZ 12, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/tt_vane.svg b/art/portraits/tt_vane.svg new file mode 100644 index 0000000..1197a53 --- /dev/null +++ b/art/portraits/tt_vane.svg @@ -0,0 +1,20 @@ + + Elias Vane + Elias Vane — baseline, SIZ 11, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/art/portraits/tt_writman.svg b/art/portraits/tt_writman.svg new file mode 100644 index 0000000..07e3ec4 --- /dev/null +++ b/art/portraits/tt_writman.svg @@ -0,0 +1,20 @@ + + Cordera miner + Cordera miner — baseline, SIZ 13, 7 hit locations, natural armour 0, antique. Generated by tools/make-portraits.mjs; do not edit by hand. + + + + + + + + + + + + + + + + + diff --git a/package.json b/package.json index b7f3421..b26ad0f 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "scripts": { "build": "node tools/build-packs.mjs && node tools/update-readme.mjs", "icons": "node tools/make-icons.mjs", + "portraits": "node tools/make-portraits.mjs", + "mj": "node tools/mj-queue.mjs", "simulate": "node tools/simulate.mjs", "check": "node tools/check-rules.mjs && node tools/check-kits.mjs && node tools/check-lang.mjs && node tools/check-templates.mjs && node tools/check-behaviour.mjs && node tools/check-scenarios.mjs && node tools/check-creatures.mjs", "test": "npm run check", diff --git a/tools/build-packs.mjs b/tools/build-packs.mjs index 0549292..fa2467f 100644 --- a/tools/build-packs.mjs +++ b/tools/build-packs.mjs @@ -308,14 +308,35 @@ function buildActor(spec, type) { // A generated portrait if the spec names one, the generic silhouette otherwise. // The token gets the same face: a pregen you can recognise on the map is worth // more at a convention table than a correct one you cannot tell apart. + /* Resolved by stem and extension rather than by an exact filename. + * + * Two things changed. Portraits are no longer only .webp — tools/make-portraits.mjs + * writes .svg for every creature, and tools/mj-queue.mjs files .png when one is + * illustrated by hand — and a creature no longer has to NAME its portrait to get one: + * art found under its own key is used. That is what lets sixty creatures gain art + * without sixty edits to content.mjs. + * + * Order matters. A hand-made raster always beats the generated diagram, so upgrading a + * creature is a matter of dropping the file in; nothing has to be un-said in source. */ + const PORTRAIT_EXT = ["webp", "png", "jpg", "svg"]; + const findPortrait = stem => { + for (const ext of PORTRAIT_EXT) { + const rel = `art/portraits/${stem}.${ext}`; + if (existsSync(path.join(ROOT, rel))) return rel; + } + return null; + }; + let face = ICON.actor; - if (spec.portrait) { - const rel = `art/portraits/${spec.portrait}.webp`; - // A missing portrait is a broken image on a pregen sheet that nobody notices - // until it is on a projector at a table. Fail the build instead. - if (!existsSync(path.join(ROOT, rel))) throw new Error(`${spec.name}: no portrait at ${rel}`); - face = `systems/ringbrp/${rel}`; + const found = findPortrait(spec.portrait ?? spec.key); + // A spec that NAMES a portrait and has none is still a hard failure: that is a broken + // image on a pregen sheet, and nobody notices until it is on a projector at a table. + // A spec that names none and has none simply keeps the generic silhouette. + if (spec.portrait && !found) { + throw new Error(`${spec.name}: no portrait for "${spec.portrait}" ` + + `(looked for ${PORTRAIT_EXT.map(e => `.${e}`).join(", ")} in art/portraits/)`); } + if (found) face = `systems/ringbrp/${found}`; const actorDoc = { _id: actorId, diff --git a/tools/make-portraits.mjs b/tools/make-portraits.mjs new file mode 100644 index 0000000..a6a7d13 --- /dev/null +++ b/tools/make-portraits.mjs @@ -0,0 +1,410 @@ +/** + * 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 ``; + } + if (shape.t === "rect") { + return ``; + } + return ``; +} + +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 = ``; + if (era === "antique") corner = ``; + if (era === "future") { + corner = ``; + scan = ``; + } + if (era === "anomalous") { + rimPath = ``; + } + + /* --- 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 = ``; + + /* --- weapon mark, bottom right --- */ + const mk = weaponMark(spec); + const mark = !mk ? "" : + `` + + (MARK_STROKE.has(mk) + ? `` + : ``) + + ``; + + return ` + ${esc(spec.name)} + ${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. + + ${corner} + ${ground} + + ${body} + ${plating} + + ${mark} + ${rimPath} + ${scan} + +`; +} + +const esc = s => String(s).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}.`); + +}