diff --git a/.gitignore b/.gitignore index 6358afc..4f732cf 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ packs/*/LOCK # Impeccable design-hook scratch; the config beside it records real decisions. .impeccable/hook.cache.json + +# The creature currently taken from the illustration queue (tools/mj-queue.mjs). +# Machine-local working state, not a fact about the repo. +.mj-inflight diff --git a/docs/scenarios/BESTIARY_ART.md b/docs/scenarios/BESTIARY_ART.md new file mode 100644 index 0000000..32910f9 --- /dev/null +++ b/docs/scenarios/BESTIARY_ART.md @@ -0,0 +1,96 @@ +# THE CUSTODIANS — bestiary art prompt sheet + +Generated by `tools/mj-queue.mjs sheet`. Do not edit by hand: every prompt is +derived from the creature's own statblock, so editing here is overwritten and, +worse, lets the art disagree with the hit-location table. Change the creature. + +## Style tokens + +| Token | Text | +|---|---| +| `[BASE]` | hand-drawn ink and pencil illustration, single-weight line work, sparse wash, heavy negative space, not photorealistic | +| `[CREATURE]` | [BASE], full figure on a plain ground, neutral background, field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `[PERSON]` | [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | + +## Assets + +| ID | Creature | Prompt | +|---|---|---| +| `ceyla` | Ceyla Marspont | portrait of Ceyla Marspont, carrying a redaction lance, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `dunn` | Dunn Halvorsen | portrait of Dunn Halvorsen, carrying a service pistol, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `rakhi` | Rakhi Oyelaran-Vost | portrait of Rakhi Oyelaran-Vost, carrying a marksman rifle, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `tomas` | Tomas Ekwueme | portrait of Tomas Ekwueme, carrying a compact pistol, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `quiet_neighbours_npc` | The Quiet Neighbours | portrait of The Quiet Neighbours, Near-world locals who have lived beside the threshold for generations, carrying a hunting bow, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `keepers` | The Keepers (waystation) | portrait of The Keepers (waystation), Scavengers who bury what comes through — and the only real information network on the far side, carrying an utility knife, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `cleaner` | Cleaner (contractor) | portrait of Cleaner (contractor), Human, armed, and working for whoever got here first, carrying a service pistol, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `cleaner_marksman` | Cleaner (marksman) | portrait of Cleaner (marksman), The one holding the high ground, carrying a marksman rifle, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `revenant` | Revenant | Revenant, What is left when something comes through without a body to come through in, adult human scale, heavy and powerfully built, thick natural plating, no weapon — it uses its own limbs, subtly wrong, something that should not be in the record, [BASE], full figure on a plain ground, neutral background, field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `duplicate` | Duplicate (of an agent) | portrait of Duplicate (of an agent), The single most expensive thing a team can meet, carrying a service pistol, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `barghest` | Barghest | Barghest, The black dog on the road out of the village, large, standing well above a tall man, heavy and powerfully built, tough hide, no weapon — it uses its own limbs, old, from long before the present day, [BASE], full figure on a plain ground, neutral background, field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `kelpie` | Kelpie | Kelpie, The horse standing at the water that is not a horse, large, standing well above a tall man, heavy and powerfully built, tough hide, no weapon — it uses its own limbs, old, from long before the present day, [BASE], full figure on a plain ground, neutral background, field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `redcap` | Redcap | Redcap, The small old man in the ruin, and what he dyes his hat with, small, about the size of a child, densely and wrongly strong for its size, tough hide, carrying a halberd, old, from long before the present day, [BASE], full figure on a plain ground, neutral background, field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `boggart` | Boggart | Boggart, The thing in the house that was tolerable until it was not, very small, no higher than a person's knee, densely and wrongly strong for its size, no armour, bare, no weapon — it uses its own limbs, old, from long before the present day, [BASE], full figure on a plain ground, neutral background, field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `churchgrim` | Church grim | Church grim, What was buried at the north door to keep the ground, adult human scale, ordinarily built, thick natural plating, no weapon — it uses its own limbs, old, from long before the present day, [BASE], full figure on a plain ground, neutral background, field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `hollow_man` | Hollow man | Hollow man, Somebody who crossed too often and came back thinner each time, adult human scale, ordinarily built, tough hide, carrying an utility knife, subtly wrong, something that should not be in the record, [BASE], full figure on a plain ground, neutral background, field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `the_tenant` | The tenant | The tenant, What moved into the house while it was empty, adult human scale, thin, wasted, not strong, no armour, bare, no weapon — it uses its own limbs, subtly wrong, something that should not be in the record, [BASE], full figure on a plain ground, neutral background, field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `the_choir` | The choir | The choir, Eleven people who all say the same thing a half second apart, adult human scale, ordinarily built, tough hide, no weapon — it uses its own limbs, subtly wrong, something that should not be in the record, [BASE], full figure on a plain ground, neutral background, field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `carrion_file` | The carrion file | The carrion file, A case file that has been eating the cases filed next to it, small, about the size of a child, heavy and powerfully built, thick natural plating, no weapon — it uses its own limbs, subtly wrong, something that should not be in the record, [BASE], full figure on a plain ground, neutral background, field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `the_understudy` | The understudy | The understudy, It has been practising being you and it is nearly ready, adult human scale, ordinarily built, tough hide, carrying an utility knife, subtly wrong, something that should not be in the record, [BASE], full figure on a plain ground, neutral background, field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `surveyor` | Surveyor | Surveyor, It is measuring the site and it will finish, adult human scale, heavy and powerfully built, heavily armoured, plated like machinery, carrying a redaction lance, recovered technology, not of this time, [BASE], full figure on a plain ground, neutral background, field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `quarantine_unit` | Quarantine unit | Quarantine unit, Sent to contain the same thing you were, and it got here first, adult human scale, heavy and powerfully built, heavily armoured, plated like machinery, carrying an unmaker, recovered technology, not of this time, [BASE], full figure on a plain ground, neutral background, field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `cuckoo_array` | Cuckoo array | Cuckoo array, A machine that has decided it is the site, huge, roughly twice the height of a man, ordinarily built, heavily armoured, plated like machinery, no weapon — it uses its own limbs, recovered technology, not of this time, [BASE], full figure on a plain ground, neutral background, field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `long_walker` | Long walker | Long walker, It has been coming here for a very long time and is nearly arrived, huge, roughly twice the height of a man, ordinarily built, heavily armoured, plated like machinery, no weapon — it uses its own limbs, recovered technology, not of this time, [BASE], full figure on a plain ground, neutral background, field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `auditor` | The auditor | The auditor, It has read the file and it has questions, adult human scale, ordinarily built, thick natural plating, carrying a service pistol, recovered technology, not of this time, [BASE], full figure on a plain ground, neutral background, field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `pc_holloway` | Marta Holloway | portrait of Marta Holloway, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `pc_okonkwo` | Ben Okonkwo | portrait of Ben Okonkwo, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `pc_sandoval` | Rey Sandoval | portrait of Rey Sandoval, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `pc_bhattacharya` | Arun Bhattacharya | portrait of Arun Bhattacharya, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `pc_rahimi` | Soraya Rahimi | portrait of Soraya Rahimi, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `pc_nkemdirim` | Dr Chidi Nkemdirim | portrait of Dr Chidi Nkemdirim, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `pc_kowalczyk` | Marek Kowalczyk | portrait of Marek Kowalczyk, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `pc_finch` | Wren Finch | portrait of Wren Finch, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `pc_agyeman` | Joseph Agyeman | portrait of Joseph Agyeman, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `pc_lindqvist` | Professor Åke Lindqvist | portrait of Professor Åke Lindqvist, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `pc_pollard` | Gareth Pollard | portrait of Gareth Pollard, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `pc_ashcroft` | Dominic Ashcroft | portrait of Dominic Ashcroft, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `pc_braithwaite` | Neil Braithwaite | portrait of Neil Braithwaite, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `pc_renshaw` | Kate Renshaw | portrait of Kate Renshaw, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `pc_sharrock` | Danny Sharrock | portrait of Danny Sharrock, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `pc_ferriby` | Colin Ferriby | portrait of Colin Ferriby, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `tt_bewley` | Mr Bewley | portrait of Mr Bewley, Holds the case file and signs for the agents., [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `tt_prosser` | Mr Prosser | portrait of Mr Prosser, Takes the modern kit off them., [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `tt_rance` | Rance Doyle | portrait of Rance Doyle, 1881, Cordera. His brother Tom went out on No., old, from long before the present day, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `tt_shard` | Mr Shard | portrait of Mr Shard, 1881, Cordera. Company detective., old, from long before the present day, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `tt_cordera_man` | Cordera man (town) | portrait of Cordera man (town), One of Rance's five in the street. Use for all of them., old, from long before the present day, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `tt_tom_doyle` | Tom Doyle | portrait of Tom Doyle, Rance's brother. Shot at minute two of every pass., old, from long before the present day, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `tt_kell` | Marius Kell | portrait of Marius Kell, Has not let go of the regulator since 3 November., [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `tt_vane` | Elias Vane | portrait of Elias Vane, Signed the chart. Knows exactly what he did., [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `tt_bligh` | Hester Bligh | portrait of Hester Bligh, Sixty-one aboard, counted hourly., [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `tt_reyes` | Ada Reyes | portrait of Ada Reyes, Telegraph operator, Cordera., [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `tt_merritt` | Cass Merritt | portrait of Cass Merritt, Hired guard, the coats. Has the company's letter and the key to the box., old, from long before the present day, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `tt_ambrose` | Josiah Ambrose | portrait of Josiah Ambrose, Miner, the writ men. Carries a court order for the payroll., old, from long before the present day, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `tt_coat` | Company guard | portrait of Company guard, One of Merritt's three. Use for all of them., old, from long before the present day, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `tt_writman` | Cordera miner | portrait of Cordera miner, One of Ambrose's four. Use for all of them., old, from long before the present day, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `bp_leah` | Leah Hall | portrait of Leah Hall, Network Rail engineer, trapped, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `bp_martin` | Martin Cole | portrait of Martin Cole, Network Rail engineer, concussed, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `bp_inspector` | Ticket inspector | Ticket inspector, Part of the service, not a person, adult human scale, ordinarily built, tough hide, carrying a cudgel, subtly wrong, something that should not be in the record, [BASE], full figure on a plain ground, neutral background, field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `bp_conductor` | The Conductor | The Conductor, The service itself, wearing a uniform, adult human scale, ordinarily built, thick natural plating, carrying a cudgel, subtly wrong, something that should not be in the record, [BASE], full figure on a plain ground, neutral background, field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `bp_nia` | Nia Mercer | portrait of Nia Mercer, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `bp_callum` | Callum Pike | portrait of Callum Pike, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `bp_mara` | Mara Venn | portrait of Mara Venn, old, from long before the present day, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `bp_owen` | Owen Dey | portrait of Owen Dey, old, from long before the present day, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `bp_elin` | Dr Elin Shaw | portrait of Dr Elin Shaw, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `bp_sennen` | Sennen Roake | portrait of Sennen Roake, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `la_enderby` | Sister Enderby | portrait of Sister Enderby, An invention that counts., [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `la_sammy` | Sammy, the boy in the wall | portrait of Sammy, the boy in the wall, An invention that hides. Cannot be hit., [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `la_marrow` | Old Marrow | portrait of Old Marrow, An invention that will not come indoors., [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `la_edith` | Edith Crane | portrait of Edith Crane, Housemaid, 22, died in the 1963 fire. Cannot be hurt., [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `la_nowak` | Beata Nowak | portrait of Beata Nowak, Floor manager, holding the night together on a radio., [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `la_tunnicliffe` | Vic Tunnicliffe | portrait of Vic Tunnicliffe, Bank loan, forty staff, one dead employee., [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `od_kearsley` | Douglas Kearsley | portrait of Douglas Kearsley, Retired exchange engineer, 71. Does not come out., [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `od_odame` | Councillor Hazel Odame | portrait of Councillor Hazel Odame, Civic society chair. Takes the head count., [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `od_vasey` | Tony Vasey | portrait of Tony Vasey, Wants Monday to start., [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `od_ferrand` | Mrs Ivy Ferrand | portrait of Mrs Ivy Ferrand, Recruiting officer., [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | +| `od_bewley` | Mr Bewley | portrait of Mr Bewley, [BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 --no photography, photorealism | + +*Private convention play materials — not for sale or distribution.* diff --git a/tools/mj-queue.mjs b/tools/mj-queue.mjs new file mode 100644 index 0000000..de569fa --- /dev/null +++ b/tools/mj-queue.mjs @@ -0,0 +1,460 @@ +/** + * The illustrated-portrait queue — the half of the art pipeline that can be automated. + * + * WHY THIS IS NOT mj-gen. The Mac has a bridge that posts `/imagine` to Discord using a + * user token and downloads the grid. It cannot be ported here and should not be: it is a + * self-bot, Midjourney's terms as of 27 May 2026 forbid automated access outright, and + * the memory note recording its own behaviour says Discord began silently dropping its + * interactions in July 2026 — which is what enforcement looks like from the inside. There + * is no official Midjourney API to move to. + * + * So this tool does everything EXCEPT talk to Discord. That turns out to be most of the + * work. What actually costs time is not pasting a prompt; it is writing sixty prompts + * that agree with sixty statblocks, remembering which ones are done, and renaming + * `dave_a_woodcut_portrait_2f9c-u1.png` to something the pack builder will find. + * + * node tools/mj-queue.mjs list what has illustration, what does not + * node tools/mj-queue.mjs prompt print one prompt + * node tools/mj-queue.mjs next take the next creature: print + clipboard + * node tools/mj-queue.mjs take take a specific one + * node tools/mj-queue.mjs file file the newest inbox image to the taken one + * node tools/mj-queue.mjs watch file automatically as images arrive + * node tools/mj-queue.mjs sheet write a markdown prompt sheet + * node tools/mj-queue.mjs status what is currently taken + * + * The loop is: `next` (prompt lands on your clipboard) -> paste into Discord yourself -> + * save the upscale -> `file`. Or leave `watch` running and just save; it files them as + * they land. One at a time, deliberately — the memory note is explicit that two + * concurrent jobs in one channel steal each other's results. + * + * PROMPTS ARE DERIVED, NOT WRITTEN. Every descriptive clause comes out of the creature's + * own spec — its anatomy from LOCATION_TABLES, its scale from SIZ, its build from STR + * against SIZ, its plating from naturalArmour. Ask for a Cadence and the prompt says + * five separate bodies and no head, because its location table has five entries and no + * head, and it will say that whatever anybody later types into content.mjs. This is the + * same argument rules.mjs makes about formulas, applied to art direction. + */ +import { writeFile, readFile, copyFile, readdir, stat, unlink, mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { LOCATION_TABLES } from "../anatomy.mjs"; +import { WEAPONS } from "./content.mjs"; +import { collectSpecs } from "./all-specs.mjs"; +import { eraOf, roleTagOf, MONSTER_TAGS, carriesNothingManufactured } from "./make-portraits.mjs"; + +const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), ".."); +const ART = path.join(ROOT, "art", "portraits"); +const STATE = path.join(ROOT, ".mj-inflight"); + +const WEAPON = new Map(WEAPONS.map(w => [w.key, w])); +const RASTER = ["png", "webp", "jpg", "jpeg"]; + +/* ---------------------------------------------------------------- style */ + +/** + * House style, as tokens, in the same shape THROUGH_TRAIN_ART.md uses — so `sheet` emits + * something tools/gen-art.mjs can already parse, and a generated sheet is a document + * rather than a thing only this script understands. + * + * The base line is the house style from the rpg skill: ink sketch, hand-drawn, never + * photorealistic. Note that the sixteen existing duty-roster plates are NOT in this + * style — they are flat vector illustration on a plain ground — so a bestiary generated + * here will not match them. That is a deliberate split: the agents are the cast and look + * like portraits, the bestiary is case material and looks like case material. + */ +export const TOKENS = { + "[BASE]": "hand-drawn ink and pencil illustration, single-weight line work, sparse wash, " + + "heavy negative space, not photorealistic", + "[CREATURE]": "[BASE], full figure on a plain ground, neutral background, " + + "field-guide plate, --style raw --s 250 --ar 2:3 --no photography, photorealism", + "[PERSON]": "[BASE], waist-up portrait, plain ground, --style raw --s 250 --ar 2:3 " + + "--no photography, photorealism" +}; + +/** + * Words Midjourney's moderation refuses even in innocent or negated use, learned in + * production and recorded in the midjourney-bridge memory. A refusal is EPHEMERAL — it + * never appears in the channel — so the failure mode is a job that simply never arrives + * and twenty minutes of wondering whether the account is out of fast hours. Cheaper to + * catch here than to sit through. + */ +const BANNED = ["gore", "bust", "corpse", "mutilat", "blood-soaked"]; + +/* ---------------------------------------------------------------- derivation */ + +const bandOf = (n, bands) => bands.find(([max]) => n <= max)?.[1] ?? bands.at(-1)[1]; + +const sizeWords = siz => bandOf(Number(siz) || 12, [ + [6, "very small, no higher than a person's knee"], + [9, "small, about the size of a child"], + [14, "adult human scale"], + [18, "large, standing well above a tall man"], + [99, "huge, roughly twice the height of a man"] +]); + +const buildWords = ch => bandOf((Number(ch?.str) || 12) / (Number(ch?.siz) || 12), [ + [0.85, "thin, wasted, not strong"], + [1.15, "ordinarily built"], + [1.60, "heavy and powerfully built"], + [99, "densely and wrongly strong for its size"] +]); + +const armourWords = n => bandOf(Number(n) || 0, [ + [0, "no armour, bare"], + [3, "tough hide"], + [6, "thick natural plating"], + [99, "heavily armoured, plated like machinery"] +]); + +/** + * The anatomy clause, read out of the creature's own hit-location table. This is the + * whole point of the tool: a Vesh prompt cannot ask for a head, because the table it is + * generated from does not have one. + */ +function anatomyWords(species) { + const t = LOCATION_TABLES[species] ?? LOCATION_TABLES.baseline; + if (species === "vesh") { + return "STRONGLY ASYMMETRIC anatomy: one long grasping limb on one side and one short " + + "bracing limb on the other, and NO HEAD AT ALL — a raised sensory ridge runs down " + + "the back where a head would be"; + } + if (species === "cadence") { + return `NOT ONE BODY: ${t.locations.length} small separate bodies moving together as a ` + + "single creature, none of them with a head, arranged as a loose group"; + } + /* Deliberately silent for baseline. + * + * Every creature in the bestiary is authored `species: baseline`, including a black dog + * and a case file that eats other case files. On a monster that is not a claim about + * anatomy — it is the absence of one, because vesh and cadence have never been used by + * anything. Saying "two arms and two legs, one head" here would put a bipedal werewolf + * in the prompt for the barghest and a little man in the prompt for the carrion file, + * and the whole argument for deriving prompts from statblocks is that they do not lie. + * + * So the prompt says nothing about shape and lets the description carry it, and + * `list` marks the creature as anatomically unmodelled. A silent wrongness becomes a + * visible piece of work. */ + return null; +} + +/** + * Whether this is a person, which decides whether the prompt asks for a portrait or a + * field-guide plate. + * + * NOT from era. The first cut tested `eraOf(spec) === "modern"` and duly classified Mr + * Bewley of the Registry, a ticket inspector and every soul in 1881 Cordera as monsters, + * because their kit is old. A person in 1881 is still a person; era answers "when", not + * "what". The structural questions are whether anybody classified it as a monster, and + * whether it carries things that were manufactured and has skin rather than plating. + */ +export function isPersonSpec(spec, kind) { + if (kind === "agent") return true; + const tag = roleTagOf(spec); + if (tag && MONSTER_TAGS.includes(tag)) return false; + if (tag) return true; // CIVILIAN, DEPARTMENT, ENTRY, REAL + if ((spec.species ?? "baseline") !== "baseline") return false; + if (carriesNothingManufactured(spec)) return false; // it was issued nothing by anyone + return (Number(spec.naturalArmour) || 0) < 2; // skin, not plating +} + +/** + * A creature whose shape the rules do not actually describe: not a person, yet carrying + * the default human anatomy because nothing in the bestiary has ever used a species that + * is not baseline. See anatomyWords. + */ +export const anatomyUnmodelled = (spec, kind) => + !isPersonSpec(spec, kind) && (spec.species ?? "baseline") === "baseline"; + +const weaponWords = (spec, isPerson) => { + const w = WEAPON.get((spec.weapons ?? [])[0] ?? ""); + if (!w) return null; + // Worth saying of a monster, because it tells the illustrator to draw claws rather than + // a knife. Not worth saying of a registry clerk, who is not carrying a weapon for the + // same reason most people are not. + if (w.fam === "brawl" && !w.mass) return isPerson ? null : "no weapon — it uses its own limbs"; + return `carrying ${/^[aeiou]/i.test(w.name) ? "an" : "a"} ${w.name.toLowerCase()}`; +}; + +/** + * The descriptive half of the role line. + * + * The first cut of this used `tactics`, on the theory that behaviour is where a creature + * lives. It is — but it is written for a GM, not for an illustrator: "Advances by bounds + * — 15 m per round while firing, 30 m if not" put ranges and rates of fire into an art + * prompt, and an image model will earnestly try to draw the number 15. `role` is the + * one-line essence the house style requires of every NPC, and it is already exactly the + * sentence you would give an artist: "The black dog on the road out of the village." + * + * The shouted category prefix is stripped — it classifies the creature rather than + * describing it, and it is already carried by the style token. + */ +const MECHANICAL = /\b(?:HP|POWER|MW|armour|damage)\b\s*[:.]?\s*\d|\d+\s*%|\b\d+d\d+|\b\d+\s?m\b|\bd100\b/i; + +const descriptionWords = spec => { + let src = String(spec.role ?? "").trim(); + + // Drop the classifying sentence if the line opens with a shouted tag: "BUREAU, present + // day. Registry. Holds the case file" describes the department before it describes the + // man, and only the second half is art direction. + if (/^[A-Z]{3,}\b/.test(src)) src = src.replace(/^[^.]*\.\s*/, ""); + + /* Scenario cast roles sometimes carry the statblock inline — "An invention that will + not come indoors. HP 20, armour 2." — and an image model will cheerfully try to draw + the 20. Sentences with mechanics in them are dropped rather than truncated, because + the descriptive ones are usually first and the numbers usually last. */ + const kept = src.split(/(?<=[.!?])\s+/) + .map(s => s.trim()) + .filter(s => s && s.length >= 14 && !MECHANICAL.test(s)) + .slice(0, 2); + + const out = kept.join(" ").replace(/\s+/g, " ").trim(); + if (!out) return null; + return out.length > 200 ? out.slice(0, 197) + "..." : out; +}; + +/** Everything above, assembled. Returns { prompt, token, warnings }. */ +export function promptFor(spec, kind = "npc") { + const species = spec.species ?? "baseline"; + const era = eraOf(spec); + const isPerson = isPersonSpec(spec, kind); + const token = isPerson ? "[PERSON]" : "[CREATURE]"; + + const clauses = [ + isPerson ? `portrait of ${spec.name}` : spec.name, + descriptionWords(spec), + !isPerson ? anatomyWords(species) : null, + !isPerson ? sizeWords(spec.ch?.siz) : null, + !isPerson ? buildWords(spec.ch) : null, + !isPerson ? armourWords(spec.naturalArmour) : null, + weaponWords(spec, isPerson), + era === "antique" ? "old, from long before the present day" : null, + era === "future" ? "recovered technology, not of this time" : null, + era === "anomalous" ? "subtly wrong, something that should not be in the record" : null, + token + ].filter(Boolean); + + const prompt = expand(clauses.join(", ").replace(/\s+/g, " ").trim()); + const warnings = BANNED.filter(b => prompt.toLowerCase().includes(b)); + return { prompt, token, warnings }; +} + +function expand(s, depth = 0) { + if (depth > 8) throw new Error(`style token loop: ${s.slice(0, 60)}`); + const out = s.replace(/\[([A-Z]+)\]/g, (whole, n) => TOKENS[`[${n}]`] ?? whole); + return out === s ? out : expand(out, depth + 1); +} + +/* ---------------------------------------------------------------- art on disk */ + +/** The illustrated portrait for a stem, if there is one. A generated SVG is not one. */ +function illustrationFor(stem) { + for (const ext of RASTER) { + if (existsSync(path.join(ART, `${stem}.${ext}`))) return `${stem}.${ext}`; + } + return null; +} + +/** Whether an SVG beside it was written by make-portraits, and so is safe to retire. */ +async function isGeneratedSvg(stem) { + const p = path.join(ART, `${stem}.svg`); + if (!existsSync(p)) return false; + return (await readFile(p, "utf8")).includes("tools/make-portraits.mjs"); +} + +/* ---------------------------------------------------------------- clipboard */ + +/** Copy to the clipboard on whichever of the three machines this is. Never fatal. */ +function toClipboard(text) { + const cmd = process.platform === "win32" ? ["clip", []] + : process.platform === "darwin" ? ["pbcopy", []] + : ["wl-copy", []]; + let r = spawnSync(cmd[0], cmd[1], { input: text }); + if (r.error && process.platform === "linux") { + r = spawnSync("xclip", ["-selection", "clipboard"], { input: text }); + } + return !r.error && r.status === 0; +} + +/* ---------------------------------------------------------------- inbox */ + +const defaultInbox = () => path.join(os.homedir(), "Downloads"); + +/** The newest image in the inbox, optionally only ones newer than `since`. */ +async function newestImage(inbox, since = 0) { + if (!existsSync(inbox)) throw new Error(`inbox does not exist: ${inbox}`); + let best = null; + for (const name of await readdir(inbox)) { + const ext = path.extname(name).slice(1).toLowerCase(); + if (!RASTER.includes(ext)) continue; + const full = path.join(inbox, name); + const st = await stat(full).catch(() => null); + if (!st?.isFile() || st.mtimeMs <= since) continue; + if (!best || st.mtimeMs > best.mtimeMs) best = { full, name, ext, mtimeMs: st.mtimeMs }; + } + return best; +} + +const readState = async () => + existsSync(STATE) ? JSON.parse(await readFile(STATE, "utf8")) : null; + +/* ---------------------------------------------------------------- commands */ + +const argv = process.argv.slice(2); +const cmd = argv[0] ?? "list"; +const opt = (n, d) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : d; }; +const INBOX = path.resolve(opt("--inbox", defaultInbox())); + +const all = await collectSpecs(); +const byKey = new Map(all.map(r => [r.spec.key, r])); +const stemOf = spec => spec.portrait ?? spec.key; + +/** Creatures with no illustration yet, in a stable order. */ +const pending = () => all.filter(({ spec }) => !illustrationFor(stemOf(spec))); + +function show(key) { + const row = byKey.get(key); + if (!row) { console.error(`no creature "${key}" — try: node tools/mj-queue.mjs list`); process.exit(1); } + const { prompt, warnings } = promptFor(row.spec, row.kind); + return { row, prompt, warnings }; +} + +switch (cmd) { + +case "list": { + let has = 0, unmodelled = 0; + for (const { spec, kind } of all) { + const art = illustrationFor(stemOf(spec)); + if (art) has++; + const bare = anatomyUnmodelled(spec, kind); + if (bare) unmodelled++; + console.log(`${art ? "img " : " "} ${spec.key.padEnd(24)} ${(art ?? "—").padEnd(26)}` + + `${eraOf(spec).padEnd(10)}${kind.padEnd(6)}${bare ? "anatomy unmodelled" : ""}`); + } + console.log(`\n${has} illustrated, ${all.length - has} not, of ${all.length}.`); + console.log(`Every one of them already has a generated SVG; this queue is the upgrade path.`); + if (unmodelled) { + console.log(`\n${unmodelled} creatures are species "baseline" and are not human-shaped —`); + console.log(`a black dog, a case file, a machine. Their prompts cannot describe a body and`); + console.log(`their hit locations are a person's. That is a statblock to write, not a bug here.`); + } + break; +} + +case "status": { + const st = await readState(); + if (!st) { console.log("nothing taken. `next` takes the next creature."); break; } + console.log(`taken: ${st.key} at ${new Date(st.at).toLocaleString()}`); + console.log(`inbox: ${st.inbox}`); + const img = await newestImage(st.inbox, st.at); + console.log(img ? `ready to file: ${img.name}` : "no new image in the inbox yet."); + break; +} + +case "prompt": { + const { prompt, warnings } = show(argv[1]); + for (const w of warnings) console.error(`WARNING: "${w}" is refused by Midjourney moderation even in innocent use — reword it.`); + console.log(prompt); + break; +} + +case "next": +case "take": { + const key = cmd === "take" ? argv[1] : pending()[0]?.spec.key; + if (!key) { console.log("nothing left — every creature has an illustration."); break; } + const { row, prompt, warnings } = show(key); + + for (const w of warnings) { + console.error(`\nWARNING: the prompt contains "${w}", which Midjourney refuses even in`); + console.error(`innocent use. The refusal is ephemeral: no grid ever arrives and nothing`); + console.error(`explains why. Reword the creature's tactics or role text first.\n`); + } + + const at = Date.now(); + await writeFile(STATE, JSON.stringify({ key, at, inbox: INBOX }, null, 2)); + + console.log(`\n── ${row.spec.name} (${key})\n`); + console.log(prompt); + console.log(`\n${toClipboard(prompt) ? "Copied to the clipboard." : "Could not reach the clipboard — copy it by hand."}`); + console.log(`Paste it into Discord as /imagine, upscale the one you want, and save it.`); + console.log(`Then: node tools/mj-queue.mjs file (watching ${INBOX})`); + break; +} + +case "file": { + const st = await readState(); + if (!st) { console.error("nothing taken — run `next` first."); process.exit(1); } + const img = await newestImage(st.inbox, st.at); + if (!img) { + console.error(`no image in ${st.inbox} newer than when ${st.key} was taken.`); + console.error(`Save the upscale first, or pass --inbox if it lands elsewhere.`); + process.exit(1); + } + await mkdir(ART, { recursive: true }); + const dest = path.join(ART, `${stemOf(byKey.get(st.key).spec)}.${img.ext === "jpeg" ? "jpg" : img.ext}`); + await copyFile(img.full, dest); + console.log(`filed ${img.name}\n -> ${path.relative(ROOT, dest)}`); + + // Retire the placeholder, but only if this tool's sibling wrote it. A hand-made SVG, + // if one ever exists, is somebody's work and is not ours to delete. + const stem = stemOf(byKey.get(st.key).spec); + if (await isGeneratedSvg(stem)) { + await unlink(path.join(ART, `${stem}.svg`)); + console.log(` retired the generated ${stem}.svg`); + } + await unlink(STATE); + const left = pending().length; + console.log(`${left} still unillustrated. Next: node tools/mj-queue.mjs next`); + break; +} + +case "watch": { + const st = await readState(); + if (!st) { console.error("nothing taken — run `next` first, then leave this running."); process.exit(1); } + console.log(`watching ${st.inbox} for ${st.key} — Ctrl-C to stop.`); + const started = st.at; + const tick = async () => { + const img = await newestImage(st.inbox, started); + if (!img) return; + const stem = stemOf(byKey.get(st.key).spec); + const dest = path.join(ART, `${stem}.${img.ext === "jpeg" ? "jpg" : img.ext}`); + await copyFile(img.full, dest); + if (await isGeneratedSvg(stem)) await unlink(path.join(ART, `${stem}.svg`)); + await unlink(STATE).catch(() => {}); + console.log(`filed ${img.name} -> ${path.relative(ROOT, dest)}`); + console.log(`${pending().length} still unillustrated. Run \`next\` for the next one.`); + process.exit(0); + }; + setInterval(tick, 2000); + break; +} + +case "sheet": { + const out = path.join(ROOT, "docs", "scenarios", "BESTIARY_ART.md"); + const rows = all.map(({ spec, kind }) => { + const { token } = promptFor(spec, kind); + // The sheet stores the UNEXPANDED prompt with its token, exactly as + // THROUGH_TRAIN_ART.md does, so gen-art.mjs can expand it the same way. + const raw = promptFor(spec, kind).prompt; + const collapsed = Object.entries(TOKENS) + .reduce((s, [tk, v]) => s.replace(expand(v), tk), raw); + return `| \`${stemOf(spec)}\` | ${spec.name} | ${collapsed} |`; + }); + const md = `# THE CUSTODIANS — bestiary art prompt sheet\n\n` + + `Generated by \`tools/mj-queue.mjs sheet\`. Do not edit by hand: every prompt is\n` + + `derived from the creature's own statblock, so editing here is overwritten and,\n` + + `worse, lets the art disagree with the hit-location table. Change the creature.\n\n` + + `## Style tokens\n\n| Token | Text |\n|---|---|\n` + + Object.entries(TOKENS).map(([k, v]) => `| \`${k}\` | ${v} |`).join("\n") + + `\n\n## Assets\n\n| ID | Creature | Prompt |\n|---|---|---|\n${rows.join("\n")}\n\n` + + `*Private convention play materials — not for sale or distribution.*\n`; + await writeFile(out, md, "utf8"); + console.log(`wrote ${path.relative(ROOT, out)} — ${rows.length} prompts.`); + break; +} + +default: + console.error(`unknown command "${cmd}". Try: list, prompt, next, take, file, watch, sheet, status`); + process.exit(1); +}