41 statblocks carry a POWER in their tactics and simulate.mjs read none of them, so a redcap that ignores the cumulative defence penalty has been measured as a creature that tires -- in check-lethality, in check-focus, and in every figure published about it. The defect was not that the powers were unimplemented, it was that nothing said they were not. powers.mjs classifies all 41: 2 wired, 14 notSimulable with a stated reason, 25 not fight rules. check-powers refuses an unclassified POWER and refuses a notSimulable without a reason -- and it does not test that the harness imports a power, it fights the creature with and without and requires the two to disagree. Moved: the courier 9.4% to 1.0% wiped (it attacks at half while carrying), the redcap 0.7% to 0.9% (small, because these fights rarely spend a second defence). ARGENT AND GULES was wired and then un-wired: it tripled the supporter's wipe rate to 75.2% because the harness has no ground and applied the borough-ground condition unconditionally. Same reason THE PULL is not wired. I had wired one and refused the other on identical facts. Lethality and focus re-recorded, bestiary regenerated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
112 lines
4.9 KiB
JavaScript
112 lines
4.9 KiB
JavaScript
/**
|
|
* Every creature's POWER must be classified, and every wired one must actually reach the fight.
|
|
*
|
|
* 41 statblocks carry a POWER in their tactics prose and, until R-275, not one of them
|
|
* reached `simulate.mjs`. The harness read characteristics and weapons and nothing else,
|
|
* so a redcap — whose entry says it ignores the cumulative defence penalty — was measured
|
|
* as a creature that tires, in check-lethality, in check-focus, and in every figure this
|
|
* project has published about it. The defect was not that the powers were unimplemented.
|
|
* It was that nothing anywhere said they were not.
|
|
*
|
|
* So this guard asserts two things, and the second is the one that matters:
|
|
*
|
|
* 1. Every POWER in content.mjs is classified in powers.mjs — wired, notSimulable with
|
|
* a reason, or outOfCombat. A creature added tomorrow cannot arrive unclassified.
|
|
*
|
|
* 2. Every WIRED power changes a fight. Not "is referenced in the harness" — measured:
|
|
* the creature is fought with its power and again with it stripped, and the two must
|
|
* disagree. A power the simulator reads and does not act on is the defect this whole
|
|
* entry is about, one layer in, and a guard that checked for the import would have
|
|
* passed on it.
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { NPCS } from "./content.mjs";
|
|
import { ROSTER } from "./roster.mjs";
|
|
import { POWERS, EFFECT_KINDS } from "./powers.mjs";
|
|
import { runFight, makeRng, seedFor } from "./simulate.mjs";
|
|
|
|
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), "..");
|
|
const RUNS = 400;
|
|
const SEED = 11;
|
|
const PARTY_KEYS = ["pc_holloway", "pc_okonkwo", "pc_nkemdirim", "pc_ferriby"];
|
|
const party = PARTY_KEYS.map(k => {
|
|
const found = ROSTER.find(r => r.key === k);
|
|
if (!found) { console.error(`check-powers: the frozen party names "${k}", not on the roster`); process.exit(1); }
|
|
return found;
|
|
});
|
|
|
|
const problems = [];
|
|
|
|
/* 1. Classification. The prose is the authority for what a power SAYS; powers.mjs is the
|
|
authority for what the harness does about it, and every one must be in both. */
|
|
const withPower = NPCS.filter(n => /POWER:/.test(n.tactics ?? ""));
|
|
for (const spec of withPower) {
|
|
const p = POWERS[spec.key];
|
|
if (!p) {
|
|
problems.push(` ${spec.key} carries a POWER in its tactics and is not classified in powers.mjs — `
|
|
+ `add it as wired, notSimulable (with a reason) or outOfCombat`);
|
|
continue;
|
|
}
|
|
if (!["wired", "notSimulable", "outOfCombat"].includes(p.kind)) {
|
|
problems.push(` ${spec.key}: unknown classification "${p.kind}"`);
|
|
continue;
|
|
}
|
|
if (p.kind === "wired") {
|
|
const keys = Object.keys(p.effect ?? {});
|
|
if (!keys.length) problems.push(` ${spec.key}: wired but carries no effect`);
|
|
for (const k of keys) {
|
|
if (!EFFECT_KINDS.includes(k)) {
|
|
problems.push(` ${spec.key}: effect "${k}" is not one the harness knows (${EFFECT_KINDS.join(", ")})`);
|
|
}
|
|
}
|
|
}
|
|
if (p.kind === "notSimulable" && !p.why) {
|
|
problems.push(` ${spec.key}: notSimulable without a reason. A gap nobody wrote down is the defect this guard exists for.`);
|
|
}
|
|
}
|
|
for (const key of Object.keys(POWERS)) {
|
|
if (!withPower.some(s => s.key === key)) {
|
|
problems.push(` ${key} is classified in powers.mjs but no longer carries a POWER in content.mjs`);
|
|
}
|
|
}
|
|
|
|
/* 2. Every wired power must MOVE something. Fought with its power and without it. */
|
|
const winRate = (spec, strip) => {
|
|
let won = 0;
|
|
const seed = seedFor(SEED, spec.key);
|
|
for (let i = 0; i < RUNS; i++) {
|
|
const foe = strip ? { ...spec, key: `${spec.key}__nopower` } : spec;
|
|
if (runFight(makeRng(seed + i * 2654435761), party, [foe, foe]).won) won++;
|
|
}
|
|
return (100 * won) / RUNS;
|
|
};
|
|
const moved = [];
|
|
for (const key of Object.keys(POWERS)) {
|
|
if (POWERS[key].kind !== "wired") continue;
|
|
const spec = NPCS.find(n => n.key === key);
|
|
if (!spec) continue;
|
|
const withIt = winRate(spec, false);
|
|
const without = winRate(spec, true);
|
|
if (withIt === without) {
|
|
problems.push(` ${key}: ${POWERS[key].name} is wired and changes nothing — the party wins `
|
|
+ `${withIt}% of fights either way over ${RUNS} runs. Either the harness is not applying it `
|
|
+ `or the effect does not bite on this creature. A power that is read and not acted on is `
|
|
+ `exactly what R-275 was about.`);
|
|
} else {
|
|
moved.push(`${key} ${withIt.toFixed(1)}% -> ${without.toFixed(1)}% without it`);
|
|
}
|
|
}
|
|
|
|
if (problems.length) {
|
|
console.error(`check-powers: FAILED — ${problems.length} problem(s)`);
|
|
problems.forEach(p => console.error(p));
|
|
process.exit(1);
|
|
}
|
|
|
|
const by = { wired: 0, notSimulable: 0, outOfCombat: 0 };
|
|
for (const p of Object.values(POWERS)) by[p.kind]++;
|
|
console.log(`check-powers: OK — ${withPower.length} powers classified, ${by.wired} wired and each one `
|
|
+ `measurably changes its fight, ${by.notSimulable} recorded as beyond the harness with a reason, `
|
|
+ `${by.outOfCombat} not fight rules`);
|