The Mac bridge posts /imagine to Discord with a user token. It is not ported here and should not be: Midjourney's terms since 27 May 2026 forbid automated access, there is no official API, and the bridge's own memory note records Discord silently dropping its interactions from 22 July — which is what enforcement looks like from inside. A Windows clone would be a second self-bot on the same account. So this does everything except talk to Discord, which turns out to be most of the work. `next` derives a prompt from the creature's spec and puts it on the clipboard; you paste it yourself; `file` or `watch` renames the download to the canonical name and retires the generated placeholder. One at a time, because the memory note is explicit that concurrent jobs in one channel steal each other's grids. Prompts are derived, not written — anatomy from the location table, scale from SIZ, build from STR against SIZ, plating from naturalArmour — so they cannot drift from the statblock. Three things learned by reading the data rather than assuming it: - tactics is written for a GM, not an illustrator. "Advances by bounds, 15 m per round" put a rate of fire in an art prompt. role is the one-line essence and is already the sentence you would hand an artist. Sentences carrying mechanics are dropped; dates and ages are kept. - era is the wrong test for whether something is a person. It classified Mr Bewley of the Registry and everyone in 1881 Cordera as monsters, because their kit is old. - 18 creatures are species "baseline" and are not human-shaped — a black dog, a case file that eats case files, a machine that has decided it is the site. The prompt refuses to describe a body it cannot verify, and `list` names them. That is a statblock to write, not something to paper over here. `sheet` emits a prompt sheet that the existing gen-art.mjs parses unchanged, so if the bridge ever works again the whole bestiary drives from it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
461 lines
21 KiB
JavaScript
461 lines
21 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.
|
|
*/
|
|
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 <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);
|
|
}
|