check-armed: a creature must be able to use what it carries

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 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-09-23 00:32:24 +01:00
co-authored by Claude Opus 5
parent 09e33907fa
commit 25d8f04a65
4 changed files with 324 additions and 6 deletions
+6 -5
View File
@@ -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:
<!-- guards:start -->
```
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
+1 -1
View File
@@ -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"
+215
View File
@@ -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"
}
]
}
+102
View File
@@ -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`);