Audience: Developers extending combat — the two combat modes and how the combat flow is wired programmatically.
See also: Combat Resolution Pipeline (the result classes and opposed-test math), Scene, Token, and Combatant Systems (the combatant model), Macros and Actions, and the player-facing Combat Basics guide.
Two modes, one rules engine
SoHL runs combat two ways, and both resolve rolls through the same engine —
a d100 roll-under against a MasteryLevelModifier, producing a
SuccessTestResult (see the pipeline doc).
They differ only in how much of the exchange the system drives:
| Assisted | Automated | |
|---|---|---|
| Scope | A single roll (attack / block / counterstrike / dodge / impact) | The whole attacker↔defender exchange |
| Context required | None | A running combat encounter, combatants, tokens, a target, and the attacker on its turn |
| Workflow | None — posts one test card | Multi-stage, cross-client, chat-driven |
| Entry | Being-sheet Combat-tab cells | StrikeModeBase.automatedCombatStart / combatant action |
| Result types | SuccessTestResult | AttackResult + DefendResult → CombatResult |
The rule of thumb when extending: assisted combat is a thin wrapper over
successTest; automated combat is an orchestration layer on top of the same
results. Anything that must work “anytime, anywhere” belongs on the assisted
path; anything that coordinates two combatants belongs on the automated path.
Assisted combat
Assisted combat is deliberately workflow-free and context-free — no combat, combatant, target, turn, or token is required. It is driven entirely from the Being sheet’s Combat tab.
BeingSheet._onRollStrikeModeTest(src/document/actor/foundry/BeingSheet.ts) readsdata-sm-id/data-item-id/data-test-kindoff the clicked cell, resolves theStrikeModeBase, and callsselectStrikeModeModifier(sm, testKind)(being-sheet-view.ts), which mapsattack → sm.attack,block → sm.defense.block,counterstrike → sm.defense.counterstrike.- It then builds a bare
SohlActionContext(shift-click setsskipDialog) and callsmlMod.successTest(context)directly. That posts a standard success-test card — no opposed resolution, no second party. - Impact is assisted the same way:
_onRollStrikeModeImpactreads the strike mode’simpactmodifier and dispatches the actor’scalcImpactaction (actorLogic.executeAction("calcImpact", …)with the modifier oncontext.scope), posting a damage card. Skills use the identical shape via_onRollSkillTest→skillLogic.successTest.
Because it only touches the strike-mode modifiers and successTest, the assisted
path never references combat state. There are no weapon-level attack/block/
counterstrike actions — assisted combat is per-strike-mode only (#69), and
dodge is a Dodge-skill test, not a Combat-tab cell.
Automated combat
Automated combat is an opinionated, chat-card-driven workflow that coordinates an
attacker and a defender across clients. The orchestration lives in
SohlCombatantLogic (src/document/combatant/logic/SohlCombatantLogic.ts),
and every stage exchanges an evaluated result through a chat card.
Requirements
To start an automated attack:
- The attacker must be a combatant in the active combat.
StrikeModeBase.automatedCombatStartresolves the combatant withfvttActiveCombatantForActor(this.parent.actor)and warns/aborts if the actor is not in the active combat (the tracker entry is on the combatant itself). - The attacker must be the current combatant (turn gate).
startAutomatedAttackaborts whenoutOfTurnAttackReason(getActiveCombat()?.combatant?.id, this.combatant?.id)returns a reason — i.e. there is no active combat turn, or the attacker is not the combatant whose turn it is. Only the current combatant may open an automated attack; out-of-turn defenses take a different path (see the note below). - The attacker must not be out of the fight.
startAutomatedAttackaborts whenattackerBlockingStatus(this.data.statuses, this.data.isDefeated)(againstATTACK_BLOCKING_STATUSES— dead, vanquished, unconscious, sleep, restrained, paralyzed, frozen, incapacitated) returns a status. - A target is required, and must be a valid combatant.
startAutomatedAttackaborts whencontext.targetis absent, when it does not resolve to a combatant in the active combat, or whentargetInvalidStatus(...)(againstTARGET_INVALID_STATUSES—dead/vanquished) reports the target is dead or defeated/surrendered. - Range is computed (
fvttRangeToTarget) and validated per strike mode (meleereach.effective, missilebaseRange.effective); missile volley beyond base range is explicitly unsupported.
Defender-side gating is likewise fully wired at card-render time (see Cross-client handoff).
Note — the turn gate applies to starting an attack, not to defending. Only the current combatant may start an automated attack (the turn gate above). The exchange it opens still crosses turns on the defender’s side: a defender’s counterstrike strikes back within that same exchange, and a Tactical Advantage the defender earns can buy a follow-up strike. Those run through the
automated*Resumeexecutors (the defense-resume path), not throughstartAutomatedAttack, so the turn gate never blocks them. Automated and assisted combat can still be freely interleaved (a fight may drop to assisted mid-exchange). TheoutOfTurnAttackReason,attackerBlockingStatus, andtargetInvalidStatuspredicates are pure and unit-tested.
Entry points
Both converge on one executor, SohlCombatantLogic.startAutomatedAttack:
- From a weapon/technique —
StrikeModeBase.automatedCombatStartstuffs the strike mode’spointerDataintocontext.scope.mode(so only that weapon’s modes are offered) and delegates:combatantLogic.executeAction("automatedCombatStart", context). - From the combat tracker — the combatant’s intrinsic
automatedCombatStartaction (executor: "startAutomatedAttack",visible: "true", groupESSENTIAL) is injected into the tracker row’s context menu bycombat-tracker-hooks.ts, gated oncombatant.isOwner.
The exchange, stage by stage
| Stage | Driver | What happens |
|---|---|---|
| 1. Attack roll | startAutomatedAttack → commonAttack (shared attack dialog) → buildAttackResult → attackResult.evaluate() | The attack is pre-evaluated on the attacker’s client (the roll is the attacker’s). A miss disables impact; impact is not rolled yet. |
| 2. Attack card | buildAttackCardData → templates/chat/attack-card.hbs | Emits all four defense buttons; embeds the serialized AttackResult in the card’s data-scope; addresses the target actor via handlerActorUuid. |
| 3. Defender responds | Defender’s intrinsic resume actions (all visible: "false", group HIDDEN) | The clicked button runs one of the *Resume executors below on the defender’s combatant, reviving the attacker’s result as context.scope.attackResult. |
| 4. Defense roll + resolution | automated{Block,Dodge,Counterstrike,Ignore}Resume → buildCombatResult → CombatResult.evaluate() | Builds a DefendResult, composes it with the (already-evaluated) attack into a CombatResult, and runs the opposed test. |
| 5. Impact | CombatResult → rollImpact | Impact is rolled only when a blow lands (this is where damage dice are rolled), producing an ImpactResult. |
| 6. Combat-result card | buildCombatCardData → attack-result-card.hbs | Two-column (Attack | Defend) card; one “Calculate Injury” button per landing side. |
| 7. Injury | injury button → BeingLogic.onCreateInjury → resolveAutomatedInjury | Rolls the hit location, applies armor/body-location protection, and records the Trauma with no dialog (automated), because the button forwards the attack’s aim. See injury resolution. |
The four defense resumes:
automatedBlockResume— collects blockable melee modes (non-disableddefense.block), defaults tolastBlockModeor the best block ML, optionally dialogs, builds aDefendResult(TEST_TYPE.BLOCK).automatedDodgeResume— resolves the Dodge skill ML (resolveSkillMasteryLevel(actorLogic, SKILL_CODE.DODGE)); no dialog,TEST_TYPE.DODGE.automatedCounterstrikeResume— a melee attack back at the original attacker (resolved fromattackResult.speaker.tokenLogic); reusescommonAttackand posts two cards so both sides display; both blows may land.automatedIgnoreResume— no contest,TEST_TYPE.IGNORE, single card.
Opposed resolution, victory score, tactical advantages, per-defense “lands a blow” rules, and impact→armor→injury are all detailed in the Combat Resolution Pipeline; this doc does not repeat them.
Cross-client handoff
The defender’s buttons must appear on the defender’s client, and the click must
be authorized. The flow (src/sohl.ts renderChatMessageHTML hook, plus
src/document/chat/):
- Addressing. The attack card sets
handlerActorUuidto the target actor’s uuid (buildAttackCardData); the buttons carry it asdata-handler-actor-uuid. - Render-time gating (every client).
gateAutomatedDefenseButtons(chat-card-gating.ts) runs on each client and:- removes all buttons unless the viewer is the defender’s owner (a GM owns all);
- if the defender has any
DEFENSE_DISABLING_STATUSES, leaves only Ignore; - shows Block only if there are blockable modes, Counterstrike only if there’s a melee attack mode, Dodge only if the Dodge skill is usable. This is UX only — it hides buttons, it does not authorize.
- Click-time authorization (the real boundary, #167).
resolveAuthorizedChatCardHandler(chat-card-dispatch.ts) resolves the handler doc (uuid precedencedocUuid → handlerUuid → handlerActorUuid → actionHandlerUuid) and returns it only ifisOwner;onChatCardButtonre-checks ownership. The button’sdata-scopebecomescontext.scope, so the resume readscontext.scope.attackResult.
Cross-actor writes never happen directly — a resume mutates only its own combatant/actor and communicates back through the target-addressed card (see actor state sovereignty).
Combatants and the combat lifecycle
Combatant properties
SohlCombatant (src/document/combatant/foundry/SohlCombatant.ts) adds
encounter-scoped state on top of Foundry’s Combatant. Key fields the logic reads:
| Field / getter | Meaning |
|---|---|
groupId | The combatant’s CombatantGroup — the side it fights on. Reads _source.group first for a stable id. The sole input to isEnemyOf / allies / threatenedBy; see Combatant groups. |
moveFactor | GM situational move multiplier (run/sprint/terrain); computedMove() scales the actor’s feetPerRound by it. |
displayedMedium | Which movement medium the tracker shows; seeded at _preCreate (user-set › the actor’s currentMoveMedium › schema default). (Not yet honored by computedMove, which uses the actor’s active medium.) |
computedMove() / displayedMove | Tactical feet-per-round from the actor’s feetPerRound (scaled by moveFactor), or null for a non-mover (movement medium NONE). |
| initiative | _getInitiativeFormula() returns the actor’s init skill mastery as a fixed string — SoHL initiative is skill-driven, not a die roll. |
Combat relationships are computed, not stored — isEnemyOf, allies, and
threatenedBy are all derived from group membership on demand. See
Combatant groups below for what that buys and what it
deliberately leaves out.
Combatant groups
A combatant group is a named side within a single encounter — nothing more.
It is Foundry’s native CombatantGroup embedded document (SoHL registers no data
model, sheet, or document subclass for it) and lives only as long as the combat
does.
Why the concept exists. Combat resolution needs one fact that nothing else in
the system models: who is fighting whom. It cannot come from the actor —
allegiance is a property of the encounter, not of the character, and the same
mercenary is an ally this week and an enemy the next. The group is where that
per-encounter fact lives, and it is deliberately the only input to it. The whole
rule is one comparison, in the pure areCombatantsEnemies: two combatants are
enemies iff their group ids differ. Same group ⇒ allies; a combatant is never
its own enemy; and a missing group on either side resolves defensively to
enemy, so a not-yet-seeded combatant is never mistaken for a friend.
The capability it enables is the relational layer on SohlCombatantLogic —
three derived queries, computed on demand and never stored, each a pure function
of group membership plus current scene state:
| Query | Definition |
|---|---|
isEnemyOf | Different non-null groups (above). |
allies | The other combatants sharing this one’s group. Empty when ungrouped. |
threatenedBy | The enemies actually menacing this combatant right now — not defeated, carrying none of THREAT_NEGATING_STATUSES (unconscious, sleep, stun, restrained, paralyzed, frozen), not hidden, and within melee reach. |
threatenedBy is what the concept is ultimately for. Engagement is the question
the combat rules keep asking — is this character engaged, and by how many? — and
it is unanswerable without sides. Group membership supplies the allegiance half;
reaches (center-to-center grid distance against the enemy’s greatest melee
reach) supplies the spatial half.
These relations are a public API surface, not yet an internal consumer.
Nothing in src/ currently reads allies or threatenedBy; they are
implemented and unit-tested, and exist for macros, modules, and the rules work
that will price engagement — see
Current gaps and caveats. What group membership
drives today is the tracker’s group chip and the seeding described below.
What a group deliberately does not do. It carries no leader, no group
initiative, and no turn ordering — the tracker sorts by individual initiative
and the chip is display-only (combat-tracker-hooks.ts explicitly does not group
rows). It does not gate targeting: automated combat takes its target from the
attacking player’s targeted token (fvttGetTargetedTokens) and never consults a
group, so nothing stops you attacking an ally. And allegiance is binary — an
earlier custom groups[] / groupStances faction matrix that modeled stances
between sides was removed as unused.
Group seeding
Sides are assigned when combatants are created, GM-authoritatively and fire-and-forget:
SohlCombat._onCreateDescendantDocuments(src/document/combat/foundry/SohlCombat.ts) — after super, when the created descendants arecombatantsand the caller is the active GM, it dispatchesvoid this.seedCombatantGroups(documents)(fire-and-forget — a combatant’sgroupIdis not set the instantcreateEmbeddedDocumentsresolves; poll for it in tests).seedCombatantGroupsmaps each combatant to{ id, hasGroup, desiredName: actor.system.defaultCombatGroup ?? null }, then the pureresolveGroupSeeding(combat/logic/combat-logic.ts) plans the distinct groups to create (case-insensitive dedup; default"Opponents"). MissingCombatantGroups are batch-created and each combatant’sgroupset.
Turn and round lifecycle
SohlHookBridge(src/core/logic/SohlHookBridge.ts) fans Foundry’s combat hooks into system lifecycle events, all GM-gated and routed through the event queue:combatStart,combatRound→roundEnd+roundStart,combatTurn→turnEnd+turnStart,deleteCombat→combatEnd.updateCombat(src/sohl.ts) captures the new current combatant’s position and resets its per-turndidActionon turn/round change. It is the source of the turn-start location thatspacesMovedThisTurnreports.- The combat tracker (
combat-tracker-hooks.ts) injects each owned combatant’s non-hidden intrinsic actions (Automated Attack, Move to Group) into its context menu, and renders the group-name and computed-move chips per row.
Current gaps and caveats
For anyone extending combat, the wired state diverges from the intended/documented state in a few places (all verified against source):
displayedMediumis not honored bycomputedMove— it seeds the tracker chip but movement always uses the actor’s active medium (currentMoveMedium).- Weapon break is display-only —
CombatResult.weaponBreakCheckis computed and shown on the card, but no breakage is applied. - The group relations have no internal consumer —
allies,threatenedBy,isThreatening, andreachesare implemented and unit-tested, but nothing insrc/reads them: no modifier, dialog, action, or card. Group membership currently drives only the tracker chip and seeding. The engagement rules that would consume them (outnumbering, engagement zone) are unbuilt — theVALUE_DELTA_INFO.OUTNUMBEREDinfo flag and its lang strings exist but are never applied.
See also
- Combat Resolution Pipeline — the result-class hierarchy, opposed-test math, victory score, tactical advantages, and injury resolution.
- Scene, Token, and Combatant Systems — token/targeting helpers, initiative, movement state.
- Body Structure — hit location and armor aggregation.
- Macros and Actions — how intrinsic actions (the
*Resumeexecutors) are defined and dispatched.