The forge: derive a creature, and say which rule made each number
CREATURE_FORGE_PLAN Part 3. `new` prints a spec as source in the shape the bestiary files are written in, `explain` prints the rule behind every value, `measure` fights it against the party check-lethality freezes, and `list` shows the knobs. The derivation is forge.mjs at the ROOT, beside rules.mjs and postings.mjs and under the same law: pure, no Foundry, no catalogues. Part 5 asks for an in-Foundry panel that forges a variant at the table and says it must not diverge from the CLI. One derivation in a module both import is the only way to guarantee that rather than argue it. Natural attacks now declare which body plans can make them, in the weapon catalogue, for the same reason a weapon already names the skill that fires it: the weapon is the thing that knows. Anatomy cannot answer it — a body plan records that a creature has a head, not that the head has horns. `explain` earned itself on the first run. A large brute derived with a baseline body could not gore, trample or hoof, and the trace said so; the finished statblock alone would have shipped bulls that bite. The role picks the body now, a non-baseline species overrides it, and --body beats both. check-forge (guard 26) derives all 450 combinations of role, size, tier and species and puts each through the bestiary's own validate(). Three mutations were planted in the forge to test it, and it caught one. Dropping the seed was caught. Dropping the brawl grant was a genuine no-op, since every role already lists brawl. Dropping the body-plan filter left all 450 rows green while the success line read "armed with something its body can use" — the guard read the location table and never used it. It checks each issued weapon against the catalogue's own declaration now, and the same mutation produces 500 failures. check-rules' inline-armour pattern matched any clamp containing the word, and false-positived three times in one evening on counts that were never damage. damageAfterArmour is a subtraction, so the pattern requires one. A guard that makes people contort working code to keep it quiet is training them badly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5351c25fda
commit
d7def0b31e
@@ -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-three guards; the build refuses to run if they fail
|
||||
npm run check # the twenty-four 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-three guards first and refuses on failure
|
||||
build-packs.mjs builds packs/ — runs the twenty-four 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,13 +178,14 @@ 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-three guards before it writes anything:
|
||||
The build runs all twenty-four guards before it writes anything:
|
||||
|
||||
<!-- guards:start -->
|
||||
```
|
||||
check-seam: OK — 70 modules scanned, only all-specs.mjs reads NPCS/PREGENS from content.mjs
|
||||
check-seam: OK — 74 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-rules: OK — 113 rules, 68 files scanned, 3 aliases + 46 constant sets checked, 417 formulas verified
|
||||
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
|
||||
check-rules: OK — 113 rules, 72 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, 1027 keys, no leaf/branch collisions
|
||||
check-templates: OK — 19 templates compile
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* THE FORGE — deriving a creature from a description of it.
|
||||
*
|
||||
* `docs/CREATURE_FORGE_PLAN.md` Part 3. It is deliberately not a form-filler: typing
|
||||
* speed was never the problem, and the plan says so in as many words. It is a derivation,
|
||||
* and the point of a derivation is that it can be interrogated — every number comes back
|
||||
* with the rule that produced it, so a statblock can be tuned rather than re-rolled.
|
||||
*
|
||||
* WHY THIS FILE IS AT THE ROOT. Part 5 of the same plan asks for an in-Foundry panel that
|
||||
* forges a variant at the table, and says it must not be able to diverge from the CLI.
|
||||
* There is exactly one way to guarantee that rather than argue it: one derivation, in a
|
||||
* module both can import. So this file lives beside rules.mjs, postings.mjs and
|
||||
* anatomy.mjs, under their law — it may not import from the runtime, and it may not touch
|
||||
* a Foundry global.
|
||||
*
|
||||
* It also may not import the catalogues. tools/content.mjs is the node side's, and the
|
||||
* engine reads packs; so the caller passes `hasWeapon`, `plansFor` and the list of
|
||||
* natural attacks, exactly as drawKitFor takes a classifier. A weapon this file would
|
||||
* like to give a creature is given only if the caller confirms it exists AND that the
|
||||
* weapon's own declaration allows this body. Nothing here contains a hand-written list
|
||||
* of weapon keys that could drift out of the catalogue without anybody noticing.
|
||||
*
|
||||
* The output is a spec in the shape tools/bestiary-*.mjs files are written in, so it
|
||||
* passes creature-schema's validate() and can be pasted into a bestiary file as source.
|
||||
*/
|
||||
import { SPECIES } from "./postings.mjs";
|
||||
import { LOCATION_TABLES } from "./anatomy.mjs";
|
||||
|
||||
/**
|
||||
* Size bands. SIZ is the spine of a creature: hit points come from CON and SIZ, the
|
||||
* damage modifier from STR and SIZ, and build-packs cuts the token footprint at 21 and
|
||||
* 30. The bands are cut at the same places, so a "huge" creature is the one that
|
||||
* actually occupies three squares rather than one that merely sounds like it.
|
||||
*/
|
||||
export const SIZE_BANDS = {
|
||||
tiny: { label: "Tiny", siz: [2, 5], str: [2, 6], con: [6, 10], note: "a cat, a hare, a bird" },
|
||||
small: { label: "Small", siz: [6, 10], str: [6, 11], con: [9, 14], note: "a dog, a goose" },
|
||||
medium: { label: "Medium", siz: [11, 16], str: [11, 18], con: [11, 17], note: "a person, a wolf, a boar" },
|
||||
large: { label: "Large", siz: [17, 24], str: [17, 26], con: [15, 22], note: "a horse, a bull — two squares" },
|
||||
huge: { label: "Huge", siz: [25, 34], str: [25, 38], con: [20, 30], note: "a whale, a cart-sized thing — three squares" }
|
||||
};
|
||||
|
||||
/**
|
||||
* What the creature does for a living, as skill emphasis and preferred natural weapons.
|
||||
*
|
||||
* `attacks` is an ORDER OF PREFERENCE, not a list to be issued whole. The caller's
|
||||
* `hasWeapon` decides what exists and `plans` decides what the body can do — an animal
|
||||
* with no hands cannot claw with them, and only a winged plan has talons. A role asking
|
||||
* for something the body cannot do falls through to the next entry rather than producing
|
||||
* a creature that attacks with equipment it does not have.
|
||||
*/
|
||||
export const CREATURE_ROLES = {
|
||||
ambusher: { label: "Ambusher", blurb: "Waits, takes one thing, and is not there afterwards",
|
||||
core: ["stealth", "scent", "dodge", "brawl"], support: ["spot", "listen", "athletics"],
|
||||
attacks: ["bite", "claw", "grasp"], plan: "quadruped", armour: -1, stat: "dex" },
|
||||
brute: { label: "Brute", blurb: "Comes straight at you and does not stop for a corridor",
|
||||
core: ["brawl", "athletics"], support: ["spot", "listen", "dodge"],
|
||||
attacks: ["gore", "trample", "hoof", "bite"], plan: "quadruped", armour: +2, stat: "str" },
|
||||
hunter: { label: "Hunter", blurb: "Follows, wears down, and finishes at its own pace",
|
||||
core: ["scent", "brawl", "athletics", "spot"], support: ["stealth", "listen", "dodge"],
|
||||
attacks: ["bite", "claw", "talon"], plan: "quadruped", armour: 0, stat: "con" },
|
||||
lurker: { label: "Lurker", blurb: "Holds still in water or dark until something is close",
|
||||
core: ["stealth", "brawl", "listen"], support: ["dodge", "spot", "athletics"],
|
||||
attacks: ["constrict", "grasp", "bite"], plan: "baseline", armour: +1, stat: "str" },
|
||||
flyer: { label: "Flyer", blurb: "Takes from above and is gone before the second shot",
|
||||
core: ["dodge", "spot", "athletics", "brawl"], support: ["listen", "stealth", "scent"],
|
||||
attacks: ["talon", "beak", "claw"], plan: "winged", armour: -1, stat: "dex" },
|
||||
sentinel: { label: "Sentinel", blurb: "Guards one place and will not be drawn off it",
|
||||
core: ["spot", "listen", "brawl"], support: ["dodge", "athletics", "stealth"],
|
||||
attacks: ["gore", "claw", "bite"], plan: "quadruped", armour: +3, stat: "con" }
|
||||
};
|
||||
|
||||
/**
|
||||
* Tiers. A single multiplier the GM can reason about, which is the whole design brief
|
||||
* for this knob: tier 3 is the ordinary case and every band is stated relative to it.
|
||||
*/
|
||||
export const FORGE_TIERS = {
|
||||
1: { label: "Nuisance", skill: [25, 40], armourBase: 0, chMul: 0.80 },
|
||||
2: { label: "Dangerous", skill: [35, 50], armourBase: 1, chMul: 0.90 },
|
||||
3: { label: "Deadly", skill: [45, 65], armourBase: 3, chMul: 1.00 },
|
||||
4: { label: "Apex", skill: [60, 78], armourBase: 5, chMul: 1.15 },
|
||||
5: { label: "Legend", skill: [72, 88], armourBase: 7, chMul: 1.30 }
|
||||
};
|
||||
|
||||
/* The table that used to be here — which attacks each body plan could make — has moved
|
||||
onto the weapons themselves, as `plans`. It was a second list of valid values, and a
|
||||
guard written to check the forge against it would have been checking the forge against
|
||||
the forge. See the note above the natural attacks in tools/content.mjs. */
|
||||
|
||||
/* A small deterministic generator, so a forged creature is reproducible from its seed.
|
||||
Math.random would make `explain` a lie: the trace would describe numbers that could
|
||||
not be produced again. */
|
||||
export function forgeRng(seed = 1) {
|
||||
let s = (Number(seed) || 1) >>> 0 || 1;
|
||||
return () => { s ^= s << 13; s >>>= 0; s ^= s >> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; };
|
||||
}
|
||||
|
||||
const clampInt = (n, lo, hi) => Math.max(lo, Math.min(hi, Math.round(n)));
|
||||
|
||||
/**
|
||||
* Forge a creature.
|
||||
*
|
||||
* Returns `{ spec, trace, problems }`. `trace` is one line per derived value naming the
|
||||
* rule that produced it — that is `explain`, and it is built during derivation rather
|
||||
* than reconstructed afterwards, because a reconstruction is a second implementation of
|
||||
* the thing it claims to describe.
|
||||
*/
|
||||
export function forgeCreature({
|
||||
key, name, species = "baseline", bodyPlan = null, size = "medium",
|
||||
role = "hunter", tier = 3, seed = 1
|
||||
} = {}, { hasWeapon = () => true, plansFor = () => null, naturalAttacks = [] } = {}) {
|
||||
const problems = [];
|
||||
const SP = SPECIES[species];
|
||||
if (!SP) problems.push(`no species "${species}" — ${Object.keys(SPECIES).join(", ")}`);
|
||||
const R = CREATURE_ROLES[role];
|
||||
if (!R) problems.push(`no role "${role}" — ${Object.keys(CREATURE_ROLES).join(", ")}`);
|
||||
const B = SIZE_BANDS[size];
|
||||
if (!B) problems.push(`no size "${size}" — ${Object.keys(SIZE_BANDS).join(", ")}`);
|
||||
const T = FORGE_TIERS[Number(tier)];
|
||||
if (!T) problems.push(`no tier "${tier}" — 1 to 5`);
|
||||
/* The body, in order of who has the best claim to it.
|
||||
*
|
||||
* The species' profile came first, and every baseline species profile IS "baseline" —
|
||||
* a person's shape — so a large brute was derived with no hooves and no horns and the
|
||||
* trace dutifully reported that it could not gore, trample or hoof, leaving it with a
|
||||
* bite. A bull is not a person who bites. The ROLE knows what shape the animal is; a
|
||||
* non-baseline species knows better still, because asking for a vesh creature is
|
||||
* asking for a vesh body; and --body beats both.
|
||||
*
|
||||
* This was visible only because `explain` prints the rejections. A forge that printed
|
||||
* the finished statblock alone would have shipped bulls that bite. */
|
||||
const speciesPlan = SP?.profile && SP.profile !== "baseline" ? SP.profile : null;
|
||||
const plan = bodyPlan ?? speciesPlan ?? R?.plan ?? "baseline";
|
||||
if (!LOCATION_TABLES[plan]) problems.push(`no body plan "${plan}" — ${Object.keys(LOCATION_TABLES).join(", ")}`);
|
||||
if (problems.length) return { spec: null, trace: [], problems };
|
||||
|
||||
const rand = forgeRng(seed);
|
||||
const trace = [];
|
||||
const say = (field, value, because) => { trace.push({ field, value, because }); return value; };
|
||||
const inBand = ([lo, hi]) => lo + Math.floor(rand() * (hi - lo + 1));
|
||||
|
||||
/* ---- characteristics ---- */
|
||||
const ch = {};
|
||||
ch.siz = say("siz", inBand(B.siz), `${B.label} band ${B.siz.join("–")}`);
|
||||
ch.str = say("str", clampInt(inBand(B.str) * T.chMul, 1, 60),
|
||||
`${B.label} band ${B.str.join("–")}, tier ${tier} ×${T.chMul}`);
|
||||
ch.con = say("con", clampInt(inBand(B.con) * T.chMul, 1, 60),
|
||||
`${B.label} band ${B.con.join("–")}, tier ${tier} ×${T.chMul}`);
|
||||
// An animal is not stupid, it is uninterested. INT and EDU stay low and flat because
|
||||
// the sheet uses EDU for training and a beast has none; the bestiary writes 1.
|
||||
ch.int = say("int", inBand([3, 9]), "animal mind, 3–9");
|
||||
ch.pow = say("pow", inBand([10, 18]), "10–18; POW is what makes a thing uncanny");
|
||||
ch.dex = say("dex", clampInt(inBand([9, 18]) * (role === "flyer" || role === "ambusher" ? 1.25 : 1), 1, 40),
|
||||
role === "flyer" || role === "ambusher" ? "9–18, ×1.25 for a fast role" : "9–18");
|
||||
ch.cha = say("cha", inBand([3, 10]), "3–10");
|
||||
ch.edu = say("edu", 1, "a beast has no schooling");
|
||||
for (const [k, d] of Object.entries(SP.shift ?? {})) {
|
||||
ch[k] = say(k, Math.max(3, (ch[k] ?? 10) + d), `${SP.label} shift ${d > 0 ? "+" : ""}${d}`);
|
||||
}
|
||||
|
||||
/* ---- armour ---- */
|
||||
const armour = say("naturalArmour", Math.max(0, T.armourBase + R.armour),
|
||||
`tier ${tier} base ${T.armourBase}, ${R.label} ${R.armour >= 0 ? "+" : ""}${R.armour}`);
|
||||
|
||||
/* ---- natural weapons ---- */
|
||||
// Preference order, filtered by what the body can do and what the catalogue has. A
|
||||
// role that wants a talon on a four-legged animal gets its next choice instead.
|
||||
// A weapon with no `plans` is unrestricted; one that lists them must list this body.
|
||||
const canWield = w => { const p = plansFor(w); return !p || p.includes(plan); };
|
||||
const weapons = [];
|
||||
for (const w of R.attacks) {
|
||||
if (weapons.length >= 2) break;
|
||||
if (!hasWeapon(w)) { trace.push({ field: "weapons", value: `(${w} skipped)`, because: "not in the weapon catalogue" }); continue; }
|
||||
if (!canWield(w)) { trace.push({ field: "weapons", value: `(${w} skipped)`, because: `the catalogue says ${w} needs ${(plansFor(w) ?? []).join(" or ")}, and this is a ${plan}` }); continue; }
|
||||
weapons.push(w);
|
||||
}
|
||||
if (!weapons.length) {
|
||||
// Never return something with nothing to do in a fight. R-213's lesson, applied at
|
||||
// the point the creature is made rather than patched afterwards. The fallback is
|
||||
// drawn from the same declarations, so it cannot reach for something this body
|
||||
// cannot use either.
|
||||
const fallback = (naturalAttacks ?? []).find(w => hasWeapon(w) && canWield(w));
|
||||
if (fallback) weapons.push(fallback);
|
||||
else problems.push(`no natural attack this ${plan} body can make is in the catalogue`);
|
||||
}
|
||||
say("weapons", weapons.join(", "), `${R.label} preference, filtered by a ${plan} body`);
|
||||
|
||||
/* ---- skills ---- */
|
||||
const band = T.skill;
|
||||
const skills = [];
|
||||
const addSkill = (famSpec, range, why) => {
|
||||
const [fam, spec = ""] = famSpec.split(":");
|
||||
if (skills.some(s => s.fam === fam && s.spec === spec)) return;
|
||||
const val = inBand(range);
|
||||
skills.push({ fam, spec, val });
|
||||
trace.push({ field: `skill ${famSpec}`, value: val, because: why });
|
||||
};
|
||||
for (const k of R.core) addSkill(k, band, `${R.label} core, tier ${tier} band ${band.join("–")}`);
|
||||
const supportBand = [Math.round(band[0] * 0.7), Math.round(band[1] * 0.7)];
|
||||
for (const k of R.support) addSkill(k, supportBand, `${R.label} support, 70% of the tier band`);
|
||||
// Whatever it attacks with names a skill, and all ten natural attacks are brawl. Grant
|
||||
// it explicitly rather than relying on the role list happening to contain it.
|
||||
addSkill("brawl", band, "it attacks with its body, and every natural attack is Brawl");
|
||||
|
||||
const spec = {
|
||||
key: key ?? `forged_${role}_${size}_${tier}`,
|
||||
name: name ?? `${T.label} ${R.label.toLowerCase()}`,
|
||||
role: `FORGED. ${R.blurb}, ${B.label.toLowerCase()} — ${B.note}`,
|
||||
species, bodyPlan: plan,
|
||||
naturalArmour: armour,
|
||||
ch, skills, weapons,
|
||||
tactics: `Forged at tier ${tier} (${T.label}) as a ${B.label.toLowerCase()} ${R.label.toLowerCase()}. `
|
||||
+ `It fights with ${weapons.join(" and ")} and carries ${armour} points of hide. `
|
||||
+ `Replace this line before the thing sees a table: a statblock without tactics is a `
|
||||
+ `number, and the GM has to invent the fight anyway.`
|
||||
};
|
||||
return { spec, trace, problems };
|
||||
}
|
||||
+1
-1
@@ -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-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-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"
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Every creature the forge offers to make must be one the game accepts.
|
||||
*
|
||||
* The forge takes four knobs — role, size, tier, species — and a GM will reach for the
|
||||
* combination nobody tried. A derivation that produces an invalid spec at one corner of
|
||||
* that space is not a tool, it is a trap, and the corner it fails at is by definition
|
||||
* the one nobody looked at.
|
||||
*
|
||||
* So all of it is derived here, every role × size × tier × species, and each result is
|
||||
* put through the same validate() that guards the written bestiary. Four further things
|
||||
* are asserted that validate() has no opinion about:
|
||||
*
|
||||
* - it can fight. A creature with no natural attack is the R-213 defect, made new
|
||||
* rather than inherited.
|
||||
* - what it fights with, its body can do. A bull that bites because the derivation
|
||||
* silently fell back is a real bug this guard found on its first run.
|
||||
* - the same seed gives the same creature. `explain` claims to name the rule behind
|
||||
* each number; if the numbers are not reproducible that claim is a fiction.
|
||||
* - every weapon and skill it names resolves against the catalogues.
|
||||
*
|
||||
* bun tools/check-forge.mjs
|
||||
*/
|
||||
import { forgeCreature, SIZE_BANDS, CREATURE_ROLES, FORGE_TIERS } from "../forge.mjs";
|
||||
import { validate } from "./creature-schema.mjs";
|
||||
import { WEAPONS, SKILL_CATALOGUE } from "./content.mjs";
|
||||
import { LOCATION_TABLES } from "../anatomy.mjs";
|
||||
import { SPECIES } from "../postings.mjs";
|
||||
|
||||
const WEAPON_BY_KEY = new Map(WEAPONS.map(w => [w.key, w]));
|
||||
const SKILLS = new Set(SKILL_CATALOGUE.map(s => (s[1] ? `${s[0]}:${s[1]}` : s[0])));
|
||||
const NATURAL = WEAPONS.filter(w => Array.isArray(w.plans)).map(w => w.key);
|
||||
const catalogues = {
|
||||
hasWeapon: k => WEAPON_BY_KEY.has(k),
|
||||
plansFor: k => WEAPON_BY_KEY.get(k)?.plans ?? null,
|
||||
naturalAttacks: NATURAL
|
||||
};
|
||||
|
||||
const problems = [];
|
||||
let derived = 0;
|
||||
|
||||
for (const role of Object.keys(CREATURE_ROLES)) {
|
||||
for (const size of Object.keys(SIZE_BANDS)) {
|
||||
for (const tier of Object.keys(FORGE_TIERS)) {
|
||||
for (const species of Object.keys(SPECIES)) {
|
||||
const where = `${role}/${size}/t${tier}/${species}`;
|
||||
const req = { role, size, tier: Number(tier), species, seed: 5, key: `probe_${role}_${size}_${tier}_${species}` };
|
||||
const { spec, trace, problems: derivProblems } = forgeCreature(req, catalogues);
|
||||
if (derivProblems.length) { problems.push(`${where}: ${derivProblems.join("; ")}`); continue; }
|
||||
derived++;
|
||||
|
||||
for (const p of validate(spec)) {
|
||||
problems.push(`${where}: ${p.message ?? JSON.stringify(p)}`);
|
||||
}
|
||||
if (!spec.weapons.length) {
|
||||
problems.push(`${where}: no natural attack — it would stand in the fight with nothing to do`);
|
||||
}
|
||||
// What it fights with, the body must be able to do — checked against the
|
||||
// WEAPON's own declaration, not against any table the forge also reads for its
|
||||
// filtering. The first version of this guard read the location table here and
|
||||
// then never used it, while the success line claimed the check had been made:
|
||||
// deleting the forge's body filter entirely left all 450 rows green.
|
||||
if (!LOCATION_TABLES[spec.bodyPlan]) {
|
||||
problems.push(`${where}: body plan "${spec.bodyPlan}" has no hit-location table`);
|
||||
}
|
||||
for (const w of spec.weapons) {
|
||||
const plans = WEAPON_BY_KEY.get(w)?.plans;
|
||||
if (Array.isArray(plans) && !plans.includes(spec.bodyPlan)) {
|
||||
problems.push(`${where}: issued "${w}", which the catalogue says only a `
|
||||
+ `${plans.join(" or ")} can make, to a ${spec.bodyPlan}`);
|
||||
}
|
||||
}
|
||||
for (const w of spec.weapons) {
|
||||
if (!WEAPON_BY_KEY.has(w)) { problems.push(`${where}: weapon "${w}" is in no catalogue`); continue; }
|
||||
const fam = WEAPON_BY_KEY.get(w).fam;
|
||||
const spc = WEAPON_BY_KEY.get(w).spec;
|
||||
const skillKey = spc ? `${fam}:${spc}` : fam;
|
||||
if (!spec.skills.some(s => (s.spec ? `${s.fam}:${s.spec}` : s.fam) === skillKey)) {
|
||||
problems.push(`${where}: carries "${w}", which is fired by ${skillKey}, and has no such skill`);
|
||||
}
|
||||
}
|
||||
for (const s of spec.skills) {
|
||||
const k = s.spec ? `${s.fam}:${s.spec}` : s.fam;
|
||||
if (!SKILLS.has(k)) problems.push(`${where}: skill "${k}" is not in the catalogue`);
|
||||
if (!Number.isFinite(s.val) || s.val < 1) problems.push(`${where}: skill "${k}" has value ${s.val}`);
|
||||
}
|
||||
// Reproducible, or `explain` describes numbers that cannot be made again.
|
||||
const again = forgeCreature(req, catalogues).spec;
|
||||
if (JSON.stringify(again) !== JSON.stringify(spec)) {
|
||||
problems.push(`${where}: the same seed produced a different creature — explain() is describing numbers it cannot reproduce`);
|
||||
}
|
||||
if (!trace.length) problems.push(`${where}: derived with an empty trace, so nothing can be explained`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* A different seed must give a different creature, or the seed is decorative and every
|
||||
forged creature at a tier is the same one wearing a different name. */
|
||||
{
|
||||
const a = forgeCreature({ role: "hunter", size: "medium", tier: 3, seed: 1 }, catalogues).spec;
|
||||
const b = forgeCreature({ role: "hunter", size: "medium", tier: 3, seed: 99 }, catalogues).spec;
|
||||
if (JSON.stringify(a.ch) === JSON.stringify(b.ch)) {
|
||||
problems.push(`seeds 1 and 99 produced identical characteristics — the seed does nothing`);
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length) {
|
||||
console.error(`check-forge: FAILED — ${problems.length} problem(s)`);
|
||||
for (const p of problems.slice(0, 30)) console.error(" " + p);
|
||||
if (problems.length > 30) console.error(` … and ${problems.length - 30} more`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`check-forge: OK — ${derived} creatures derived across `
|
||||
+ `${Object.keys(CREATURE_ROLES).length} roles × ${Object.keys(SIZE_BANDS).length} sizes × `
|
||||
+ `${Object.keys(FORGE_TIERS).length} tiers × ${Object.keys(SPECIES).length} species, `
|
||||
+ `every one valid, armed with something its body can use, and reproducible from its seed`);
|
||||
+15
-2
@@ -57,8 +57,17 @@ for (const rel of files) {
|
||||
const REIMPLEMENTED = [
|
||||
[/Math\.(?:floor|round|ceil)\(\s*[^()]*\b(?:hide|naturalArmour|armour|armor)\b[^()]*\/\s*2/i,
|
||||
"halves armour inline — armourAgainst() and exposedHideFor() own that"],
|
||||
/* Requires the SUBTRACTION, not merely the word.
|
||||
damageAfterArmour is `rolled - armour`, floored at zero, so an inline
|
||||
reimplementation of it necessarily contains a minus. Without that requirement this
|
||||
matched any clamp with "armour" in it, and it false-positived three times in one
|
||||
evening on counts that were never damage: `Math.max(0, d.armour | 0)` in
|
||||
drawKitFor and `Math.max(0, T.armourBase + R.armour)` in the forge. Each time the
|
||||
answer was to contort the code around the guard, which teaches people to write
|
||||
worse code to keep a check quiet. */
|
||||
[/Math\.max\(\s*0\s*,[^;]*\b(?:armour|armor)\b[^;]*\)/i,
|
||||
"subtracts armour inline — damageAfterArmour() owns that"]
|
||||
"subtracts armour inline — damageAfterArmour() owns that",
|
||||
/-/]
|
||||
];
|
||||
for (const rel of files) {
|
||||
if (rel.startsWith("tools/check-")) continue;
|
||||
@@ -69,7 +78,11 @@ for (const rel of files) {
|
||||
// the pattern in order to explain why the code below it avoids that shape. Guards
|
||||
// that read prose as code make the honest documentation of a near-miss unwritable.
|
||||
if (/^\s*(?:\/\/|\*|\/\*)/.test(line)) return;
|
||||
for (const [re, why] of REIMPLEMENTED) if (re.test(line)) fail(`${rel}:${i + 1} ${why}`);
|
||||
for (const [re, why, also] of REIMPLEMENTED) {
|
||||
if (!re.test(line)) continue;
|
||||
if (also && !also.test(line)) continue;
|
||||
fail(`${rel}:${i + 1} ${why}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+22
-10
@@ -98,7 +98,19 @@ export const SKILL_CATALOGUE = [
|
||||
|
||||
/* ---------- weapons (v0.5 §4.7) ---------- */
|
||||
export const WEAPONS = [
|
||||
{ key: "punch", name: "Punch / kick", cls: "melee", fam: "brawl", spec: "", dmg: "1d3", wound: "blunt", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 },
|
||||
/* `plans` — which body plans can make this attack.
|
||||
*
|
||||
* It is on the WEAPON for the same reason skillFamilyId is: the weapon is the one
|
||||
* thing that knows. The forge held its own table of which plan could do what, and a
|
||||
* guard checking the forge against that table would have been checking it against
|
||||
* itself — which is how a dropped filter passed a guard whose success line read
|
||||
* "armed with something its body can use". Anatomy cannot answer it either: a body
|
||||
* plan records that a creature has a head, not that the head has horns.
|
||||
*
|
||||
* Absent means unrestricted, which is right for the manufactured weapons: anything
|
||||
* that can hold a rifle can fire it, and that is a question about hands rather than
|
||||
* about the rifle. */
|
||||
{ key: "punch", name: "Punch / kick", cls: "melee", fam: "brawl", spec: "", dmg: "1d3", wound: "blunt", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 , plans: ["baseline", "vesh"] },
|
||||
{ key: "baton", name: "Collapsible baton", cls: "melee", fam: "melee_weapon", spec: "club", dmg: "1d6+2", wound: "blunt", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0.7, trad: "tradition_agency" },
|
||||
{ key: "dagger", name: "Utility knife", cls: "melee", fam: "melee_weapon", spec: "knife", dmg: "1d4+2", wound: "edged", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0.3, trad: "tradition_agency" },
|
||||
{ key: "prybar", name: "Entry bar", cls: "melee", fam: "melee_weapon", spec: "prybar", dmg: "1d10", wound: "blunt", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 3, trad: "tradition_agency" },
|
||||
@@ -108,7 +120,7 @@ export const WEAPONS = [
|
||||
// a kelpie, a church grim and an apple tree. Twenty-two creatures carry this and five
|
||||
// of them are Vesh; it is the generic heavy natural attack and now says so. The KEY is
|
||||
// unchanged, so every kit reference and every built pack still resolves.
|
||||
{ key: "grasp", name: "Grasping limb", cls: "melee", fam: "brawl", spec: "", dmg: "1d6+1", wound: "blunt", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 },
|
||||
{ key: "grasp", name: "Grasping limb", cls: "melee", fam: "brawl", spec: "", dmg: "1d6+1", wound: "blunt", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 , plans: ["baseline", "vesh", "cadence"] },
|
||||
|
||||
/* --- natural attacks --------------------------------------------------------- *
|
||||
* Added because the bestiary could not express what an animal does. Until now the
|
||||
@@ -124,14 +136,14 @@ export const WEAPONS = [
|
||||
* the same as `punch` and `grasp` — so they arrive with a skill every creature has
|
||||
* and need no new skill family. Damage is calibrated against the melee line already
|
||||
* in this table: dagger 1d4+2, baton 1d6+2, machete 1d8+1, prybar 1d10. */
|
||||
{ key: "bite", name: "Bite", cls: "melee", fam: "brawl", spec: "", dmg: "1d6", wound: "piercing", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 },
|
||||
{ key: "claw", name: "Claw", cls: "melee", fam: "brawl", spec: "", dmg: "1d6", wound: "edged", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 },
|
||||
{ key: "talon", name: "Talon", cls: "melee", fam: "brawl", spec: "", dmg: "1d6+1", wound: "piercing", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 },
|
||||
{ key: "beak", name: "Beak", cls: "melee", fam: "brawl", spec: "", dmg: "1d6+1", wound: "piercing", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 },
|
||||
{ key: "gore", name: "Horn / tusk", cls: "melee", fam: "brawl", spec: "", dmg: "1d8+1", wound: "piercing", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 },
|
||||
{ key: "hoof", name: "Hoof / kick", cls: "melee", fam: "brawl", spec: "", dmg: "1d6+2", wound: "blunt", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 },
|
||||
{ key: "trample", name: "Trample", cls: "melee", fam: "brawl", spec: "", dmg: "1d10", wound: "blunt", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 },
|
||||
{ key: "constrict", name: "Constriction", cls: "melee", fam: "brawl", spec: "", dmg: "1d6", wound: "blunt", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 },
|
||||
{ key: "bite", name: "Bite", cls: "melee", fam: "brawl", spec: "", dmg: "1d6", wound: "piercing", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 , plans: ["baseline", "quadruped", "winged"] },
|
||||
{ key: "claw", name: "Claw", cls: "melee", fam: "brawl", spec: "", dmg: "1d6", wound: "edged", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 , plans: ["baseline", "quadruped", "winged"] },
|
||||
{ key: "talon", name: "Talon", cls: "melee", fam: "brawl", spec: "", dmg: "1d6+1", wound: "piercing", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 , plans: ["winged"] },
|
||||
{ key: "beak", name: "Beak", cls: "melee", fam: "brawl", spec: "", dmg: "1d6+1", wound: "piercing", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 , plans: ["winged"] },
|
||||
{ key: "gore", name: "Horn / tusk", cls: "melee", fam: "brawl", spec: "", dmg: "1d8+1", wound: "piercing", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 , plans: ["quadruped"] },
|
||||
{ key: "hoof", name: "Hoof / kick", cls: "melee", fam: "brawl", spec: "", dmg: "1d6+2", wound: "blunt", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 , plans: ["quadruped"] },
|
||||
{ key: "trample", name: "Trample", cls: "melee", fam: "brawl", spec: "", dmg: "1d10", wound: "blunt", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 , plans: ["quadruped"] },
|
||||
{ key: "constrict", name: "Constriction", cls: "melee", fam: "brawl", spec: "", dmg: "1d6", wound: "blunt", mod: true, ranges: [0,0,0], fire: "", cap: 0, mass: 0 , plans: ["baseline", "quadruped", "vesh", "cadence"] },
|
||||
{ key: "shortbow", name: "Hunting bow", cls: "bow", fam: "missile_weapon",spec: "bow", dmg: "1d6+1", wound: "piercing", mod: false, ranges: [30,60,90], fire: "single", cap: 12, mass: 1 },
|
||||
{ key: "pistol_light", name: "Compact pistol", cls: "firearm", fam: "firearm", spec: "pistol", dmg: "1d6+2", wound: "piercing", mod: false, ranges: [10,30,90], fire: "rapid", cap: 8, mass: 0.7, trad: "tradition_agency" },
|
||||
{ key: "pistol_med", name: "Service pistol", cls: "firearm", fam: "firearm", spec: "pistol", dmg: "1d8+2", wound: "piercing", mod: false, ranges: [10,30,90], fire: "rapid", cap: 15, mass: 1.1, trad: "tradition_agency" },
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* The forge, at the command line.
|
||||
*
|
||||
* The derivation is in ../forge.mjs, at the root and under the same law as rules.mjs:
|
||||
* pure, no Foundry, no catalogues. This file is the part that cannot be pure — it holds
|
||||
* the catalogues, validates the result against creature-schema, and formats a spec as
|
||||
* source. Part 5's in-Foundry panel imports the same derivation, which is the only way
|
||||
* to guarantee rather than hope that the two agree.
|
||||
*
|
||||
* node tools/forge.mjs new --role brute --size large --tier 4
|
||||
* node tools/forge.mjs new --role flyer --body winged --seed 7 --name "The martlet"
|
||||
* node tools/forge.mjs explain --role lurker --size huge --tier 5
|
||||
* node tools/forge.mjs measure --role brute --size large --tier 4
|
||||
* node tools/forge.mjs list
|
||||
*
|
||||
* `new` prints the spec as source, formatted the way tools/bestiary-*.mjs are written,
|
||||
* so it can be pasted into one. It VALIDATES BEFORE PRINTING: a forge that emits
|
||||
* something check-creatures would reject has wasted the reader's time and taught them to
|
||||
* distrust the tool.
|
||||
*
|
||||
* `measure` runs it against the party check-lethality freezes, because "is this thing
|
||||
* too dangerous" is the question a GM actually has, and this repository answers questions
|
||||
* like that with a number rather than an adjective.
|
||||
*/
|
||||
import { forgeCreature, SIZE_BANDS, CREATURE_ROLES, FORGE_TIERS } from "../forge.mjs";
|
||||
import { validate } from "./creature-schema.mjs";
|
||||
import { WEAPONS } from "./content.mjs";
|
||||
import { ROSTER } from "./roster.mjs";
|
||||
import { measure, seedFor } from "./simulate.mjs";
|
||||
|
||||
const WEAPON_BY_KEY = new Map(WEAPONS.map(w => [w.key, w]));
|
||||
/* The natural attacks are the ones that declare which bodies can make them; a rifle
|
||||
declares nothing because holding a rifle is a question about hands, not about the
|
||||
rifle. That declaration is also what makes this list derivable rather than typed. */
|
||||
const NATURAL = WEAPONS.filter(w => Array.isArray(w.plans)).map(w => w.key);
|
||||
const catalogues = {
|
||||
hasWeapon: k => WEAPON_BY_KEY.has(k),
|
||||
plansFor: k => WEAPON_BY_KEY.get(k)?.plans ?? null,
|
||||
naturalAttacks: NATURAL
|
||||
};
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const cmd = argv[0] ?? "help";
|
||||
const opt = (name, dflt) => {
|
||||
const i = argv.indexOf(`--${name}`);
|
||||
return i === -1 || !argv[i + 1] || argv[i + 1].startsWith("--") ? dflt : argv[i + 1];
|
||||
};
|
||||
|
||||
function options() {
|
||||
return {
|
||||
key: opt("key", null), name: opt("name", null),
|
||||
species: opt("species", "baseline"), bodyPlan: opt("body", null),
|
||||
size: opt("size", "medium"), role: opt("role", "hunter"),
|
||||
tier: Number(opt("tier", 3)), seed: Number(opt("seed", 1))
|
||||
};
|
||||
}
|
||||
|
||||
/** A spec as source, in the shape the bestiary files are written in. */
|
||||
function asSource(spec) {
|
||||
const q = s => JSON.stringify(s);
|
||||
const ch = Object.entries(spec.ch).map(([k, v]) => `${k}: ${v}`).join(", ");
|
||||
const skills = spec.skills
|
||||
.map(s => `{fam:${q(s.fam)},spec:${q(s.spec)},val:${s.val}}`)
|
||||
.join(",\n ");
|
||||
return ` { key: ${q(spec.key)}, name: ${q(spec.name)}, role: ${q(spec.role)},\n`
|
||||
+ ` naturalArmour: ${spec.naturalArmour}, species: ${q(spec.species)}, bodyPlan: ${q(spec.bodyPlan)},\n`
|
||||
+ ` ch: { ${ch} },\n`
|
||||
+ ` skills: [${skills}],\n`
|
||||
+ ` weapons: [${spec.weapons.map(q).join(", ")}],\n`
|
||||
+ ` tactics: ${q(spec.tactics)} },`;
|
||||
}
|
||||
|
||||
function forgeOrDie() {
|
||||
const { spec, trace, problems } = forgeCreature(options(), catalogues);
|
||||
if (problems.length) {
|
||||
console.error("forge: cannot derive that creature");
|
||||
for (const p of problems) console.error(" " + p);
|
||||
process.exit(1);
|
||||
}
|
||||
/* Validated before it is shown, never after. */
|
||||
const bad = validate(spec);
|
||||
if (bad.length) {
|
||||
console.error(`forge: derived a spec that creature-schema rejects — this is a bug in `
|
||||
+ `forge.mjs, not in the request`);
|
||||
for (const p of bad) console.error(" " + (p.message ?? JSON.stringify(p)));
|
||||
process.exit(1);
|
||||
}
|
||||
return { spec, trace };
|
||||
}
|
||||
|
||||
if (cmd === "list") {
|
||||
console.log("roles");
|
||||
for (const [k, r] of Object.entries(CREATURE_ROLES)) console.log(` ${k.padEnd(9)} ${r.blurb}`);
|
||||
console.log("\nsizes");
|
||||
for (const [k, b] of Object.entries(SIZE_BANDS)) console.log(` ${k.padEnd(9)} SIZ ${String(b.siz.join("–")).padEnd(7)} ${b.note}`);
|
||||
console.log("\ntiers");
|
||||
for (const [k, t] of Object.entries(FORGE_TIERS)) console.log(` ${k} ${t.label.padEnd(10)} skills ${t.skill.join("–")}, hide ${t.armourBase}`);
|
||||
} else if (cmd === "new") {
|
||||
const { spec } = forgeOrDie();
|
||||
console.log(asSource(spec));
|
||||
} else if (cmd === "explain") {
|
||||
const { spec, trace } = forgeOrDie();
|
||||
console.log(`${spec.name} (${spec.key})`);
|
||||
console.log(`${spec.role}\n`);
|
||||
for (const t of trace) {
|
||||
console.log(` ${String(t.field).padEnd(22)} ${String(t.value).padEnd(26)} ${t.because}`);
|
||||
}
|
||||
} else if (cmd === "measure") {
|
||||
const { spec } = forgeOrDie();
|
||||
const PARTY = ["pc_holloway", "pc_okonkwo", "pc_nkemdirim", "pc_ferriby"];
|
||||
const party = PARTY.map(k => ROSTER.find(r => r.key === k)).filter(Boolean);
|
||||
const runs = Number(opt("runs", 1000));
|
||||
const r = measure(party, [spec], { runs, seed: seedFor(11, spec.key) });
|
||||
console.log(`${spec.name} — ${runs} runs against ${PARTY.map(k => k.slice(3)).join(", ")}`);
|
||||
console.log(` party wiped ${(r.wipeRate * 100).toFixed(1)}%`);
|
||||
console.log(` agents killed ${r.deathsPerRun.toFixed(2)} per fight`);
|
||||
console.log(` creature killed ${(r.winRate * 100).toFixed(1)}% of fights`);
|
||||
} else {
|
||||
console.log(`forge — derive a creature, and say which rule produced each number
|
||||
|
||||
node tools/forge.mjs new --role brute --size large --tier 4
|
||||
node tools/forge.mjs explain --role lurker --size huge --tier 5
|
||||
node tools/forge.mjs measure --role flyer --body winged --tier 3
|
||||
node tools/forge.mjs list
|
||||
|
||||
--role --size --tier 1-5 --species --body --seed --name --key --runs`);
|
||||
}
|
||||
Reference in New Issue
Block a user