diff --git a/README.md b/README.md index 01c245b..ffa540a 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ The build runs all seven guards before it writes anything: ``` -check-rules: OK — 99 rules, 31 files scanned, 3 aliases + 46 constant sets checked, 321 formulas verified +check-rules: OK — 103 rules, 31 files scanned, 3 aliases + 46 constant sets checked, 346 formulas verified check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct check-lang: OK — en.json, 995 keys, no leaf/branch collisions check-behaviour: OK — 67 behavioural tests diff --git a/ringbrp.mjs b/ringbrp.mjs index 644c5e5..6f64e2b 100644 --- a/ringbrp.mjs +++ b/ringbrp.mjs @@ -24,6 +24,7 @@ import { locationMaxHp, reflexRating, awarenessRating, encumbranceFrom, reactionBaseFrom, reactionLoadFrom, reactionPrintedFrom, woundEffectsFor, BLEED, bleedStoppedBy, bleedRateFor, locationEffectsFor, + locationFor, resolveLocationHit, locationsFor, woundPenaltyFrom, encumbrancePenaltyFor, ENCUMBERED_CATEGORIES, freeCarryFor, defencePenaltyFor, defenceTypeAllowed, DEFENCE_STEP, defenceOutcomeFor, levelRank, LEVEL_LADDER, skillBaseFrom, @@ -57,7 +58,8 @@ import { DEVELOPMENT, developmentMarksOn, developmentImproves, developmentGainFor } from "./rules.mjs"; import { LOCATION_TABLES, BODY_ART } from "./anatomy.mjs"; -export { hitPointsFor, majorWoundFor, damageModifierFor, locationMaxHp, reflexRating, awarenessRating }; +export { hitPointsFor, majorWoundFor, damageModifierFor, locationMaxHp, reflexRating, awarenessRating, + locationFor, resolveLocationHit, locationsFor }; function rbRenderTemplate(path, data) { const fn = foundry?.applications?.handlebars?.renderTemplate ?? globalThis.renderTemplate; @@ -426,34 +428,8 @@ export function resolveHit({ level, rolled, armour, majorWoundThreshold }) { }; } -/** Spec §3.6. Which location a 1D20 finds, for this species and attack type. */ -export function locationFor(roll, speciesId = "baseline", mode = "ranged") { - const table = RINGBRP.locationTables[speciesId] ?? RINGBRP.locationTables.baseline; - const key = mode === "melee" ? "melee" : "ranged"; - return table.locations.find(l => roll >= l[key][0] && roll <= l[key][1]) ?? table.locations[0]; -} - - -/** - * Spec §3.6. Resolve a hit against a location. - * A blow that meets the location's maximum disables it; twice that destroys it. - * General hit points still take the damage, so the two systems agree. - */ -export function resolveLocationHit({ damage, locationMax, locationTaken = 0, majorWoundThreshold }) { - const d = Number(damage) || 0; - const after = locationTaken + d; - return { - damage: d, - locationTaken: after, - disabled: after >= locationMax, - // Cumulative, like `disabled` and like the actor's own derived state. This - // used to test THIS hit's damage alone, so a limb could never be destroyed by - // repeated wounds and the resolver disagreed with the sheet about the same - // limb: derived data said destroyed, the resolver said not. - destroyed: after >= locationMax * 2, - majorWound: d >= (Number(majorWoundThreshold) || Infinity) - }; -} +/* locationFor and resolveLocationHit moved to rules.mjs so the lethality harness can + import them — node cannot load this file. Re-exported below with the rest. */ /* -------------------------------------------- */ /* Documents */ @@ -1379,15 +1355,10 @@ export const coherenceBand = coherenceBandFrom; * numbers add on top of it. Ranged and melee read the manipulation figure because * both are done with the hands, which is how the location wounds already worked. */ +/* The mapping itself lives in rules.mjs as woundPenaltyFrom, so the lethality harness + applies the same arithmetic to a plain object. This unwraps the actor, nothing more. */ export function woundPenaltyFor(actor, categoryId) { - const w = actor?.system?.wounded; - if (!w) return 0; - let pen = Number(w.all) || 0; - if (["manip", "melee", "ranged"].includes(categoryId)) pen += Number(w.manipulation) || 0; - if (categoryId === "phys") pen += Number(w.physical) || 0; - if (categoryId === "percep") pen += Number(w.perception) || 0; - if (categoryId === "comm") pen += Number(w.communication) || 0; - return pen; + return woundPenaltyFrom(actor?.system?.wounded, categoryId); } export function coherencePenaltyFor(value, categoryId) { diff --git a/rules.mjs b/rules.mjs index 8bde499..4dcc309 100644 --- a/rules.mjs +++ b/rules.mjs @@ -16,6 +16,11 @@ * this file may import from the runtime or touch Foundry globals. */ +// The species anatomies. anatomy.mjs imports nothing and touches nothing, so this stays +// inside the rule above: it is data the hit-location rules are defined over, and both +// the engine and the node-side tools read that one copy. +import { LOCATION_TABLES } from "./anatomy.mjs"; + export const ceil = Math.ceil; /** Hit points: half of CON + SIZ, standard BRP. */ @@ -501,6 +506,71 @@ export function locationEffectsFor(locations = []) { * rucksack does not make you worse at Knowledge, and a rule that says otherwise is * a rule nobody will use. */ +/* ---------------------------------------------------------------- hit locations */ + +/** + * Spec §3.6. Which location a 1D20 finds, for this species and attack type. + * + * Moved out of ringbrp.mjs so node can reach it, for exactly the reason applyDifficulty, + * resolveBands and gradeRoll were moved: the lethality harness could not model hit + * locations while the only copy lived in the engine, and an unlocated Cadence is not a + * Cadence — being whittled down body by body is the entire species. The tables come from + * anatomy.mjs, which is the same one the engine reads. + */ +export function locationFor(roll, speciesId = "baseline", mode = "ranged") { + const table = LOCATION_TABLES[speciesId] ?? LOCATION_TABLES.baseline; + const key = mode === "melee" ? "melee" : "ranged"; + return table.locations.find(l => roll >= l[key][0] && roll <= l[key][1]) ?? table.locations[0]; +} + +/** + * Spec §3.6. Resolve a hit against a location. + * A blow that meets the location's maximum disables it; twice that destroys it. + * General hit points still take the damage, so the two systems agree. + */ +export function resolveLocationHit({ damage, locationMax, locationTaken = 0, majorWoundThreshold }) { + const d = Number(damage) || 0; + const after = locationTaken + d; + return { + damage: d, + locationTaken: after, + disabled: after >= locationMax, + // Cumulative, like `disabled` and like the actor's own derived state. This used to + // test THIS hit's damage alone, so a limb could never be destroyed by repeated + // wounds and the resolver disagreed with the sheet about the same limb. + destroyed: after >= locationMax * 2, + majorWound: d >= (Number(majorWoundThreshold) || Infinity) + }; +} + +/** Every location a species has — what a combatant has to keep a tally for. */ +export function locationsFor(speciesId = "baseline") { + return (LOCATION_TABLES[speciesId] ?? LOCATION_TABLES.baseline).locations; +} + +/** + * How much a battered body costs a roll of one category. + * + * The mapping — `all` against everything, manipulation against anything you need hands + * for, physical against phys, and so on — lived only inside ringbrp.mjs's + * woundPenaltyFor, which reads a Foundry actor. The harness needs the same arithmetic + * over a plain object, and writing the mapping a second time is the one thing this + * repository does not permit. woundPenaltyFor now unwraps the actor and calls this. + * + * Note that melee and ranged both take the MANIPULATION penalty: a lost arm is what + * stops you fighting, whether the weapon is a knife or a rifle. Dodge is `phys`, so + * losing a leg is what stops you getting out of the way. + */ +export function woundPenaltyFrom(wounded, categoryId) { + if (!wounded) return 0; + let pen = Number(wounded.all) || 0; + if (["manip", "melee", "ranged"].includes(categoryId)) pen += Number(wounded.manipulation) || 0; + if (categoryId === "phys") pen += Number(wounded.physical) || 0; + if (categoryId === "percep") pen += Number(wounded.perception) || 0; + if (categoryId === "comm") pen += Number(wounded.communication) || 0; + return pen; +} + export const ENCUMBERED_CATEGORIES = ["phys", "melee", "ranged", "manip"]; export function encumbrancePenaltyFor(skillPenalty, categoryId) { diff --git a/tools/check-rules.mjs b/tools/check-rules.mjs index 1a9e765..be9a5c1 100644 --- a/tools/check-rules.mjs +++ b/tools/check-rules.mjs @@ -479,6 +479,40 @@ const checks = [ ["defenceOutcomeFor(special, special)", RULES.defenceOutcomeFor("special","special").turnedAside, true], ["defenceOutcomeFor(success, success)", RULES.defenceOutcomeFor("success","success").turnedAside, true], ["defenceOutcomeFor(success, fumble)", RULES.defenceOutcomeFor("success","fumble").landsAt, "success"], + // Hit locations. Moved out of ringbrp.mjs so the harness can locate damage; these are + // the spot-checks they never had while the engine held the only copy. A species that + // has no head must not be able to return one, which is the whole point of the tables. + ["locationFor(20, baseline, melee) is the head", RULES.locationFor(20, "baseline", "melee").id, "head"], + ["locationFor(20, baseline, ranged) is the head", RULES.locationFor(20, "baseline", "ranged").id, "head"], + ["locationFor(1, baseline, melee) is a leg", RULES.locationFor(1, "baseline", "melee").kind, "leg"], + // melee finds limbs, ranged finds centre of mass — the same roll, two answers + ["locationFor(11, baseline, melee) is abdomen", RULES.locationFor(11, "baseline", "melee").id, "abdomen"], + ["locationFor(11, baseline, ranged) is chest", RULES.locationFor(11, "baseline", "ranged").id, "chest"], + // a vesh has no head at any roll; its vital is the ridge + ["locationFor(20, vesh, melee) is the ridge", RULES.locationFor(20, "vesh", "melee").id, "ridge"], + ["locationFor(20, vesh, ranged) is the ridge", RULES.locationFor(20, "vesh", "ranged").id, "ridge"], + // a cadence is bodies all the way down — no head, no vital, whatever you roll + ["locationFor(20, cadence, melee) is a unit", RULES.locationFor(20, "cadence", "melee").kind, "unit"], + ["locationFor(1, cadence, ranged) is a unit", RULES.locationFor(1, "cadence", "ranged").kind, "unit"], + ["locationsFor(baseline) has 7", RULES.locationsFor("baseline").length, 7], + ["locationsFor(vesh) has 6", RULES.locationsFor("vesh").length, 6], + ["locationsFor(cadence) has 5", RULES.locationsFor("cadence").length, 5], + ["locationsFor(unknown) falls back to baseline", RULES.locationsFor("nonesuch").length, 7], + // meeting the maximum disables; twice it destroys; and both are CUMULATIVE + ["resolveLocationHit(4 of 5) holds", RULES.resolveLocationHit({damage:4,locationMax:5}).disabled, false], + ["resolveLocationHit(5 of 5) disables", RULES.resolveLocationHit({damage:5,locationMax:5}).disabled, true], + ["resolveLocationHit(10 of 5) destroys", RULES.resolveLocationHit({damage:10,locationMax:5}).destroyed, true], + ["resolveLocationHit(3 onto 2 of 5) disables", RULES.resolveLocationHit({damage:3,locationMax:5,locationTaken:2}).disabled, true], + ["resolveLocationHit(3 onto 7 of 5) destroys", RULES.resolveLocationHit({damage:3,locationMax:5,locationTaken:7}).destroyed, true], + // the wound-penalty mapping: all hits everything, manipulation stops you fighting, + // physical stops you dodging + ["woundPenaltyFrom(all -10, melee)", RULES.woundPenaltyFrom({all:-10}, "melee"), -10], + ["woundPenaltyFrom(manip -30, melee)", RULES.woundPenaltyFrom({manipulation:-30}, "melee"), -30], + ["woundPenaltyFrom(manip -30, ranged)", RULES.woundPenaltyFrom({manipulation:-30}, "ranged"), -30], + ["woundPenaltyFrom(manip -30, phys)", RULES.woundPenaltyFrom({manipulation:-30}, "phys"), 0], + ["woundPenaltyFrom(phys -30, phys)", RULES.woundPenaltyFrom({physical:-30}, "phys"), -30], + ["woundPenaltyFrom(percep -30, percep)", RULES.woundPenaltyFrom({perception:-30}, "percep"), -30], + ["woundPenaltyFrom(nothing)", RULES.woundPenaltyFrom(null, "melee"), 0], // the Quiet Arts cost Coherence, and Unmake costs more than the rest ["powerCostFor('thread')", RULES.powerCostFor("thread"), 1], ["powerCostFor('thread', pushed)", RULES.powerCostFor("thread",{pushed:true}), 2], diff --git a/tools/simulate.mjs b/tools/simulate.mjs index 3561ee9..b98ed2a 100644 --- a/tools/simulate.mjs +++ b/tools/simulate.mjs @@ -26,16 +26,30 @@ * WHAT IT MODELS, AND WHAT IT DOES NOT. It runs Reaction order re-rolled every round, * attack banding with the 1% floor and the 96-99 rule, one defence per incoming blow at * the stacking defence penalty, graded defences, damage by band with armour ignored on a - * critical and halved on a special, major wounds, and the dying clock. It does NOT model - * hit locations, bleeding, panic, Coherence, cover, range bands, fire modes or burst. - * Those all exist in rules.mjs and all matter; they are not here yet, and every one of - * them makes a fight WORSE for whoever is losing. Read the output as a floor on how bad - * a creature is, never as a ceiling. + * critical and halved on a special, major wounds, and the dying clock. + * + * It also, now, models HIT LOCATIONS — 1d20 against the defender's own species table, + * melee finding limbs and shooting finding centre of mass, each location carrying its + * own share of the pool, disabled at its maximum and destroyed at twice it. The + * consequences apply: a destroyed head is unconscious immediately, a ruined arm costs + * you every attack, a ruined leg costs you every dodge, and a Cadence losing a body + * costs the whole creature 10% of everything it does. + * + * That last one is why this was worth doing. A Cadence has no vital and no head, so it + * cannot be dropped by a lucky shot and instead degrades toward useless; measured + * without locations it was simply a person with an odd portrait, and the number the + * harness printed was about a creature nobody was playing. + * + * It still does NOT model bleeding, panic, Coherence, cover, range bands, fire modes or + * burst. Those all exist in rules.mjs, they all matter, and every one of them makes a + * fight WORSE for whoever is losing. Read the output as a floor, never as a ceiling. */ import { hitPointsFor, majorWoundFor, damageModifierFor, reactionBaseFrom, resolveBands, gradeRoll, defenceOutcomeFor, defencePenaltyFor, - conditionFor, dyingLimitFor, REACTION + conditionFor, dyingLimitFor, REACTION, + locationFor, resolveLocationHit, locationsFor, locationMaxHp, + locationEffectsFor, woundPenaltyFrom } from "../rules.mjs"; import { WEAPONS, ARMOURS, NPCS, PREGENS } from "./content.mjs"; import { ROSTER } from "./roster.mjs"; @@ -145,8 +159,19 @@ export function buildCombatant(rawSpec, { side = "?" } = {}) { const worn = (spec.armour ?? []).reduce((t, k) => t + (ARMOUR.get(k)?.points ?? 0), 0); + /* Every location this species has, each with its own share of the hit points. The + share comes from the creature's OWN table, so a cadence gets five bodies at 0.28 + apiece and no vital at all, and a vesh gets a ridge instead of a head. */ + const species = spec.species ?? "baseline"; + const locations = locationsFor(species).map(l => ({ + id: l.id, kind: l.kind, + max: locationMaxHp(hp, l.frac), + taken: 0, disabled: false, destroyed: false + })); + return { side, key: spec.key, name: spec.name, + species, hpMax: hp, hp, majorWound: majorWoundFor(hp), majorWounds: 0, @@ -155,14 +180,20 @@ export function buildCombatant(rawSpec, { side = "?" } = {}) { armour: worn + (Number(spec.naturalArmour) || 0), dodge: skills.get("dodge:") ?? 0, arms, + locations, + wounded: locationEffectsFor(locations), defencesThisRound: 0, dyingRounds: 0, startedUp: true }; } +/* A destroyed head is "unconscious immediately", which conditionFor has always known how + to read and this harness never told it — so a headshot used to leave the target + standing and swinging. */ const conditionOf = c => conditionFor({ - hp: c.hp, dyingRounds: c.dyingRounds, majorWound: c.majorWounds + hp: c.hp, dyingRounds: c.dyingRounds, majorWound: c.majorWounds, + unconscious: !!c.wounded?.unconscious }); const isFighting = c => conditionOf(c) === "up"; @@ -172,15 +203,24 @@ function attack(rng, attacker, defender) { const arm = attacker.arms[0]; if (!arm) return; - const level = gradeRoll(d100(rng), resolveBands(arm.rating)); + // Where a blow lands depends on how it was thrown: melee finds limbs, because they are + // between you and the target, and shooting finds centre of mass. + const mode = arm.w.cls === "melee" ? "melee" : "ranged"; + + // A wounded attacker is a worse attacker. Melee and ranged both take the MANIPULATION + // penalty — losing an arm is what stops you fighting, whatever is in your hand — and + // the mapping comes from rules.mjs rather than being restated here. + const atkPen = woundPenaltyFrom(attacker.wounded, mode); + const level = gradeRoll(d100(rng), resolveBands(arm.rating, { situational: atkPen })); if (level === "fumble" || level === "failure") return; // One defence per incoming blow, at the stacking penalty for each already spent this - // round. A defender with nothing left still gets the roll — at a rating the penalty - // has usually driven to the 1% floor, which resolveBands enforces rather than us. + // round, PLUS whatever a ruined pair of legs costs — dodge is a `phys` skill, so a + // disabled leg is exactly what stops you getting out of the way. let landing = level; if (defender.dodge > 0) { - const penalty = defencePenaltyFor(defender.defencesThisRound); + const penalty = defencePenaltyFor(defender.defencesThisRound) + + woundPenaltyFrom(defender.wounded, "phys"); const dLevel = gradeRoll(d100(rng), resolveBands(defender.dodge, { situational: penalty })); defender.defencesThisRound++; const outcome = defenceOutcomeFor(level, dLevel); @@ -201,8 +241,34 @@ function attack(rng, attacker, defender) { const taken = Math.max(0, dmg - armour); if (taken <= 0) return; - if (taken >= defender.majorWound) defender.majorWounds++; + + /* Locate it. 1d20 against the defender's own species table, then the damage goes to + that location AND to general hit points — the two systems agree rather than + competing, which is what resolveLocationHit is for. + + This is where a Cadence stops being a person with a funny picture. Its five bodies + each carry 0.28 of the pool, it has no vital and no head, so it cannot be dropped by + one good shot; instead every body that goes costs the whole creature -10% to + everything, and it degrades toward useless rather than dying. A Vesh, conversely, + has a ridge for a vital that is a LONG target rather than a small one. None of that + could be measured before, because damage was never located. */ + const loc = locationFor(d(rng, 20), defender.species, mode); + const slot = defender.locations.find(l => l.id === loc.id) ?? defender.locations[0]; + const res = resolveLocationHit({ + damage: taken, + locationMax: slot.max, + locationTaken: slot.taken, + majorWoundThreshold: defender.majorWound + }); + slot.taken = res.locationTaken; + slot.disabled = res.disabled; + slot.destroyed = res.destroyed; + if (res.majorWound) defender.majorWounds++; + defender.hp -= taken; + // Recomputed from the whole body, not accumulated, because the effects fold together + // rather than stacking per hit — two disabled legs are one condition, not two. + defender.wounded = locationEffectsFor(defender.locations); } /** One fight to a conclusion. Returns which side stood, and how long it took. */