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>
103 lines
5.4 KiB
JavaScript
103 lines
5.4 KiB
JavaScript
/**
|
|
* 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`);
|