Combat Model

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:

AssistedAutomated
ScopeA single roll (attack / block / counterstrike / dodge / impact)The whole attacker↔defender exchange
Context requiredNoneA running combat encounter, combatants, tokens, a target, and the attacker on its turn
WorkflowNone — posts one test cardMulti-stage, cross-client, chat-driven
EntryBeing-sheet Combat-tab cellsStrikeModeBase.automatedCombatStart / combatant action
Result typesSuccessTestResultAttackResult + DefendResultCombatResult

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) reads data-sm-id / data-item-id / data-test-kind off the clicked cell, resolves the StrikeModeBase, and calls selectStrikeModeModifier(sm, testKind) (being-sheet-view.ts), which maps attack → sm.attack, block → sm.defense.block, counterstrike → sm.defense.counterstrike.
  • It then builds a bare SohlActionContext (shift-click sets skipDialog) and calls mlMod.successTest(context) directly. That posts a standard success-test card — no opposed resolution, no second party.
  • Impact is assisted the same way: _onRollStrikeModeImpact reads the strike mode’s impact modifier and dispatches the actor’s calcImpact action (actorLogic.executeAction("calcImpact", …) with the modifier on context.scope), posting a damage card. Skills use the identical shape via _onRollSkillTestskillLogic.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.automatedCombatStart resolves the combatant with fvttActiveCombatantForActor(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). startAutomatedAttack aborts when outOfTurnAttackReason(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. startAutomatedAttack aborts when attackerBlockingStatus(this.data.statuses, this.data.isDefeated) (against ATTACK_BLOCKING_STATUSES — dead, vanquished, unconscious, sleep, restrained, paralyzed, frozen, incapacitated) returns a status.
  • A target is required, and must be a valid combatant. startAutomatedAttack aborts when context.target is absent, when it does not resolve to a combatant in the active combat, or when targetInvalidStatus(...) (against TARGET_INVALID_STATUSESdead / vanquished) reports the target is dead or defeated/surrendered.
  • Range is computed (fvttRangeToTarget) and validated per strike mode (melee reach.effective, missile baseRange.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*Resume executors (the defense-resume path), not through startAutomatedAttack, so the turn gate never blocks them. Automated and assisted combat can still be freely interleaved (a fight may drop to assisted mid-exchange). The outOfTurnAttackReason, attackerBlockingStatus, and targetInvalidStatus predicates are pure and unit-tested.

Entry points

Both converge on one executor, SohlCombatantLogic.startAutomatedAttack:

  1. From a weapon/techniqueStrikeModeBase.automatedCombatStart stuffs the strike mode’s pointerData into context.scope.mode (so only that weapon’s modes are offered) and delegates: combatantLogic.executeAction("automatedCombatStart", context).
  2. From the combat tracker — the combatant’s intrinsic automatedCombatStart action (executor: "startAutomatedAttack", visible: "true", group ESSENTIAL) is injected into the tracker row’s context menu by combat-tracker-hooks.ts, gated on combatant.isOwner.

The exchange, stage by stage

StageDriverWhat happens
1. Attack rollstartAutomatedAttackcommonAttack (shared attack dialog) → buildAttackResultattackResult.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 cardbuildAttackCardDatatemplates/chat/attack-card.hbsEmits all four defense buttons; embeds the serialized AttackResult in the card’s data-scope; addresses the target actor via handlerActorUuid.
3. Defender respondsDefender’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 + resolutionautomated{Block,Dodge,Counterstrike,Ignore}ResumebuildCombatResultCombatResult.evaluate()Builds a DefendResult, composes it with the (already-evaluated) attack into a CombatResult, and runs the opposed test.
5. ImpactCombatResultrollImpactImpact is rolled only when a blow lands (this is where damage dice are rolled), producing an ImpactResult.
6. Combat-result cardbuildCombatCardDataattack-result-card.hbsTwo-column (Attack | Defend) card; one “Calculate Injury” button per landing side.
7. Injuryinjury button → BeingLogic.onCreateInjuryresolveAutomatedInjuryRolls 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-disabled defense.block), defaults to lastBlockMode or the best block ML, optionally dialogs, builds a DefendResult (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 from attackResult.speaker.tokenLogic); reuses commonAttack and 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/):

  1. Addressing. The attack card sets handlerActorUuid to the target actor’s uuid (buildAttackCardData); the buttons carry it as data-handler-actor-uuid.
  2. 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.
  3. Click-time authorization (the real boundary, #167). resolveAuthorizedChatCardHandler (chat-card-dispatch.ts) resolves the handler doc (uuid precedence docUuid → handlerUuid → handlerActorUuid → actionHandlerUuid) and returns it only if isOwner; onChatCardButton re-checks ownership. The button’s data-scope becomes context.scope, so the resume reads context.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 / getterMeaning
groupIdThe 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.
moveFactorGM situational move multiplier (run/sprint/terrain); computedMove() scales the actor’s feetPerRound by it.
displayedMediumWhich 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() / displayedMoveTactical 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:

QueryDefinition
isEnemyOfDifferent non-null groups (above).
alliesThe other combatants sharing this one’s group. Empty when ungrouped.
threatenedByThe 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 are combatants and the caller is the active GM, it dispatches void this.seedCombatantGroups(documents) (fire-and-forget — a combatant’s groupId is not set the instant createEmbeddedDocuments resolves; poll for it in tests).
  • seedCombatantGroups maps each combatant to { id, hasGroup, desiredName: actor.system.defaultCombatGroup ?? null }, then the pure resolveGroupSeeding (combat/logic/combat-logic.ts) plans the distinct groups to create (case-insensitive dedup; default "Opponents"). Missing CombatantGroups are batch-created and each combatant’s group set.

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, combatRoundroundEnd + roundStart, combatTurnturnEnd + turnStart, deleteCombatcombatEnd.
  • updateCombat (src/sohl.ts) captures the new current combatant’s position and resets its per-turn didAction on turn/round change. It is the source of the turn-start location that spacesMovedThisTurn reports.
  • 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):

  1. displayedMedium is not honored by computedMove — it seeds the tracker chip but movement always uses the actor’s active medium (currentMoveMedium).
  2. Weapon break is display-onlyCombatResult.weaponBreakCheck is computed and shown on the card, but no breakage is applied.
  3. The group relations have no internal consumerallies, threatenedBy, isThreatening, and reaches are implemented and unit-tested, but nothing in src/ 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 — the VALUE_DELTA_INFO.OUTNUMBERED info flag and its lang strings exist but are never applied.

See also