Files
slaguru666andClaude Opus 5 27ef3e3693 Correction: "bare" was not the trigger. Remove it from BANNED
0b51d4e claimed the martlet's three stalls were caused by the word "bare" and
said it was diagnosed rather than guessed. It was guessed.

The evidence was one successful regeneration with that word replaced. Run
again afterwards with "bare" restored and nothing else changed, the same
prompt was accepted in seconds. The variable that actually differed was that
the successful run was the only job in flight — and the word was believed
because a memory note from 16 September already named it, which is a reason
to suspect a conclusion rather than to trust it.

Two further hypotheses tested and also dead: moor_cat's full prompt is
accepted immediately, so it is not blocked either; and four jobs fired two
seconds apart were all answered, so it is not a concurrency limit at that
scale. What made those two fail six times between them is not reproducible
now, and this commit does not claim to know.

The throw-instead-of-warn stays: for a genuinely banned word, a warning
printed into a log predicts a job that never arrives and never errors. The
armour wording stays too, because it reads better — not as a workaround.

~/bin/mj-blocked is the tool this needed from the start. It sends a prompt
and reports within two minutes whether the bot answered at all, because a
refusal is ephemeral and never reaches the channel, so silence there is the
only thing that separates a banned word from a slow one. Three sessions have
now spent 30-minute timeouts on this question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 02:12:10 +01:00

584 lines
29 KiB
JavaScript

