diff --git a/README.md b/README.md index 37bd4f3..7638d47 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-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: ``` -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 diff --git a/forge.mjs b/forge.mjs new file mode 100644 index 0000000..5682279 --- /dev/null +++ b/forge.mjs @@ -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 }; +} diff --git a/package.json b/package.json index c484945..095dd1d 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-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" diff --git a/tools/check-forge.mjs b/tools/check-forge.mjs new file mode 100644 index 0000000..ee4c546 --- /dev/null +++ b/tools/check-forge.mjs @@ -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`); diff --git a/tools/check-rules.mjs b/tools/check-rules.mjs index 0c89e74..da8282b 100644 --- a/tools/check-rules.mjs +++ b/tools/check-rules.mjs @@ -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}`); + } }); } } diff --git a/tools/content.mjs b/tools/content.mjs index 2ddd4b9..d7216ea 100644 --- a/tools/content.mjs +++ b/tools/content.mjs @@ -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" }, diff --git a/tools/forge.mjs b/tools/forge.mjs new file mode 100644 index 0000000..2d42cca --- /dev/null +++ b/tools/forge.mjs @@ -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`); +}