Audience: SoHL maintainers extending tests, opposed rolls, or combat outcomes.
Class hierarchy
TestResult (abstract) src/entity/result/TestResult.ts
├── SuccessTestResult src/entity/result/SuccessTestResult.ts
│ ├── AttackResult src/entity/result/AttackResult.ts
│ └── DefendResult src/entity/result/DefendResult.ts
└── OpposedTestResult src/entity/result/OpposedTestResult.ts
└── CombatResult src/entity/result/CombatResult.ts
Pipeline overview
A combat exchange flows through these stages:
1. Attacker selects strike mode
↓
2. MasteryLevelModifier.successTest() → AttackResult
(d100 roll, success level, pre-defense damage, allowed defenses)
↓
3. Defender chooses defense type (block, counterstrike, dodge, ignore)
↓
4. MasteryLevelModifier.successTest() → DefendResult
(d100 roll, success level, defense-specific mishaps)
↓
5. CombatResult compares attack vs. defense (opposed test resolution)
(winner, margin, combined mishaps)
↓
6. Impact resolution: margin + pre-defense damage + armor → final injury
(hit location via BodyStructure, protection per ImpactAspect)
Non-combat tests (skill checks, attribute tests) use steps 1-2 only, producing a SuccessTestResult directly.
Automated-combat invariants (enforced before step 1)
Automated combat checks these invariants up front and aborts (with a player-facing UI notification) on any violation — both participants must be combatants in the same active combat, the attacker must be the current combatant (it must be its turn), the attacker must not be incapacitated/defeated/dead, and the target must not be out of the fight (dead or vanquished/defeated). Enforcement points:
- Turn gate:
startAutomatedAttackaborts whenoutOfTurnAttackReason(getActiveCombat()?.combatant?.id, this.combatant?.id)returns a reason — there is no active combat turn, or the attacker is not the current combatant. Only the current combatant may start an automated attack; out-of-turn defenses (a counterstrike, a Tactical-Advantage follow-up) run through theautomated*Resumepath, notstartAutomatedAttack, so the gate never blocks them. - Attacker status:
startAutomatedAttack(src/document/combatant/logic/SohlCombatantLogic.ts) aborts whenattackerBlockingStatus(this.data.statuses, this.data.isDefeated)(matched againstATTACK_BLOCKING_STATUSES) returns a status. The attacker’s combat membership is guaranteed by the entry point (StrikeModeBase.automatedCombatStartresolves the attacker viafvttActiveCombatantForActor; the tracker action is on the combatant itself). - Target validity:
startAutomatedAttackresolves the target to a combatant (fvttActiveCombatantForActor(context.target.actorLogic?.actor)) — aborting if it isn’t one — then aborts whentargetInvalidStatus(...)(matched againstTARGET_INVALID_STATUSES=dead/vanquished) returns a status. - Incapacitated defender → Ignore-only:
gateAutomatedDefenseButtons(src/document/chat/chat-card-gating.ts), usingDEFENSE_DISABLING_STATUSES+hasAnyStatus. Render-time gating removes Dodge/Block/Counterstrike for an incapacitated defender, leaving Ignore.
The status sets and predicates (outOfTurnAttackReason, attackerBlockingStatus, targetInvalidStatus, hasAnyStatus) are pure and unit-tested; the resolution/gating that consumes them is Foundry glue. The turn gate applies only to starting an attack: automated and assisted combat can still be freely interleaved, and a defender’s counterstrike (or a Tactical-Advantage follow-up) resolves within the attacker’s exchange without waiting for the defender’s own turn.
Result classes in detail
TestResult
Abstract base. Holds speaker identity, title, description, and the parent Logic reference. Defines the evaluate() contract.
- Created by test methods on modifiers or logic classes.
evaluate()resolves the outcome — returnstrueif the result should be displayed.- Results are transient — not persisted to the database.
SuccessTestResult
The standard d100 roll-under mastery level test.
| Property | Type | Description |
|---|---|---|
roll | SimpleRoll | The d100 roll |
masteryLevelModifier | MasteryLevelModifier | The ML modifier used for this test |
successLevel | number | How far above/below the target (positive = success) |
isSuccess | boolean | Whether the test passed |
isCritical | boolean | Whether a critical result occurred (last-digit match) |
mishaps | Set<string> | Fumble/stumble flags from critical failures |
movement | SuccessTestResultMovement | Tactical movement state after the test |
resultText / resultDesc | string | Descriptive output for chat display |
Evaluation flow:
- Roll 1d100 (or reuse prior roll for fate).
- Success level = constrained ML − roll.
- Check critical success/failure against last-digit lists.
- Compute value diamonds from description table.
- Populate result text for chat.
Chat output: Renders via templates/chat/standard-test-card.hbs.
Prior test results: When context.scope.priorTestResult is provided, the dialog redisplays for modifier adjustment but reuses the prior roll. This supports fate mechanics.
OpposedTestResult
Two competing SuccessTestResults compared to determine a winner.
| Property | Type | Description |
|---|---|---|
sourceTestResult | SuccessTestResult | Initiating actor’s test |
targetTestResult | SuccessTestResult | Responding actor’s test |
breakTies | boolean | Whether a tie is settled rather than reported |
tieBreak | number | Which side a tie was awarded to (SOURCE / NONE / TARGET) |
tieBreakReason | string | Which rule settled it (roll / ml / rolloff) |
sourceWins / targetWins | boolean | Outcome flags |
isTied / bothFail | boolean | Edge case flags |
isTieBroken | boolean | The contest tied, and tieBreak then settled it |
victoryStars | number | Margin in Victory Stars; 1 for a broken tie, 0 for a tie |
Winner and margin are compared on the raw (unclamped) success levels
(SuccessTestResult.rawSuccessLevel), so a successLevelMod that pushes a level
past the four-point scale widens the margin with it — the Victory Star count has no
ceiling. CombatResult.margin is separate and still normalized (−3..+3).
Two-phase execution:
opposedTestStart()— source rolls (its pre-roll dialog offers Break Ties), result posted to chat with “respond” button.opposedTestResume()— target rolls, opposed outcome evaluated (settling a tie when asked) and posted.
Chat output: Renders via templates/chat/opposed-request-card.hbs (phase 1) and templates/chat/opposed-result-card.hbs (phase 2).
AttackResult
The attacker’s side of a combat exchange.
| Property | Type | Description |
|---|---|---|
allowedDefenses | Set<string> | Defense types the target may use |
damage | number | Pre-defense damage value |
situationalModifier | number | Player-entered attack modifier |
modifiers | Map<string, string> | Named modifier map for audit |
Evaluation: Rolls the attack, checks for attack-specific mishaps (weapon break, stumble, fumble, wild swing), and computes pre-defense damage.
DefendResult
The defender’s side of a combat exchange.
| Property | Type | Description |
|---|---|---|
situationalModifier | number | Player-entered defense modifier |
Evaluation: Rolls the defense (block, counterstrike, or dodge), checks for defense-specific mishaps (shield break, stumble, fumble).
CombatResult
The full combat exchange — composes AttackResult + DefendResult via opposed test resolution.
| Property | Type | Description |
|---|---|---|
attackResult | AttackResult | The attacker’s result |
defendResult | DefendResult | The defender’s result |
margin | number | Victory score VS (see below) |
tacticalAdvantages | { side, count } | TAs awarded by the exchange |
weaponBreakCheck | "attacker" | "defender" | "none" | Whose weapon must roll for breakage |
Determines (via opposedTestEvaluate()):
- Who lands a blow — the derived getters
attackerLandsBlow/defenderLandsBlow(the defender only via Counterstrike). “Lands a blow” means connected; the blow may still be fully absorbed by armor during impact resolution, so it does not by itself imply damage. - The victory score
VS = attacker.normSuccessLevel − defender.normSuccessLevel. This is the raw level difference, deliberately not the inheritedsourceWins/isTiedgetters — those carve out a “both failed” case, whereas the SoHL combat tables resolve every exchange by relative margin (a less-bad failure still beats a worse one). Winning the exchange is not the same as landing a blow: a failed attack takes the margin, and any Tactical Advantages with it, without connecting. - Tactical Advantages and the weapon-break check (display-only for now).
Per-defense outcome. Every attacker cell is additionally conditional on
attackResult.isSuccess — a failed attack never lands, however badly the
defence blundered:
| Defense | Attacker delivers | Defender delivers | Notes |
|---|---|---|---|
| Block | VS > 0 | never | a tie wards the blow, and sets weaponBreakCheck = "defender" |
| Counterstrike | VS >= 0 | when its own roll succeeds | both blows may land |
| Dodge | VS > 0, or tie with a lower dodge roll than attack roll | never | |
| Ignore | always | never | no defender contest |
A block need only tie to ward the blow — and the tie is what the blocker’s weapon-break check exists for, so it fires only when there was a blow to absorb (a tie between two failures sets no check). A dodge must win outright; its tie goes to the ordinary tiebreak, which the higher roll takes.
Tactical Advantages: the winner of a |VS| >= 2 exchange earns |VS| − 1 TAs
(attacker on VS >= 2, defender on VS <= -2).
Does NOT determine: Final damage — that is computed by the impact resolution
stage (src/entity/body/injury-resolution.ts) using the attack’s pre-defense
damage, the aspect, and the target’s armor/body-location protection.
Strength and impact
A strike mode’s impact is not a constant property of the weapon. The
wielder’s Strength Impact Modifier is folded in during the finalize phase, by
applyStrengthImpact via the document-layer wiring
in src/document/item/logic/wielderStrength.ts, which both
WeaponGearLogic and
SkillLogic (for combat techniques) call.
Finalize, not evaluate: the rule reads the wielder’s Strength attribute across
documents, and attribute scores only settle once every sibling item has
evaluated — the same cross-item read the governing mastery-level wiring makes.
Because it lands there rather than at attack time, the sheet and the attack card
agree, and every contribution arrives as a named delta (StrImp, OffHnd,
Thrwn) so the impact breakdown stays auditable.
The rule itself
(strengthImpactModifier) is a closed form rather
than the published lookup table, so it extends without bound in both directions:
⌊(STR − 10) / 2⌋ at STR ≥ 5, and the steeper 2 × STR − 12 below it. It
applies to melee modes and thrown weapons only — a launcher firing separate
ammunition is excluded, as is anything carrying the noStrMod trait.
Off-hand determination runs through isOffHandGrip; see Body Structure → Laterality and dominance.
Injury resolution
The impact stage is a Foundry-free module, shared by both combat modes and the manual Add Injury flow:
resolveInjury(input)— takes the hit location resolved upstream (an explicitlocationfrom a manual pick or Zone-Die aiming, or a weighted-random draw when none is given), subtracts the effective protection (armorValue − armorReduction, floored at 0), maps the effective impact to a level (≤0 none · 1–4 M1 · 5–9 S2 · 10–14 S3 · 15–19 G4 · 20+ G5), and derives the Shock Index, glancing blow, stumble/fumble, bleeding, and amputation. Armor value is the location’s natural protection plus any worn armor folded on byaggregateArmor()during the lifecycle.buildTraumaData(injury)— thesystem.*shape for a new Trauma item.
Chat card templates
| Template | Used by | Purpose |
|---|---|---|
standard-test-card.hbs | SuccessTestResult | Standard test outcome |
opposed-request-card.hbs | OpposedTestResult (phase 1) | “Respond to opposed test” prompt |
opposed-result-card.hbs | OpposedTestResult (phase 2) | Final opposed outcome |
attack-card.hbs | AttackResult | Attack-specific display |
attack-result-card.hbs | AttackResult | Attack outcome details |
damage-card.hbs | buildDamageCardData | Rolled impact + Calculate Injury button |
injury-card.hbs | buildInjuryCardData | Resolved injury (level, shock, mishaps) |
Extension guidance
- New graded / special-result test — do not subclass. A test that rolls a
d100 against a mastery level and reports a bespoke set of outcomes (“Keeps
Footing” / “Stumbles” / “Drops It”, a shock state, a fear reaction) is not a
new class. Drive the one generic
MasteryLevelModifier.successTest(context)and pass the outcome mapping as data in the action scope:scope.resultDescTablesupplies the result-description table (label / description / stars per rung), and an optionalscope.targetValueFuncremaps the target when the test grades off something other than the raw mastery level. Follow-up consent buttons ride the same standard card — passbuttonstoSuccessTestResult.toChat(see the card-data contract). This inherits impairment/fatigue gating, Fate eligibility,priorTestResultreconstruction, and card rendering for free; a subclass re-implements all of it and drifts. Worked example:keepControlTable+BeingLogic.stumbleTest/fumbleTest. See Extension Points §3. - Subclass
SuccessTestResultonly for genuinely different roll math — a test whose evaluation is not “d100 ≤ constrained mastery level” (a different die, a multi-roll resolution, a non-threshold outcome). New result text or a new follow-up button is never a reason to subclass. When you do subclass, overrideevaluate()and keeptoChat()payloads compatible. - New combat mechanic: Extend
AttackResult/DefendResult(orSuccessTestResult) for new test types, orCombatResultfor new combat exchange patterns. - Custom modifiers: Add deltas to the
MasteryLevelModifierbeforeevaluate()is called — don’t modify the result after evaluation. - Keep
evaluate()deterministic from input state; avoid hidden side effects. - Keep
toChat()payloads backward-compatible for template and macro consumers.