From 25d8f04a65eb706b472b5b4c5265e23b6a92e41a Mon Sep 17 00:00:00 2001 From: slaguru666 <111923774+slaguru666@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:32:24 +0100 Subject: [PATCH] check-armed: a creature must be able to use what it carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-kits has asserted this for the 36 postings since it was written — "every posting can use what it carries" is in its success line. Nothing asserted it for the creatures, and 35 of them hold a weapon they have no skill for. The hollow man has a dagger and no knife. The hulk haunt has a boarding axe and no axe. One PREGEN carries a pistol she cannot fire. It is not cosmetic. simulate.mjs sorts a combatant's arms by expected damage and an unskilled weapon sits at the 1% floor, so it loses to the creature's own fists every time: the statblock advertises a dagger and the thing punches. That is R-310's defect written down rather than generated. Recorded, not fixed. Giving each of the 35 the missing skill means choosing a rating, and a rating is a balance decision that moves check-lethality's numbers for every creature touched — that belongs to whoever owns the game. The 35 are held at a ratchet that may shrink and may never grow, so no new creature can be written holding something it cannot use. Specs are expanded through the register first. A spec written as a job has no skills of its own, and judging one unexpanded reports a Field Officer as unable to fire the pistol her own posting issues her. --update refused to create the record it needs on its first run, because with no file on disk all 35 existing cases counted as new. The first recording is the baseline; only later ones are held to the ratchet. Co-Authored-By: Claude Opus 5 --- README.md | 11 +- package.json | 2 +- tools/armed-baseline.json | 215 ++++++++++++++++++++++++++++++++++++++ tools/check-armed.mjs | 102 ++++++++++++++++++ 4 files changed, 324 insertions(+), 6 deletions(-) create mode 100644 tools/armed-baseline.json create mode 100644 tools/check-armed.mjs diff --git a/README.md b/README.md index 1d3cbb4..a8da8a3 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ packs or regenerate art on that machine: ```bash npm install # pulls classic-level, used to write the LevelDB packs npm run build # rebuilds packs/ from tools/content.mjs -npm run check # the twenty-four guards; the build refuses to run if they fail +npm run check # the twenty-five guards; the build refuses to run if they fail ``` To update a deployed server: `git pull` and restart Foundry. If the pull touches @@ -157,7 +157,7 @@ icons/ fonts/ art/ generated art packs/ built LevelDB compendia (committed — see Deploying) tools/ content.mjs the catalogue: skills, weapons, armour, gear, vehicles, NPCs - build-packs.mjs builds packs/ — runs the twenty-four guards first and refuses on failure + build-packs.mjs builds packs/ — runs the twenty-five guards first and refuses on failure make-icons.mjs draws all 231 icons rules-text.mjs generates the rules journal FROM rules.mjs mission.mjs the case generator @@ -178,14 +178,15 @@ stops being identity-equal to what it aliases, if a constant is re-declared as a literal, or if an exported rule has no spot-check. The packs used to be built under one set of numbers and played under another; this makes that impossible to ship. -The build runs all twenty-four guards before it writes anything: +The build runs all twenty-five guards before it writes anything: ``` -check-seam: OK — 74 modules scanned, only all-specs.mjs reads NPCS/PREGENS from content.mjs +check-seam: OK — 75 modules scanned, only all-specs.mjs reads NPCS/PREGENS from content.mjs check-generator: OK — 210 posting × tier pairs draw from their own stores, 129 weapons issued, each with the skill it names check-forge: OK — 450 creatures derived across 6 roles × 5 sizes × 5 tiers × 3 species, every one valid, armed with something its body can use, and reproducible from its seed; 90 of them forged identically from the built pack and from content.mjs -check-rules: OK — 113 rules, 72 files scanned, 3 aliases + 46 constant sets checked, 417 formulas verified +check-armed: OK — 35 creature(s) hold a weapon they have no skill for, every one on the record; no new ones +check-rules: OK — 113 rules, 73 files scanned, 3 aliases + 46 constant sets checked, 417 formulas verified check-kits: OK — 25 roles, 10 trades, 226 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct check-lang: OK — en.json, 1044 keys, no leaf/branch collisions check-templates: OK — 19 templates compile diff --git a/package.json b/package.json index 095dd1d..cb6c724 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "play": "node tools/playthrough.mjs", "mj": "node tools/mj-queue.mjs", "simulate": "node tools/simulate.mjs", - "check": "bun tools/check-seam.mjs && bun tools/check-generator.mjs && bun tools/check-forge.mjs && bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-outcomes.mjs && bun tools/check-creatures.mjs && bun tools/check-powers.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-firstblood.mjs && bun tools/check-attackers.mjs && bun tools/check-fight-tail.mjs && bun tools/check-packs.mjs && bun tools/detention-tables.mjs --check && bun tools/bellhouse-plans.mjs --check && bun tools/check-cited.mjs && bun tools/check-figures.mjs && bun tools/check-handouts.mjs && bun tools/check-bestiary.mjs", + "check": "bun tools/check-seam.mjs && bun tools/check-generator.mjs && bun tools/check-forge.mjs && bun tools/check-armed.mjs && bun tools/check-rules.mjs && bun tools/check-kits.mjs && bun tools/check-lang.mjs && bun tools/check-templates.mjs && bun tools/check-behaviour.mjs && bun tools/check-scenarios.mjs && bun tools/check-rollable.mjs && bun tools/check-outcomes.mjs && bun tools/check-creatures.mjs && bun tools/check-powers.mjs && bun tools/check-anatomy.mjs && bun tools/check-lethality.mjs && bun tools/check-focus.mjs && bun tools/check-firstblood.mjs && bun tools/check-attackers.mjs && bun tools/check-fight-tail.mjs && bun tools/check-packs.mjs && bun tools/detention-tables.mjs --check && bun tools/bellhouse-plans.mjs --check && bun tools/check-cited.mjs && bun tools/check-figures.mjs && bun tools/check-handouts.mjs && bun tools/check-bestiary.mjs", "test": "bun run check", "readme": "bun tools/update-readme.mjs", "tokens": "python3 tools/make-tokens.py" diff --git a/tools/armed-baseline.json b/tools/armed-baseline.json new file mode 100644 index 0000000..8f45aec --- /dev/null +++ b/tools/armed-baseline.json @@ -0,0 +1,215 @@ +{ + "note": "Creatures holding a weapon they have no skill for. May shrink, never grow.", + "cases": [ + { + "key": "anglesmith", + "source": "bestiary-anomalous.mjs", + "weapon": "cutter", + "skill": "transposed_weapon" + }, + { + "key": "bp_leah", + "source": "scenario-starter.mjs STARTER_CAST", + "weapon": "punch", + "skill": "brawl" + }, + { + "key": "bp_martin", + "source": "scenario-starter.mjs STARTER_CAST", + "weapon": "punch", + "skill": "brawl" + }, + { + "key": "cailleach", + "source": "bestiary-folklore.mjs", + "weapon": "quarterstaff", + "skill": "melee_weapon:club" + }, + { + "key": "ceyla", + "source": "content.mjs PREGENS", + "weapon": "pistol_med", + "skill": "firearm:pistol" + }, + { + "key": "counterpoint", + "source": "bestiary-anomalous.mjs", + "weapon": "baton", + "skill": "melee_weapon:club" + }, + { + "key": "drowned_crew", + "source": "bestiary-water.mjs", + "weapon": "prybar", + "skill": "melee_weapon:prybar" + }, + { + "key": "five_of_six", + "source": "bestiary-anomalous.mjs", + "weapon": "monoblade", + "skill": "melee_weapon:knife" + }, + { + "key": "hollow_man", + "source": "content.mjs NPCS", + "weapon": "dagger", + "skill": "melee_weapon:knife" + }, + { + "key": "hulk_haunt", + "source": "bestiary-water.mjs", + "weapon": "boarding_axe", + "skill": "melee_weapon:axe" + }, + { + "key": "la_edith", + "source": "scenario-lastadmission.mjs LA_CAST", + "weapon": "punch", + "skill": "brawl" + }, + { + "key": "la_nowak", + "source": "scenario-lastadmission.mjs LA_CAST", + "weapon": "punch", + "skill": "brawl" + }, + { + "key": "la_tunnicliffe", + "source": "scenario-lastadmission.mjs LA_CAST", + "weapon": "punch", + "skill": "brawl" + }, + { + "key": "latecomer", + "source": "bestiary-anomalous.mjs", + "weapon": "baton", + "skill": "melee_weapon:club" + }, + { + "key": "many_hands", + "source": "bestiary-anomalous.mjs", + "weapon": "dagger", + "skill": "melee_weapon:knife" + }, + { + "key": "net_mender", + "source": "bestiary-water.mjs", + "weapon": "dagger", + "skill": "melee_weapon:knife" + }, + { + "key": "od_bewley", + "source": "scenario-openday.mjs OPENDAY_CAST", + "weapon": "punch", + "skill": "brawl" + }, + { + "key": "od_ferrand", + "source": "scenario-openday.mjs OPENDAY_CAST", + "weapon": "punch", + "skill": "brawl" + }, + { + "key": "od_kearsley", + "source": "scenario-openday.mjs OPENDAY_CAST", + "weapon": "punch", + "skill": "brawl" + }, + { + "key": "od_odame", + "source": "scenario-openday.mjs OPENDAY_CAST", + "weapon": "punch", + "skill": "brawl" + }, + { + "key": "od_vasey", + "source": "scenario-openday.mjs OPENDAY_CAST", + "weapon": "punch", + "skill": "brawl" + }, + { + "key": "quarantine_unit", + "source": "content.mjs NPCS", + "weapon": "baton", + "skill": "melee_weapon:club" + }, + { + "key": "recurrence", + "source": "bestiary-anomalous.mjs", + "weapon": "cudgel", + "skill": "melee_weapon:club" + }, + { + "key": "second_pass", + "source": "bestiary-anomalous.mjs", + "weapon": "baton", + "skill": "melee_weapon:club" + }, + { + "key": "the_teind", + "source": "bestiary-folklore.mjs", + "weapon": "dagger", + "skill": "melee_weapon:knife" + }, + { + "key": "the_tideline", + "source": "bestiary-anomalous.mjs", + "weapon": "monoblade", + "skill": "melee_weapon:knife" + }, + { + "key": "the_understudy", + "source": "content.mjs NPCS", + "weapon": "dagger", + "skill": "melee_weapon:knife" + }, + { + "key": "tt_bewley", + "source": "scenario-throughtrain.mjs TT_CAST", + "weapon": "punch", + "skill": "brawl" + }, + { + "key": "tt_bligh", + "source": "scenario-throughtrain.mjs TT_CAST", + "weapon": "punch", + "skill": "brawl" + }, + { + "key": "tt_kell", + "source": "scenario-throughtrain.mjs TT_CAST", + "weapon": "punch", + "skill": "brawl" + }, + { + "key": "tt_prosser", + "source": "scenario-throughtrain.mjs TT_CAST", + "weapon": "punch", + "skill": "brawl" + }, + { + "key": "tt_reyes", + "source": "scenario-throughtrain.mjs TT_CAST", + "weapon": "punch", + "skill": "brawl" + }, + { + "key": "tt_shard", + "source": "scenario-throughtrain.mjs TT_CAST", + "weapon": "punch", + "skill": "brawl" + }, + { + "key": "tt_vane", + "source": "scenario-throughtrain.mjs TT_CAST", + "weapon": "punch", + "skill": "brawl" + }, + { + "key": "uninvited", + "source": "bestiary-folklore.mjs", + "weapon": "dagger", + "skill": "melee_weapon:knife" + } + ] +} diff --git a/tools/check-armed.mjs b/tools/check-armed.mjs new file mode 100644 index 0000000..d15b192 --- /dev/null +++ b/tools/check-armed.mjs @@ -0,0 +1,102 @@ +/** + * A creature must be able to use what its statblock says it carries. + * + * check-kits asserts this for the 36 postings — "every posting can use what it carries" + * is in its success line. Nothing asserted it for the creatures, and 35 of them hold a + * weapon they have no skill for: the hollow man has a dagger and no knife skill, the + * hulk haunt a boarding axe and no axe, and one PREGEN carries a pistol she cannot fire. + * + * It is not a cosmetic mismatch. tools/simulate.mjs sorts a combatant's arms by expected + * damage, and an unskilled weapon sits at the 1% floor, so it loses to the creature's own + * fists every time. The statblock advertises a dagger; the thing punches. That is the + * same defect R-310 found in the NPC generator, one layer further out — written down + * rather than generated. + * + * WHY A RATCHET AND NOT A FIX. Giving each of the 35 the missing skill means choosing a + * rating, and a rating is a balance decision that moves check-lethality's recorded + * numbers for every creature touched. That belongs to whoever owns the game, not to the + * guard that found it. So the existing 35 are recorded by name and held: the count may + * fall, and it may never rise. A new creature cannot be written holding something it + * cannot use. + * + * bun tools/check-armed.mjs fail on anything not on the record + * bun tools/check-armed.mjs --update re-record (only ever to LOWER the count) + */ +import { readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { collectSpecs } from "./all-specs.mjs"; +import { WEAPONS } from "./content.mjs"; +import { expandFromRegister } from "./expand-spec.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const RECORD = path.join(ROOT, "tools", "armed-baseline.json"); +const UPDATE = process.argv.includes("--update"); + +const WEAPON_BY_KEY = new Map(WEAPONS.map(w => [w.key, w])); +const skillOf = w => (w.spec ? `${w.fam}:${w.spec}` : w.fam); + +/* Expanded exactly as the build expands it. A spec written as a job carries no skills of + its own — they come out of the register at build time — and judging one unexpanded + would report a Field Officer as unable to fire the pistol her posting issues her. */ +const found = []; +for (const row of await collectSpecs()) { + let spec = row.spec; + if (!spec.skills && (spec.role || spec.trade)) { + spec = expandFromRegister(spec, { where: "check-armed" }); + } + if (!spec.skills) continue; + const has = new Set(spec.skills.map(s => (s.spec ? `${s.fam}:${s.spec}` : s.fam))); + for (const key of [...(spec.weapons ?? []), ...(spec.extraWeapons ?? [])]) { + const w = WEAPON_BY_KEY.get(key); + if (!w) continue; // check-creatures owns "is that a real weapon" + const id = skillOf(w); + if (!has.has(id)) found.push({ key: spec.key, source: row.label, weapon: key, skill: id }); + } +} +found.sort((a, b) => `${a.key}${a.weapon}`.localeCompare(`${b.key}${b.weapon}`)); +const idOf = f => `${f.key}:${f.weapon}`; + +if (UPDATE) { + /* The FIRST recording is the baseline and is allowed to contain anything. Every later + one may only shrink. Without that distinction --update refused to create the record + it needs, because with no file on disk all 35 existing cases count as new — which is + what happened on the first run of this tool. */ + const first = !existsSync(RECORD); + const before = first ? { cases: [] } : JSON.parse(await readFile(RECORD, "utf8")); + const prev = new Set((before.cases ?? []).map(idOf)); + const added = first ? [] : found.filter(f => !prev.has(idOf(f))); + if (added.length) { + console.error(`check-armed: refusing to --update — ${added.length} NEW case(s) would be ` + + `recorded, and this record may only ever shrink:`); + for (const f of added) console.error(` ${f.key} carries ${f.weapon} and has no ${f.skill}`); + console.error(`\n Give the creature the skill, or take the weapon off it.`); + process.exit(1); + } + await writeFile(RECORD, JSON.stringify({ note: "Creatures holding a weapon they have no skill for. May shrink, never grow.", cases: found }, null, 2) + "\n"); + console.log(`check-armed: recorded ${found.length} case(s) (was ${before.cases?.length ?? 0})`); + process.exit(0); +} + +if (!existsSync(RECORD)) { + console.error(`check-armed: no record at tools/armed-baseline.json — run with --update once`); + process.exit(1); +} +const record = JSON.parse(await readFile(RECORD, "utf8")); +const known = new Set((record.cases ?? []).map(idOf)); +const novel = found.filter(f => !known.has(idOf(f))); +const fixed = (record.cases ?? []).filter(c => !found.some(f => idOf(f) === idOf(c))); + +if (novel.length) { + console.error(`check-armed: FAILED — ${novel.length} creature(s) carry a weapon they cannot use`); + for (const f of novel) { + console.error(` ${f.key} (${f.source}) carries ${f.weapon}, which is fired by ${f.skill}, and has no such skill`); + } + console.error(`\n simulate.mjs sorts arms by expected damage and an unskilled weapon sits at the`); + console.error(` 1% floor, so this creature will punch instead and the statblock will lie about it.`); + console.error(` Give it the skill or take the weapon away. Do not re-record.`); + process.exit(1); +} +console.log(`check-armed: OK — ${found.length} creature(s) hold a weapon they have no skill for, ` + + `every one on the record${fixed.length ? `, and ${fixed.length} since fixed` : ""}; no new ones`);