Extension Points (Developer Guide)

Audience: Developers maintaining or extending SoHL. Goal: Identify the safest places to add features with minimal risk.

See also: Architecture Overview, House Rules Cookbook, Security Model.

Choosing extension scope

  • Use a Module hook for additive behavior without modifying SoHL source — best for house rules that affect many items or actors.
  • Use actions (context-menu entries on a document) for single-item behavior overrides (e.g., one specific spell). A Script Action can also override an intrinsic action on that one document by reusing its shortcode — see Overriding an intrinsic action.

Lifecycle hooks are emitted with item-type granularity (sohl.<itemType>.<stage>). Filter by item.system.shortcode inside the handler for narrower targeting.

When a hook performs persistent side effects, guard it with a world-setting toggle and a GM-only check, so the rule is opt-in per world and runs under a single authority. See the worked recipe in House Rules Cookbook — guard pattern.

Golden rule

Extend by adding new classes and registering them, rather than editing core logic in-place — see the architectural rules and extension mechanisms.

When in doubt:

  1. Find the nearest existing example.
  2. Replicate the pattern.
  3. Change the minimum.

1) System initialization & registration

Primary files: src/sohl.ts, src/core/logic/SohlSystem.ts

Common extension needs: register new settings, sheets, hooks, or document classes.

Guidelines: Keep registration logic explicit and centralized. Avoid side-effect imports.

Registration runs in Hooks.once("init") (settings, system config, calendars, hooks, combat/time defaults, sheet registration), then Hooks.once("ready") (Handlebars helpers, then SohlSystem.ready = true). Read src/sohl.ts for the authoritative order, and add new registration alongside the existing calls there and in SohlSystem.

2) Actor and Item type extension

Actors

Core actor classes: src/document/actor/ with base SohlActor in foundry/.

The per-type split into Document, DataModel, Logic, and Sheet classes — and how they relate — is covered by the three-layer architecture and three-class pattern; read those rather than re-deriving them here.

How to extend: Add logic class in src/document/actor/logic/, data model + sheet in src/document/actor/foundry/, register in SohlSystem.

Items

Core item classes: src/document/item/ with base SohlItem in foundry/.

Same layering as actors.

Worked example (new Item kind):

  1. Add the kind constant + metadata in src/utils/constants.ts (ITEM_KIND, *_METADATA).
  2. Create the logic class in src/document/item/logic/ and the data model + sheet in src/document/item/foundry/.
  3. Register the data model / logic / sheet in SohlSystem.
  4. Add templates and localization keys.
  5. Verify fromData(...), drag/drop, sheet rendering, and context-menu behavior resolve; validate startup.

This is the canonical “add a type” procedure — the Runtime Contracts safe-extension checklist points here.

Avoid: Adding if type === X branches in base classes; prefer polymorphism.

Overriding a Logic class from a module (runtime)

The steps above add a kind in-system. A variant module instead overrides an existing kind’s Logic class at runtime — no source edits. The base classes are exposed on the sohl global as sohl.actorLogicClasses / sohl.itemLogicClasses (kind → base class), and registerActorLogic / registerItemLogic swap the class used to build every document of that kind. The resolution path (SohlDataModel.create) already reads that registry, so no construction sites change.

Register during the module’s init/setup hook, before the first .logic for that kind is built:

Hooks.once("setup", () => {
  class MyBeing extends sohl.actorLogicClasses.being {
    // override rules here
  }
  sohl.registerActorLogic("being", MyBeing);
});

Every being actor prepared afterward uses MyBeing. registerItemLogic(kind, cls) is the item-side equivalent, keyed by ITEM_KIND.

3) Combat / tests / resolution pipeline

