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`);