R-273: the opposed roll, built where the authority lives
The content has called for 'opposed POWx5' since before the rule existed -- the hollow man's tactics, CLEAN GROUND twice, slice-text three times -- and rules.mjs defined none. A scenario carried a local ruling that said in its own text it was a ruling and not a rule. Generalises that ruling rather than inventing another: both sides roll, the better band wins, only the ladder the game already has. Ties go to whoever is being acted upon, which is what defenceOutcomeFor has always said; the scenario's 'favour the agent' gave the same answer only because no agent ever initiates one. Neither side succeeding leaves the contest unsettled rather than won, which the two beats need in opposite directions. Two exports at the scenario session's request: opposedOutcomeFor compares graded levels and carries both, so a caller can price a fumbled attempt without this file deciding what a fumble costs; opposedContestFor runs it from ratings and rolls with per-side difficulty, so 'resists at Difficult' does not put applyDifficulty back into a document. Spot-checked against the real Act Four beat: 55 against 85 at Difficult, which is 42. Page 1 states it by asking it -- the tie-break and the margin are computed from the rule at build time, so the book cannot drift from the engine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d03b3208e5
commit
cdbb2e34ee
@@ -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
|
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
|
write a censored measurement, the other will not let an unstable figure be cited. Neither can
|
||||||
be satisfied by re-recording.
|
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.
|
||||||
|
|||||||
+2
-2
@@ -29,7 +29,7 @@ import {
|
|||||||
locationDamageReplacement, armourAgainst, damageAfterArmour,
|
locationDamageReplacement, armourAgainst, damageAfterArmour,
|
||||||
encumbrancePenaltyFor, ENCUMBERED_CATEGORIES, freeCarryFor,
|
encumbrancePenaltyFor, ENCUMBERED_CATEGORIES, freeCarryFor,
|
||||||
defencePenaltyFor, defenceTypeAllowed, DEFENCE_STEP,
|
defencePenaltyFor, defenceTypeAllowed, DEFENCE_STEP,
|
||||||
defenceOutcomeFor, levelRank, LEVEL_LADDER, skillBaseFrom,
|
defenceOutcomeFor, opposedOutcomeFor, opposedContestFor, levelRank, LEVEL_LADDER, skillBaseFrom,
|
||||||
applyDifficulty, resolveBands, gradeRoll,
|
applyDifficulty, resolveBands, gradeRoll,
|
||||||
DYING, dyingLimitFor, conditionFor, lastEntryFor, LAST_ENTRY, destructionOutcomeFor,
|
DYING, dyingLimitFor, conditionFor, lastEntryFor, LAST_ENTRY, destructionOutcomeFor,
|
||||||
RETURN_CLOCK, returnPenaltyFor, returnIsDifficult, extractionCostFor,
|
RETURN_CLOCK, returnPenaltyFor, returnIsDifficult, extractionCostFor,
|
||||||
@@ -6311,7 +6311,7 @@ Hooks.once("init", () => {
|
|||||||
isAcross, setAcross, useTransposed, debrief, activeCaseFile, returnCrossing, caseMembers,
|
isAcross, setAcross, useTransposed, debrief, activeCaseFile, returnCrossing, caseMembers,
|
||||||
earnedDiscoveries, deviceFunctionStatus, earnDiscovery, useDeviceFunction, pickContactActor,
|
earnedDiscoveries, deviceFunctionStatus, earnDiscovery, useDeviceFunction, pickContactActor,
|
||||||
resolveContact, newEncounter, claimRewards, activeRewardEffects,
|
resolveContact, newEncounter, claimRewards, activeRewardEffects,
|
||||||
defenceOutcomeFor, levelRank, recordBlow, standingBlow, amendBlow,
|
defenceOutcomeFor, opposedOutcomeFor, opposedContestFor, levelRank, recordBlow, standingBlow, amendBlow,
|
||||||
addDossierItem, useAuthority, widenScope, rehabilitate,
|
addDossierItem, useAuthority, widenScope, rehabilitate,
|
||||||
tickDying, died, stabilise, reviveIfHealed, conditionFor, dyingLimitFor,
|
tickDying, died, stabilise, reviveIfHealed, conditionFor, dyingLimitFor,
|
||||||
fall, asphyxiate, burn, detonate, burstAttack, twoWeaponAttack,
|
fall, asphyxiate, burn, detonate, burstAttack, twoWeaponAttack,
|
||||||
|
|||||||
@@ -1927,6 +1927,79 @@ export function levelRank(level) {
|
|||||||
return i < 0 ? 1 : i;
|
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.
|
* What a defence actually does to the blow it answers.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -506,6 +506,31 @@ const checks = [
|
|||||||
["defenceOutcomeFor(special, special)", RULES.defenceOutcomeFor("special","special").turnedAside, true],
|
["defenceOutcomeFor(special, special)", RULES.defenceOutcomeFor("special","special").turnedAside, true],
|
||||||
["defenceOutcomeFor(success, success)", RULES.defenceOutcomeFor("success","success").turnedAside, true],
|
["defenceOutcomeFor(success, success)", RULES.defenceOutcomeFor("success","success").turnedAside, true],
|
||||||
["defenceOutcomeFor(success, fumble)", RULES.defenceOutcomeFor("success","fumble").landsAt, "success"],
|
["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
|
// 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
|
// 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.
|
// has no head must not be able to return one, which is the whole point of the tables.
|
||||||
|
|||||||
+12
-1
@@ -30,7 +30,7 @@ import {
|
|||||||
REACTION, BLEED, CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom,
|
REACTION, BLEED, CIRCUMSTANCE, RANGE_LADDER, rangeBandFrom,
|
||||||
MAJOR_WOUND_TABLE, COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY,
|
MAJOR_WOUND_TABLE, COHERENCE_BANDS, COHERENCE_COSTS, COHERENCE_RECOVERY,
|
||||||
PANIC_BANDS, PANIC_TRIGGERS, PANIC_RELIEF, PANIC_MAX, PANIC_AFTER_BREAK, PANIC_BREAK,
|
PANIC_BANDS, PANIC_TRIGGERS, PANIC_RELIEF, PANIC_MAX, PANIC_AFTER_BREAK, PANIC_BREAK,
|
||||||
DEVELOPMENT
|
DEVELOPMENT, opposedOutcomeFor
|
||||||
} from "../rules.mjs";
|
} from "../rules.mjs";
|
||||||
// Postings, trades and induction are plain data and are imported as such.
|
// Postings, trades and induction are plain data and are imported as such.
|
||||||
import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS } from "../postings.mjs";
|
import { ROLES, TRADES, INDUCTION, TIERS, TRADE_BANDS } from "../postings.mjs";
|
||||||
@@ -143,6 +143,17 @@ automatically.</li>
|
|||||||
</ol>
|
</ol>
|
||||||
<p>Difficulty first, then everything else. A skill can never be reduced below 1%.</p>
|
<p>Difficulty first, then everything else. A skill can never be reduced below 1%.</p>
|
||||||
|
|
||||||
|
<h2>When two people want opposite things</h2>
|
||||||
|
<p>Sometimes the thing in your way is a person, and they are trying too. Then
|
||||||
|
<strong>both of you roll, and the better RESULT wins</strong> — 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.</p>
|
||||||
|
<p><strong>Equal results go to ${opposedOutcomeFor("success", "success").winner === "resisting" ? "the one being acted upon" : "the one acting"}</strong>, 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.</p>
|
||||||
|
<p>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.</p>
|
||||||
|
|
||||||
<h2>Where the numbers come from</h2>
|
<h2>Where the numbers come from</h2>
|
||||||
${table(["Thing", "How", "Ceyla, for example"], [
|
${table(["Thing", "How", "Ceyla, for example"], [
|
||||||
["Hit points", "(CON + SIZ) ÷ 2, rounded up", `${CEYLA.con} + ${CEYLA.siz} → <strong>${cHp}</strong>`],
|
["Hit points", "(CON + SIZ) ÷ 2, rounded up", `${CEYLA.con} + ${CEYLA.siz} → <strong>${cHp}</strong>`],
|
||||||
|
|||||||
Reference in New Issue
Block a user