Core components:

  • src/entity/result/* — test and combat result classes
  • src/entity/modifier/* — value tracking and modification
  • src/document/combatant/ — combatant tracking
  • src/entity/action/SohlActionContext.ts — request context

Safe extension:

  • Add a graded / special-result test as data — see the recipe below — not a new class.
  • Add new *Modifier types for new influences.
  • Keep results serializable for chat/UI.

High-risk:

  • Changing shared modifier interpretation rules
  • Changing success thresholds or resolution order

Adding a graded / special-result test — pass data, don’t subclass

The most common “new test” need is a d100-vs-mastery-level roll that reports a bespoke set of outcomes and optionally offers a follow-up action — a Stumble (“Keeps Footing” / “Stumbles”), a Fumble (“Retains Grip” / “Drops It”), a Shock test, a Fear test. None of these is a new class. Do not subclass SuccessTestResult, and do not write a bespoke result card. Drive the single, well-tested generic path — successTest — and supply everything bespoke as data in the action scope:

  • scope.resultDescTable — a result-description table (LimitedDescription[]) that maps each success rung to its label / description / star count. This is the bespoke result text, carried as serializable data.
  • scope.targetValueFunc (optional) — remaps the value the outcome grades against when the test keys off something other than the raw constrained mastery level (e.g. the success-value tests use index + successLevel - 1).
  • scope.priorTestResult (optional) — reuse an already-rolled result instead of rolling fresh (Fate, GM edits, opposed resume). The die is not re-rolled; see the prior-result seam.

Because you drove the generic path, the test inherits impairment/fatigue gating, Fate eligibility, priorTestResult reconstruction, and standard-card rendering with no extra code. A subclass re-implements all of that and drifts from the one path everyone else fixes bugs in.

Follow-up consent buttons ride the standard card. When a graded result should offer an action (apply the shock state, record a healing rate), you no longer need a bespoke postActionCard template. Post the standard card yourself and hand it buttons — one ActionCardButton or an array:

// Roll the generic test but don't auto-post (`noChat`), then post with a button.
const result = await mlMod.successTest(
  new SohlActionContext({
    speaker,
    scope: { resultDescTable: keepControlTable(winner), noChat: true },
  }),
);
if (result) {
  await result.toChat({
    buttons: {
      action: "applyStumble",
      handlerUuid: this.uuid,
      scope: { priorTestResult: result },
      label: sohl.i18n.localize("SOHL.Being.Stumble.apply"),
      iconFAClass: "fa-solid fa-person-falling",
    },
  });
}

toChat folds buttons through the same toRenderableButtons normalizer the action-card framework uses (scope pre-serialized, skipDialog defaulted), so each button carries the well-known action-card-button handles and dispatches through the shared chat-card chokepoint. Nothing auto-fires — the button is offered, and the target’s controlling player accepts (the consent model). See the toChat card-data contract.

When you do subclass. Reserve a SuccessTestResult subclass for a test whose roll math genuinely differs — a different die, a multi-roll resolution, a non-threshold outcome. New result text or a new follow-up button is never, by itself, a reason to subclass. AttackResult / OpposedTestResult are the existing examples of a legitimately different resolution.

See Combat Resolution Pipeline and Modifier Model.

4) Active effects

Core: SohlActiveEffect.

SoHL extends Foundry’s ActiveEffect with an expanded targeting model (targetType / targetName) so one effect can target self, the owning actor, or sibling items by type. See Active Effects for the model and Effects Integration for the full reference.

5) UI: templates and chat cards

  • Chat cards: templates/chat/*
  • Dialogs: templates/dialog/*
  • Actor/item sheets: templates/actor/*, templates/item/*

Safe extension: Add new templates rather than overloading existing ones. Keep template context objects stable and well-documented.

Adding a chat-card button

Chat-card buttons (inside .card-buttons) and a.edit-action links are routed by the renderChatMessageHTML hook in sohl.ts, which resolves the handler document from the clicked element’s dataset and invokes its onChatCardButton(btn) (or onChatCardEditAction). The dataset attribute precedence is a contract — see Chat-card dispatch contract.

When adding a new button: emit one of the recognized dataset attributes (do not introduce a new attribute name), and add an action case to the resolved document’s handler — e.g. SohlItem.onChatCardButton (see SohlItem), which switches on btn.dataset.action.

To pass data to the action — a result, a request object — do not invent a new data-*-json attribute; put the whole payload in one data-scope. The card-creation logic sets a scope field (scopeData: defaultToJSON(scope)) and the template renders data-scope="{{toJSON scopeData}}" (the {{toJSON}} helper stringifies it); the handler revives it with buildActionScope, so the action case reads live objects off context.scope (e.g. context.scope.attackResult), never a JSON string. See Chat-card dispatch contract — Passing scope and the Entity serialization contract.

Cross-actor effects (the acknowledge-button pattern)

Enforce actor state sovereignty: an actor mutates only itself. To make one actor affect another, never reach into the target — instead:

  1. Resolve the source side on the source. Roll the attack / spell / effect test against the source’s own modifiers and mutate only the source.
  2. Emit a target-addressed button. Post a chat card whose button carries the target actor’s uuid in data-handler-actor-uuid (or data-handler-uuid) and an action. The label must make the consequence unmistakable (“Acknowledge you fall asleep”).
  3. Apply on the target’s client. In the target’s onChatCardButton action case, run any required test first (e.g. a resistance roll), then mutate this actor.

Render-time gating makes the button appear only to the responding actor’s owner (the GM owns all). gateAutomatedDefenseButtons (src/document/chat/chat-card-gating.ts) is the reference: it removes a button whose data-handler-actor-uuid actor the current user does not own (actor.isOwner). Reuse this gating for any new target-addressed button.

Working examples already in the tree: automated-combat defense buttons (resolve on the defender’s client) and the createInjury / “Calculate Injury” button (the target wounds itself). Model new mechanics — spells, conditions, knockback, afflictions — on these; do not add a code path where the source writes the target’s state.

6) Localization

  • lang/en.json

Rules:

  • Never rename keys (breaks translations and downstream consumers).
  • Add new keys; deprecate old keys slowly if needed.

7) System registries

src/core/logic/SohlSystem.ts — the central registry for CONFIG mappings.

Rules:

  • Keep mappings explicit.
  • Avoid runtime reflection-based wiring.
  • Validate registrations early during init.

8) Event triggers (time, combat, scene-region)

The event queue (sohl.events) dispatches named triggers, and a subscription runs a document action when one fires — the deferred half of the consent model. Extension surfaces:

  • Subscribe an action to a trigger from a Logic class’s finalize() (sohl.events.subscribe({ uuid, actionName, triggerName, predicate })) — the built-in updateWorldTime / combat-lifecycle triggers, plus the scene-region and environment triggers (regionTokenEnter/Exit/Turn*/Round*, sceneDarknessChange — issue #593). Region triggers are event-driven: no fireAt, so nextFireTime is undefined and system.lastRun is the temporal query.
  • The trigger RegionBehavior (foundry) is the GM opt-in surface: dropped on a region, it forwards curated events into the queue (GM-gated, once) and can offer a region-authored action. High-frequency streams (tokenMove*) are excluded by curation.
  • Register a custom trigger (registerSohlTrigger + fireSohlTrigger) to add your own lifecycle moment.

