diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index 4ad67bf..0ba986e 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -6160,3 +6160,52 @@ value and exactly why an assumption inside one is the least likely thing in the questioned. Both new guards are therefore built to refuse rather than to record: one will not write a censored measurement, the other will not let an unstable figure be cited. Neither can be satisfied by re-recording. + +## R-273 — the opposed roll the content had been calling for since before the rule existed + +The hollow man's THE FILE IS YOURS NOW says "opposed POWx5". CLEAN GROUND calls for one +twice. `slice-text.mjs` uses the word three times. `rules.mjs` — the single authority — +defined no opposed roll at all, and a scenario was carrying a local ruling that said in +its own text that it was a ruling and not a rule. That is the honest way to hold a gap +open and it is not a way to close one. + +**The rule is the scenario's ruling, generalised, not a new invention.** Both sides roll, +the better band wins, and it uses only the ladder the game already has. Two changes in +making it general: + +*Ties.* The scenario said "equal bands favour the agent", which `rules.mjs` cannot say +because it does not know who is a player. It now says **ties go to whoever is being acted +upon** — which `defenceOutcomeFor` has always said, since a defence turns aside a blow of +its own quality or better. The scenario session checked every opposed beat in its document +rather than assuming there were two: in both, the antagonist is the aggressor, so the two +formulations give identical answers everywhere the game actually asks. Its version was +parochial and read correctly only because no agent ever initiates one. + +*Unsettled is not a win.* If neither side reaches a success the contest is unsettled — +`{ winner: null, settled: false }` — rather than a win for the resister. The difference is +visible at the table, and the scenario needed both halves: the hollow man's swap is a +repeatable attempt within a scene, and the Act Four countdown row fires once and is marked +permanent. The rule carries the distinction and deliberately does not say which applies. + +**Two things the scenario session required, both of which changed the shape.** It asked +for both sides' levels in the return, so a scenario can price a fumbled swap without this +file deciding what a fumble costs; and it asked for per-side difficulty, because its Act +Four beat is *Ashcroft resists at Difficult* — 85 to 42 — and a rule taking two flat +ratings would have forced `applyDifficulty` back into its prose, which is the arrangement +check-rules exists to forbid. So there are two exports: `opposedOutcomeFor` compares two +graded levels, and `opposedContestFor` runs the whole thing from ratings and rolls through +`resolveBands` and `gradeRoll`. Neither redefines anything. + +**Spot-checked against a real beat rather than an invented one.** The Act Four contest is +in check-rules: POWx5 55 against 85 at Difficult, which resolves to 42, and the tie goes to +Ashcroft. 112 rules, 413 formulas. + +**And it reaches a page, because a rule nothing reaches is this project's oldest defect.** +Page 1 of the player rules now has "When two people want opposite things", and it states +the rule by ASKING it — the tie-break sentence and the margin example are computed from +`opposedOutcomeFor` at build time, so the book cannot drift from the engine. That is the +arrangement the rest of that document already uses and the reason it has never been wrong. + +`docs/scenarios/` is untouched by me. The local ruling is the scenario session's to delete, +and removing the line that says the game has no opposed roll would have been the obvious +courtesy and exactly the wrong one. diff --git a/ringbrp.mjs b/ringbrp.mjs index bc24a42..92e7205 100644 --- a/ringbrp.mjs +++ b/ringbrp.mjs @@ -29,7 +29,7 @@ import { locationDamageReplacement, armourAgainst, damageAfterArmour, encumbrancePenaltyFor, ENCUMBERED_CATEGORIES, freeCarryFor, defencePenaltyFor, defenceTypeAllowed, DEFENCE_STEP, - defenceOutcomeFor, levelRank, LEVEL_LADDER, skillBaseFrom, + defenceOutcomeFor, opposedOutcomeFor, opposedContestFor, levelRank, LEVEL_LADDER, skillBaseFrom, applyDifficulty, resolveBands, gradeRoll, DYING, dyingLimitFor, conditionFor, lastEntryFor, LAST_ENTRY, destructionOutcomeFor, RETURN_CLOCK, returnPenaltyFor, returnIsDifficult, extractionCostFor, @@ -6311,7 +6311,7 @@ Hooks.once("init", () => { isAcross, setAcross, useTransposed, debrief, activeCaseFile, returnCrossing, caseMembers, earnedDiscoveries, deviceFunctionStatus, earnDiscovery, useDeviceFunction, pickContactActor, resolveContact, newEncounter, claimRewards, activeRewardEffects, - defenceOutcomeFor, levelRank, recordBlow, standingBlow, amendBlow, + defenceOutcomeFor, opposedOutcomeFor, opposedContestFor, levelRank, recordBlow, standingBlow, amendBlow, addDossierItem, useAuthority, widenScope, rehabilitate, tickDying, died, stabilise, reviveIfHealed, conditionFor, dyingLimitFor, fall, asphyxiate, burn, detonate, burstAttack, twoWeaponAttack, diff --git a/rules.mjs b/rules.mjs index e6fb555..1883d53 100644 --- a/rules.mjs +++ b/rules.mjs @@ -1927,6 +1927,79 @@ export function levelRank(level) { return i < 0 ? 1 : i; } +/** + * OPPOSED ROLLS. + * + * Two people want incompatible things and both are competent. The content has called + * for this since long before the rule existed: the hollow man's THE FILE IS YOURS NOW + * says "opposed POWx5", CLEAN GROUND calls for it twice, and `rules.mjs` — the single + * authority — answered none of them. A scenario carried a local ruling in the meantime + * and said in its own text that it was a ruling and not a rule, which is the honest way + * to hold a gap open but is not a way to close one. + * + * BOTH SIDES ROLL AGAINST THEIR OWN RATING AND THE BETTER BAND WINS. No new resolution: + * `resolveBands` and `gradeRoll` produce the levels, `levelRank` orders them, and a + * critical beats a special beats a success exactly as it does everywhere else. + * + * TIES GO TO WHOEVER IS BEING ACTED UPON. The game already says this in the one place + * it had to: `defenceOutcomeFor` turns aside a blow of the defence's own quality or + * better, so two successes favour the defender. An opposed roll is the general case of + * the same contest and answers ties the same way. It also gives the table the answer it + * expects, since the aggressor is nearly always the thing across the table. + * + * NEITHER SUCCEEDING IS NOT A WIN FOR ANYBODY. If no side reaches a success the contest + * is unsettled: nothing happens and it may be attempted again. That is a different + * outcome from the resisting side winning, and the difference is visible at the table — + * one permits another attempt next round, the other closes the question — so it is in + * the return value rather than folded into the winner. + * + * A FUMBLE IS NOT HANDLED HERE. On the ladder it sits below failure, so an aggressor who + * fumbles against a defender who merely fails leaves the contest unsettled, which is the + * same outcome the defender would have got by winning it. What a fumble COSTS is the + * caller's business — the fumble tables, Panic, a dropped weapon — and folding those in + * here would be this file deciding something it does not know. + * + * margin is how many bands separated them, for anything that scales with the degree of + * the win. It is 0 on a tie and 0 when nothing was settled. + */ +export function opposedOutcomeFor(activeLevel, resistingLevel) { + const success = levelRank("success"); + const a = levelRank(activeLevel); + const r = levelRank(resistingLevel); + // Both levels travel with the verdict. A caller that wants to hang something on HOW a + // side lost — a fumbled swap costing the thing that tried it — needs the bands, and + // deciding what a fumble costs is the caller's business rather than this file's. + const both = { activeLevel: LEVEL_LADDER[a], resistingLevel: LEVEL_LADDER[r] }; + if (a < success && r < success) return { winner: null, settled: false, margin: 0, ...both }; + return a > r + ? { winner: "active", settled: true, margin: a - r, ...both } + : { winner: "resisting", settled: true, margin: r - a, ...both }; +} + +/** + * The whole contest, from two ratings and two rolls. + * + * `opposedOutcomeFor` compares results that have already been graded. This runs the + * thing end to end, because each side may be at its own difficulty and a caller that + * had to reach for `applyDifficulty` itself would be holding a piece of the formula + * outside this file — which is the arrangement check-rules exists to forbid, and the + * reason a scenario could not express "he resists at Difficult" without doing the + * arithmetic in its own prose. + * + * Each side is `{ rating, roll, difficulty, situational, printedBase }`, exactly the + * options `resolveBands` already takes. Returns the verdict, both levels, and both + * sides' full bands so a sheet or a card can show what each of them needed. + */ +export function opposedContestFor(active = {}, resisting = {}) { + const side = s => { + const bands = resolveBands(s.rating, s); + return { bands, level: gradeRoll(Number(s.roll) || 0, bands), roll: Number(s.roll) || 0 }; + }; + const a = side(active); + const r = side(resisting); + return { ...opposedOutcomeFor(a.level, r.level), active: a, resisting: r }; +} + /** * What a defence actually does to the blow it answers. * diff --git a/tools/check-rules.mjs b/tools/check-rules.mjs index 479aa7c..ede5ba3 100644 --- a/tools/check-rules.mjs +++ b/tools/check-rules.mjs @@ -506,6 +506,31 @@ 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"], + // Opposed rolls. The better band wins; ties go to whoever is being acted upon, which is + // the same answer defenceOutcomeFor gives a defender; and if nobody reached a success + // the contest is unsettled rather than won, so it can be tried again. + ["opposedOutcomeFor(crit, success).winner", RULES.opposedOutcomeFor("critical","success").winner, "active"], + ["opposedOutcomeFor(crit, success).margin", RULES.opposedOutcomeFor("critical","success").margin, 2], + ["opposedOutcomeFor(success, crit)", RULES.opposedOutcomeFor("success","critical").winner,"resisting"], + ["opposedOutcomeFor(success, success)", RULES.opposedOutcomeFor("success","success").winner, "resisting"], + ["opposedOutcomeFor(crit, crit)", RULES.opposedOutcomeFor("critical","critical").winner,"resisting"], + ["opposedOutcomeFor(success, success).margin", RULES.opposedOutcomeFor("success","success").margin, 0], + ["opposedOutcomeFor(special, success)", RULES.opposedOutcomeFor("special","success").winner, "active"], + ["opposedOutcomeFor(success, failure)", RULES.opposedOutcomeFor("success","failure").winner, "active"], + ["opposedOutcomeFor(failure, failure).settled", RULES.opposedOutcomeFor("failure","failure").settled, false], + ["opposedOutcomeFor(failure, failure).winner", RULES.opposedOutcomeFor("failure","failure").winner, null], + ["opposedOutcomeFor(fumble, failure).settled", RULES.opposedOutcomeFor("fumble","failure").settled, false], + ["opposedOutcomeFor(success, fumble)", RULES.opposedOutcomeFor("success","fumble").winner, "active"], + // Both sides' levels travel with the verdict, so a caller can price a fumbled attempt + // without this file deciding what fumbling costs. + ["opposedOutcomeFor(fumble, failure).activeLevel", RULES.opposedOutcomeFor("fumble","failure").activeLevel, "fumble"], + ["opposedOutcomeFor(fumble, failure).resistingLevel", RULES.opposedOutcomeFor("fumble","failure").resistingLevel, "failure"], + // The whole contest, each side at its own difficulty. CLEAN GROUND's Act Four beat: + // the understudy at POWx5 55 against Ashcroft's 85 resisting at Difficult, which is 42. + ["opposedContestFor(...).resisting.bands.effective", RULES.opposedContestFor({rating:55,roll:12},{rating:85,roll:40,difficulty:"difficult"}).resisting.bands.effective, 42], + ["opposedContestFor(...).winner", RULES.opposedContestFor({rating:55,roll:12},{rating:85,roll:40,difficulty:"difficult"}).winner, "resisting"], + ["opposedContestFor(aggressor specials)", RULES.opposedContestFor({rating:55,roll:8},{rating:85,roll:40,difficulty:"difficult"}).winner, "active"], + ["opposedContestFor(both miss).settled", RULES.opposedContestFor({rating:55,roll:90},{rating:85,roll:90,difficulty:"difficult"}).settled, false], // 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. diff --git a/tools/rules-text.mjs b/tools/rules-text.mjs index 7793c21..09d4a3e 100644 --- a/tools/rules-text.mjs +++ b/tools/rules-text.mjs @@ -30,7 +30,7 @@ import { REACTION, BLEED, CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom, MAJOR_WOUND_TABLE, COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY, PANIC_BANDS, PANIC_TRIGGERS, PANIC_RELIEF, PANIC_MAX, PANIC_AFTER_BREAK, PANIC_BREAK, - DEVELOPMENT + DEVELOPMENT, opposedOutcomeFor } from "../rules.mjs"; // Postings, trades and induction are plain data and are imported as such. import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS } from "../postings.mjs"; @@ -143,6 +143,17 @@ automatically.

Difficulty first, then everything else. A skill can never be reduced below 1%.

+

When two people want opposite things

+

Sometimes the thing in your way is a person, and they are trying too. Then +both of you roll, and the better RESULT wins — a critical beats a +special beats a success, the same ladder as everywhere else. It is not who rolled +lower; a 02 on a skill of 20 is a critical and beats a 40 on a skill of 90.

+

Equal results go to ${opposedOutcomeFor("success", "success").winner === "resisting" ? "the one being acted upon" : "the one acting"}, because being moved off what you are +already doing takes more than matching it. And if neither of you succeeds, that +${opposedOutcomeFor("failure", "failure").settled ? "settles it" : "settles nothing"}: nothing happens. Whether the attempt can be made again is the situation's business, not the roll's — some things in this job come round every turn and some fire once.

+

How far apart the two results were is the size of the win — a critical against a +success is ${opposedOutcomeFor("critical", "success").margin} bands clear — which some things care about and most do not.

+

Where the numbers come from

${table(["Thing", "How", "Ceyla, for example"], [ ["Hit points", "(CON + SIZ) ÷ 2, rounded up", `${CEYLA.con} + ${CEYLA.siz} → ${cHp}`],