Combat Resolution Pipeline

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: startAutomatedAttack aborts when outOfTurnAttackReason(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 the automated*Resume path, not startAutomatedAttack, so the gate never blocks them.
  • Attacker status: startAutomatedAttack (src/document/combatant/logic/SohlCombatantLogic.ts) aborts when attackerBlockingStatus(this.data.statuses, this.data.isDefeated) (matched against ATTACK_BLOCKING_STATUSES) returns a status. The attacker’s combat membership is guaranteed by the entry point (StrikeModeBase.automatedCombatStart resolves the attacker via fvttActiveCombatantForActor; the tracker action is on the combatant itself).
  • Target validity: startAutomatedAttack resolves the target to a combatant (fvttActiveCombatantForActor(context.target.actorLogic?.actor)) — aborting if it isn’t one — then aborts when targetInvalidStatus(...) (matched against TARGET_INVALID_STATUSES = dead / vanquished) returns a status.
  • Incapacitated defender → Ignore-only: gateAutomatedDefenseButtons (src/document/chat/chat-card-gating.ts), using DEFENSE_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 — returns true if the result should be displayed.
  • Results are transient — not persisted to the database.

SuccessTestResult

The standard d100 roll-under mastery level test.

PropertyTypeDescription
rollSimpleRollThe d100 roll
masteryLevelModifierMasteryLevelModifierThe ML modifier used for this test
successLevelnumberHow far above/below the target (positive = success)
isSuccessbooleanWhether the test passed
isCriticalbooleanWhether a critical result occurred (last-digit match)
mishapsSet<string>Fumble/stumble flags from critical failures
movementSuccessTestResultMovementTactical movement state after the test
resultText / resultDescstringDescriptive output for chat display

Evaluation flow:

  1. Roll 1d100 (or reuse prior roll for fate).
  2. Success level = constrained ML − roll.
  3. Check critical success/failure against last-digit lists.
  4. Compute value diamonds from description table.
  5. 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.

PropertyTypeDescription
sourceTestResultSuccessTestResultInitiating actor’s test
targetTestResultSuccessTestResultResponding actor’s test
breakTiesbooleanWhether a tie is settled rather than reported
tieBreaknumberWhich side a tie was awarded to (SOURCE / NONE / TARGET)
tieBreakReasonstringWhich rule settled it (roll / ml / rolloff)
sourceWins / targetWinsbooleanOutcome flags
isTied / bothFailbooleanEdge case flags
isTieBrokenbooleanThe contest tied, and tieBreak then settled it
victoryStarsnumberMargin 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:

  1. opposedTestStart() — source rolls (its pre-roll dialog offers Break Ties), result posted to chat with “respond” button.
  2. 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.

PropertyTypeDescription
allowedDefensesSet<string>Defense types the target may use
damagenumberPre-defense damage value
situationalModifiernumberPlayer-entered attack modifier
modifiersMap<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.

PropertyTypeDescription
situationalModifiernumberPlayer-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.

PropertyTypeDescription
attackResultAttackResultThe attacker’s result
defendResultDefendResultThe defender’s result
marginnumberVictory 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 inherited sourceWins/isTied getters — 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.isSuccessa failed attack never lands, however badly the defence blundered:

DefenseAttacker deliversDefender deliversNotes
BlockVS > 0nevera tie wards the blow, and sets weaponBreakCheck = "defender"
CounterstrikeVS >= 0when its own roll succeedsboth blows may land
DodgeVS > 0, or tie with a lower dodge roll than attack rollnever
Ignorealwaysneverno 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 explicit location from 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 by aggregateArmor() during the lifecycle.
  • buildTraumaData(injury) — the system.* shape for a new Trauma item.

Chat card templates

TemplateUsed byPurpose
standard-test-card.hbsSuccessTestResultStandard test outcome
opposed-request-card.hbsOpposedTestResult (phase 1)“Respond to opposed test” prompt
opposed-result-card.hbsOpposedTestResult (phase 2)Final opposed outcome
attack-card.hbsAttackResultAttack-specific display
attack-result-card.hbsAttackResultAttack outcome details
damage-card.hbsbuildDamageCardDataRolled impact + Calculate Injury button
injury-card.hbsbuildInjuryCardDataResolved 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.resultDescTable supplies the result-description table (label / description / stars per rung), and an optional scope.targetValueFunc remaps the target when the test grades off something other than the raw mastery level. Follow-up consent buttons ride the same standard card — pass buttons to SuccessTestResult.toChat (see the card-data contract). This inherits impairment/fatigue gating, Fate eligibility, priorTestResult reconstruction, 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 SuccessTestResult only 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, override evaluate() and keep toChat() payloads compatible.
  • New combat mechanic: Extend AttackResult/DefendResult (or SuccessTestResult) for new test types, or CombatResult for new combat exchange patterns.
  • Custom modifiers: Add deltas to the MasteryLevelModifier before evaluate() 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.

See Also