All Foundry hook wiring lives in SohlHookBridge — do not call Hooks.on(...) elsewhere for dispatch. See the Event Queue Reference.

9) Calendar registration

SoHL keeps a registry of calendars that modules can extend; registered calendars appear in the GM’s calendar settings. The registry API (SohlSystem.registerCalendar / unregisterCalendar / getCalendar / applyCalendar / calendars), how to register from a module, and the JSON import format are documented in the Calendar Reference.

10) Create-dialog archetypes (flags.sohl.docArchetype)

The shared Create dialog (sohlCreateDialog, used by both SohlActor and SohlItem) offers an Archetype picker that seeds a new document from an existing, fully-populated one — so a new Being is born with body, attributes, and movement instead of blank. Archetypes are data, not code: no source change is needed to add one.

The contract. Flag any Actor/Item — in a compendium pack or in the world — with flags.sohl.docArchetype = <priority:number> and it becomes an archetype for its (type, subType) in the picker. The value is a numeric priority (see below); a non-numeric marker is ignored.

Identity is the shortcode, not the name. After the (type, subType) filter, an archetype’s system.shortcode is its identity. Two candidates sharing a shortcode are the same logical archetype even if their names differ (a localization, or a diverged world copy) and are deduped to one winner; the name is presentation only. Keep a shortcode stable and meaningful — same spirit as the stable-lang-key rule.