/**
* 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 <key> print one prompt
* node tools/mj-queue.mjs next take the next creature: print + clipboard
* node tools/mj-queue.mjs take <key> 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.
*/
/* Learned in production, one silent stall at a time.
*
* "bare" was added here on 2026-09-23 and REMOVED the same night. The martlet had failed
* three times over ninety minutes; regenerating it with that one word replaced brought it
* back, and that was published as the diagnosis. It was wrong. Run again afterwards with
* "bare" restored and nothing else changed, the same prompt was accepted in seconds. The
* variable that actually differed was that the successful run was the only job in flight,
* and the word was believed because a memory note from 16 September already named it —
* which is a reason to suspect a conclusion, not to trust it.
*
* Nothing in this list should be added from a single successful retry. Use ~/bin/mj-blocked,
* which sends the prompt and reports in two minutes whether the bot answered at all: a
* refusal is ephemeral and never reaches the channel, so silence there is the only
* evidence that distinguishes a banned word from a slow one. */
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"]
]);
/* Heavy natural armour does not look the same on a heraldic beast as on a far-side
thing. The single band list read "plated like machinery" at 6+, which drew the
guildhall gryphon — six centuries of carved stone and feather — as a mech.
That was fixed once, by branching the SIMILE on era, and the gryphon came back as a
mech again: the branch changed what the plating was compared to while both arms still
opened with the word "armoured", and that word is what Midjourney draws. Changing the
reasoning is not the same as changing the cause.
So no natural creature's description contains "armour" at all now. A beast's armour is
its hide, and the clause says hide. The word survives only where it is meant literally
— a far-side thing that really is plated — and `negativesFor` below asks for worn
armour to be left out, because a word choice is an argument and a --no is closer to a
guarantee. */
const armourWords = (n, era = "antique") => bandOf(Number(n) || 0, [
/* This band said "bare, unprotected" and was changed on the belief that "bare" was
blocking the martlet. That belief did not survive testing — the prompt with "bare" is
accepted — so the change is kept only because the wording is better, and NOT as a
workaround for anything. */
[0, "thin-skinned, nothing between it and a blow"],
[3, "tough hide"],
[6, "thick natural plating, part of the animal"],
[99, era === "future" || era === "anomalous"
? "heavily armoured, plated like machinery"
: "hide and scale thick as carved stone, weathered and old, its own body its only protection"]
]);
/**
* What must not be in the picture, beyond the house negatives.
*
* A creature that is old and heavy reads to Midjourney as a thing in plate, and a
* creature described as strong reads as a thing holding a weapon. Both are stated
* positively elsewhere in the prompt and neither can be talked out of the image.
*/
function negativesFor(spec, era, isPerson) {
if (isPerson) return [];
const out = [];
/* SHORT, deliberately. The base style already carries two negatives, and this project
has a recorded failure where prompts with ten or more --no terms never returned a
grid at all while the same prompt with four came back in two minutes — a stall that
is indistinguishable from a moderation refusal and so gets misdiagnosed every time.
The first version of this function pushed six armour terms and three weapon terms,
which took the gryphon to eight and into that band. Three terms and one; steer with
the prompt, which is what the armourWords rewrite above is for. */
if (era !== "future" && era !== "anomalous") out.push("plate armour", "machinery", "robot");
// "no weapon — it uses its own limbs" is already in the prompt, positively. An animal
// is still handed a spear often enough to be worth stating twice.
if (!(spec.weapons ?? []).length) out.push("weapons");
return out;
}
/**
* 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, bodyPlan = "") {
/* R-311. This read `species` alone, and the bestiary does not put its shape there.
* A creature's hit locations come from `bodyPlan`; `species` carries characteristic
* dice and talents. 44 of the 100 creatures added in 32eaee1 are species "baseline"
* with bodyPlan "quadruped" or "winged", so every one of their prompts fell through
* to the silent branch below and asked for no shape at all. The proof is on disk:
* art/creatures/gryphon-u1.png is a wingless gryphon, generated from a spec whose
* bodyPlan is "winged".
*
* This is the exact failure the function was written to prevent, relocated one field
* to the left. Read the plan that selects the table, then the species. */
const plan = bodyPlan || species;
const t = LOCATION_TABLES[plan] ?? LOCATION_TABLES[species] ?? LOCATION_TABLES.baseline;
if (plan === "quadruped") {
return "FOUR-LEGGED ANIMAL ANATOMY: stands on four legs, with a head on a neck at "
+ "the front and hindquarters behind — NOT bipedal, NOT humanoid, no arms and no hands";
}
if (plan === "winged") {
const wings = t.locations.filter(l => /^wing/.test(l.id)).length;
return `FOUR-LEGGED ANIMAL ANATOMY WITH ${wings} LARGE WINGS: stands on four legs, `
+ "head on a neck at the front, and a great wing on each shoulder, clearly visible "
+ "and fully drawn — NOT bipedal, NOT humanoid, no arms and no hands";
}
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 a baseline body on a baseline species.
*
* A case file that eats other case files is authored bodyPlan "baseline" and is not a
* humanoid; it simply has no better table. 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;
/* R-311. Structure before label.
*
* `if (tag) return true` below fails OPEN: any role tag the list has not heard of is
* read as a person. MONSTER_TAGS named three tags; the bestiary added in 32eaee1 uses
* twelve, so ANOMALOUS, HERALDRY, BEAST, LIVESTOCK, REVENANT, FAR-SIDE, MENAGERIE,
* HYBRID and ECHO — 43 creatures — all fell through and were prompted as "portrait of
* X, waist-up portrait". art/creatures/gryphon-u1.png is the receipt: a wingless
* gryphon drawn as a person, from a spec whose bodyPlan is "winged".
*
* The tag list is free text that grows whenever somebody writes a new creature.
* bodyPlan cannot drift the same way — it selects the hit-location table, so the
* engine already enforces it. A four-legged or winged body is not a person, whatever
* its role line happens to say. */
if ((spec.bodyPlan ?? "baseline") !== "baseline") return false;
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, spec.bodyPlan ?? "") : null,
!isPerson ? sizeWords(spec.ch?.siz) : null,
!isPerson ? buildWords(spec.ch) : null,
!isPerson ? armourWords(spec.naturalArmour, era) : 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);
/* The style token ends with `--no photography, photorealism`, and --no takes a comma
list, so anything appended after expansion continues that list rather than starting
a new clause. This is the only reason the extra negatives can be added here. */
const base = expand(clauses.join(", ").replace(/\s+/g, " ").trim());
const extra = negativesFor(spec, era, isPerson);
const prompt = extra.length ? `${base}, ${extra.join(", ")}` : base;
/* A prompt with too many --no terms does not fail, which is the problem: it never
returns a grid at all, and a job that never arrives looks exactly like a moderation
refusal — those are ephemeral and never appear in the channel either. The recorded
case cost twenty minutes of wondering whether the account was out of fast hours.
Refuse to build one, and say which terms, rather than letting it be diagnosed again
from scratch. Six is under the observed cliff with room to spare. */
const NEGATIVE_CAP = 6;
const negatives = (prompt.split("--no ")[1] ?? "").split(",").map(x => x.trim()).filter(Boolean);
if (negatives.length > NEGATIVE_CAP) {
throw new Error(`${spec.key}: ${negatives.length} --no terms (cap ${NEGATIVE_CAP}) — `
+ `a long negative list stalls the job silently rather than failing it. `
+ `Steer in the prompt instead. Terms: ${negatives.join(", ")}`);
}
/* REFUSE, rather than warn. These were returned as `warnings` for the caller to print,
and a warning in a log nobody reads is what let the martlet stall three times: the
failure it predicts is a job that never arrives, which is indistinguishable from the
service being slow. The negative cap above refuses for the same reason. */
const warnings = BANNED.filter(b => prompt.toLowerCase().includes(b));
if (warnings.length) {
throw new Error(`${spec.key}: prompt contains ${warnings.map(w => `"${w}"`).join(", ")}, `
+ `which Midjourney's moderation refuses even in innocent use. The refusal is ephemeral `
+ `— the job never arrives and never fails — so this must not be sent. Reword the `
+ `clause that produced it.`);
}
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 <dir> 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);
}