Winner selection (only matters on a shortcode collision). Among candidates sharing a shortcode, the winner is chosen by priority descending, then source tier ascending (world 0 < system 1 < module 2), then a stable UUID. So a GM’s world copy shadows a shipped system archetype at equal priority (no priority fiddling needed), and a module must ship priority > 0 to override a system archetype (a module left at 0 loses by tier — it cannot silently clobber a stock archetype). New archetypes with fresh shortcodes always appear regardless of priority. SoHL ships its stock archetypes at priority 0.

The Foundry-free discovery/resolution helper. The rules above live in the Foundry-free archetype module and are unit-tested independently of any dialog: resolveArchetypes filters by (type, subType), dedups by shortcode, and returns winners sorted best-first; buildArchetypeOptions turns those into UUID-valued <option>s (labelled Name (shortcode)) plus (none), defaulting to the top winner (or (none) when the type has no archetype). The Foundry boundary that gathers candidates from the world directory and every matching compendium pack is fvttDiscoverArchetypes in FoundryHelpers.ts.

Archetype-first defaulting (Name/Shortcode). The dialog is laid out Type → SubType → Archetype → Name → Shortcode, with Name and Shortcode optional. Selecting an archetype pre-fills Name and Shortcode from its own name / system.shortcode (live, until you type into a field); leaving them blank creates a document that matches the archetype — its shortcode is the archetype’s, subject only to uniqueness bumping (broadsword, broadsword2, …). Choosing (none) keeps the blank-slate behavior: Name defaults to the class defaultName and the Shortcode derives from the Name. The resolution rules are the Foundry-free, unit-tested resolveCreateIdentity (the dialog only wires the DOM and applies uniqueShortcode against the taken set).

Shortcode uniqueness (create + update). (type, shortcode) is a unique lookup key, enforced at runtime across world / embedded / pack scopes — see Shortcode Integrity. The Create dialog live-checks the entered shortcode against that scope and disables Create until it is unique (the _preCreate reject is the backstop). A programmatic caller opts into automatic key management with the shortcodeDedupe: true create/update option — a colliding code is suffixed and a name-less create gets a random id, so it never fails; without it, a collision is rejected. System-generated item creation (fvttCreateEmbeddedItems, cross-actor gear drops) opts in; the human dialog stays strict so the author picks a unique code deliberately.

Instantiation strips the flag; copy-verbatim preserves it. flags.sohl.docArchetype is removed at every point where an archetype is instantiated into a live document, and kept only when a document is copied as a library entry. The single stripping primitive is the pure stripDocArchetypeFlag (it deletes exactly that one key; other flags.sohl.* are legitimate inherited data).

  • Strip — Create dialog seeding from an archetype: a document created from an archetype is not itself an archetype.
  • Strip — Drop-to-embed: dragging a compendium or world item onto an actor/item sheet creates an embedded, in-play child — never a template. The strip lives in SohlActorSheetBase._onDropItem, immediately before createEmbeddedDocuments("Item", …), so the flag never rides onto the owner (an embedded item that kept it would pollute discovery and could be re-instantiated as if it were a template).
  • Preserve — Import and Duplicate of a top-level directory document: these are copy-verbatim operations that yield another library document; preserving the flag is exactly how a GM makes a world-tier override.

Never move the strip into _preCreate. That hook runs for every create — dialog, drop-embed, directory Import, and Duplicate alike — so it cannot distinguish instantiation from copy-verbatim. If the strip ever migrates there, the world-override workflow silently breaks (Import/Duplicate would lose the flag). The strip must stay at the specific instantiation entry points (dialog + drop), and both sides are guarded by tests.

See Module Development → Archetypes for the module-author recipe.

What to update when you add something

  • New actor/item type: Add class, register, add templates, update JSDoc, update docs.
  • New user-facing workflow: Add or update the user guide under assets/packs/journals/_source/ (compiled into Foundry journal entries at build).

Deep dives