ThirdEra

Development reference

This page is the single reference for contributors working in this repository: project overview, architecture, conventions, and operational notes. If you are extending the system (adding compendiums, modules, or new content) rather than changing core code, see Extending first.

Project Overview

ThirdEra is a Foundry VTT game system implementing D&D 3.5 Edition using the System Reference Document (SRD). It targets Foundry VTT v13 and uses the ApplicationV2 / HandlebarsApplicationMixin sheet framework (not the legacy Application v1). Only SRD content is intended to be implemented - no proprietary data outside the SRD. The system is designed so that third-party compendiums or modules can extend it.

There is no build step, bundler, or package manager for the system at runtime. The system is plain ES modules + CSS + Handlebars templates, loaded directly by Foundry VTT at runtime.

Automated unit tests use a dev-only toolchain (package.json + Vitest) for logic that can run in Node. Tests live under test/ and complement in-world checks (F5 reload, optional scenarios under gitignored docs/testing/ when present). Roadmap and planned work are summarized on Future plans.

Published documentation (docs-site) and feature state

The docs-site (this MkDocs tree: development.md, extending.md, usage/*, compendium-guide.md, etc.) is the published description of how the system behaves in releases. It should stay honest about what is implemented vs what is only planned or manual:

Development Setup

Automated unit tests

Prerequisites: Node.js v20+ on your PATH (same major version as .github/workflows/validate.yml). You do not need Foundry running to execute unit tests.

First-time setup (same as CI): From the repository root:

  1. npm ci — installs exact versions from package-lock.json (use npm install instead if you are not using a lockfile-driven workflow locally, but CI always uses npm ci).
  2. make test or npm test — runs Vitest once and exits; must pass before considering related work done.
  3. make lint or npm run lint — runs ESLint on test/**/*.mjs, module/**/*.mjs, thirdera.mjs, and vitest.config.mjs (eslint.config.js; package.json lint passes the same globs so CI matches local); same scope as the Validate workflow lint job.

scripts/: The repo’s scripts/ directory (maintenance scripts, migrations, generators) is not in the Vitest tree and is not included in ESLint — by design, not planned for CI. Validate changes there manually (see docs-site/compendium-guide.md for script-oriented workflows).

Day-to-day: After dependencies are installed, make test or npm test is enough for quick iteration. Use npm run test:watch for interactive re-runs while editing (not used in CI). Before finishing work that touches scoped backend logic, also run make test-coverage so minimum coverage (see below) is satisfied—the same command CI runs. After editing tests, system JavaScript under module/, thirdera.mjs, or vitest.config.mjs, run make lint (or npm run lint) before pushing.

Coverage (same as CI for PRs on scoped files):

Layout: Tests live under test/unit/, grouped to mirror code: test/unit/logic/module/logic/, test/unit/utils/module/utils/, test/unit/data/module/data/ helpers. Use the *.test.mjs (or *.spec.mjs) suffix. test/README.md lists which production files are covered and which are intentionally out of scope for Node (Foundry-only). The system’s esmodules entry is unchanged; node_modules/ is dev-only and gitignored.

Policy: Changes to behavioral logic under module/logic/, module/utils/, and pure module/data/*_helpers.mjs should include new or updated tests where practical, make test must pass, make test-coverage must pass including coverage thresholds, and make lint must pass (see test/README.md at the repository root for scope and exceptions). make lint covers the paths in step 3; clear reported issues when you touch those files, and use dedicated PRs for broad ESLint cleanup when the backlog is large. Pull request validation uses the Validate workflow: unit-tests (npm ci + make test), coverage (npm ci + make test-coverage), lint (npm ci + npm run lint), and static-validation (JSON, node --check on .mjs, template paths).

Architecture

Entry Point

thirdera.mjs - Registers all data models, document classes, sheet classes, Handlebars helpers, and partials in Hooks.once("init"). Also registers sidebar delete button hooks. Global config: CONFIG.THIRDERA.

The Hooks.once("ready") path is tuned to keep the browser responsive on large worlds and packs: compendium index/img fix-up rebuilds directory trees in small batches with main-thread yields between chunks; condition status metadata and the domain-spell compendium cache load in parallel; GM compendium JSON import yields between packs; Phase 6 NPC sense migration and HP-derived condition sync use bounded parallelism where safe. For timing breakdowns when debugging slow reloads, enable the client setting Log client bootstrap timing (debug) under Configure Settings → Third Era (console output only).

Data Models (module/data/)

Each file defines a TypeDataModel subclass with static defineSchema() using Foundry field classes (NumberField, StringField, SchemaField, HTMLField, etc.).

Derived data (ability modifiers, save totals, grapple, initiative) is calculated in prepareDerivedData() on the data models, not on document classes.

Document Classes (module/documents/)

Sheet Classes (module/sheets/)

Both use ApplicationV2: HandlebarsApplicationMixin(ActorSheetV2) / HandlebarsApplicationMixin(ItemSheetV2).

Tab switching is manual via a changeTab action (not Foundry's built-in tab system).

Templates (templates/)

Context comes from _prepareContext(); item lists are pre-sorted in the sheet's _prepareItems(). For NPC actors, embedded skills in sheet context are de-duplicated by skill identity (npc-embedded-skill-identity.mjs: same system.key for most skills; profession uses key + name). ThirdEraActor.createEmbeddedDocuments also drops duplicate skill payloads so imports/drops cannot stack identical rows.

Handlebars Helpers

Registered in thirdera.mjs:registerHandlebarsHelpers(): abilityMod(score), signedNumber(num), eq(a, b), concat(...args).

Styles

styles/thirdera.css - All selectors under .thirdera. ProseMirror: .active/.inactive for edit/display; --editor-min-height custom property.

Design tokens (on .thirdera): Use only these - no new hardcoded colors or font sizes.

Localization

lang/en.json - THIRDERA.* namespace; also TYPES.Item.* and TYPES.Actor.* for Foundry type labels.

Reference Resources

Key Conventions

Item references

All references between items (and any membership or “do you have this?” checks) must use document identity — ID or UUID — not name, key, or other string labels.

Rationale: Names and keys can change or collide; IDs and UUIDs are stable and unique. Compendium documents are identified by UUID; world documents have id (and uuid). Using ID/UUID keeps world and compendium references consistent and avoids ambiguity when the same “logical” item exists in multiple packs or the world.

Examples:

Exception: Where the codebase explicitly uses a stable key (e.g. system.key) for compendium name-based matching (e.g. loader “match by key when name collides”), that remains a separate concern. The key is still not used as the canonical reference between items for “which feat is required” or “does the actor have this?” — those use ID/UUID. Keys may be used to resolve “which document is Dodge?” when building UUID references (e.g. in migration or authoring), but the stored reference is the document’s id/UUID.

Release migrations

Every meaningful change must consider upgrade from the previous release version. Migration work is part of development, not a post-release cleanup task.

Modifier system (module/logic/modifier-aggregation.mjs)

A unified modifier pipeline aggregates contributions from conditions, feats, race, equipped items, and future sources into one modifier bag per actor. Extensions, magic-item categories, and sequencing for non-numeric traits are summarized on Future plans.

Extending the modifier system: You can add new modifier sources in two ways.

  1. Register a provider function. Push a function to CONFIG.THIRDERA.modifierSourceProviders. Each provider has signature (actor) => Array<{ label: string, changes: Array<{ key: string, value: number, label?: string }> }>. The aggregator runs all providers and merges contributions; only keys in the canonical set (see CONFIG.THIRDERA.modifierKeys) are applied. Example: a module that adds temporary spell effects could register a provider that returns one contribution per active spell with a changes array.
  2. Item method getModifierChanges(actor). On any item type, implement getModifierChanges(actor) returning { applies: boolean, changes: Array<{ key: string, value: number, label?: string }> }. The built-in item provider in the registry iterates actor.items and, when this method exists, calls it; when applies === true, it merges the returned changes with label = item.name. No change to the aggregator is required. (Note: the stock itemsModifierProvider today reads system.changes directly for feat, race, and gear items; getModifierChanges is the documented extension hook for custom item types.) Items can use optional system.changes (same shape) and rely on the default item provider’s type-specific “applies” rules (feat and race: always when owned; armor, equipment, weapon: when equipped/wielded by default). Gear apply scope (Phase 5g): armor, equipment, and weapon items may set system.mechanicalApplyScope to equipped (default) or carried. The same predicate gates GMS numeric modifiers and embedded-item CGS grants (module/logic/item-gear-mechanical-apply.mjs, embeddedGearMechanicalEffectsApply).

Capability grants (structured effects, parallel to the modifier system)

In-world and sheet naming: Character and item sheets use the label Capability grants for structured contributions that are not numeric modifier rows (e.g. senses and vision lines, condition-driven suppression, spell-like grants, extra creature types and subtypes for shapechanging and template-style effects). Internally the codebase may still refer to the Capability Grant System (CGS) in logic modules and maintainer docs; contributors should keep numeric bonuses in Mechanical effects (system.changes) and structured non-numeric effects in system.cgsGrants where applicable.

CGS — supported in core today

The engine merges grants from items, conditions, race, class features, actor-level CGS mechanics, and (for NPCs) stat-block inputs where applicable; refresh propagates when sources change. Today this includes:

CGS — not in core yet (planned, deferred, or GM/manual only)

These areas may have data and UI in ThirdEra but no built-in automation, or are explicitly out of scope for the current v1 overlay design:

When any item above moves from “not yet” to “supported,” update this subsection and any linked usage pages in the same effort.

Initiative and combat

Rest and natural healing

Compendiums (packs/ and module/logic/compendium-loader.mjs)

See Compendium guide for the full guide.

Foundry VTT v13 - Critical Technical Notes

Spell cast chat (message flags: save, concentration, spell penetration)

When a character casts a spell, the posted chat message includes flags.thirdera.spellCast with:

Field Purpose
dc Save DC (number)
saveType "fort" | "ref" | "will" | null
spellName, spellUuid Display and document reference
targetActorUuids Optional: UUIDs of targeted actors when casting with tokens selected
spellLevel Spell level (0–9) for this cast
classItemId Embedded class item id on the caster
casterLevel Caster level for that class at cast time
srKey Raw spell resistance setting from the spell item (system.spellResistance); drives cast-chat spell penetration when spellAllowsPenetrationRoll is true

Spell resistance helpers (module/logic/spell-resistance-helpers.mjs): getActorSpellResistance(actor) returns the numeric SR for NPCs (system.statBlock.spellResistance) and characters (system.details.spellResistance), otherwise 0. spellAllowsPenetrationRoll(srKey) is true only for yes and yes-harmless. For yes-harmless, willingness and “harmless” are adjudicated at the table: the GM skips or ignores the roll when SR does not apply.

Spell penetration roll: ThirdEraActor#rollSpellPenetration({ casterLevel, spellResistance, label }) (module/documents/actor.mjs) rolls 1d20 + casterLevel (CL truncated; non-finite CL → 0) against the given SR (truncated, non-negative). SRD: the check succeeds if the total meets or exceeds SR. Chat flavor shows CL bonus, “vs SR N”, and Success / Failure (same styling as spell saves). Invalid non-numeric spellResistance aborts with a notification and returns null.

Spell penetration from cast chat (module/logic/spell-sr-from-chat.mjs): When srKey allows automation and casterLevel is a finite number, the owner of the message speaker (or a GM) sees penetration controls. If targetActorUuids includes at least one actor with SR > 0, one button per such target (Spell penetration vs Name (SR N)) rolls immediately vs that target’s current SR. Otherwise a single Spell penetration… button opens a dialog listing character and NPC actors with SR > 0 that the user may observe (GMs see all). Right‑click the message → Roll spell penetration… opens the same target picker. The roll always uses the speaker as the caster (rollSpellPenetration on that actor).

Legacy cast messages and caster level: Chat messages created before spellCast.casterLevel was stored, or any message where casterLevel is missing or not a finite number, do not show spell penetration automation (the SR line in the card may still display for reference). casterLevel may be 0 (finite); in that case controls still appear and the roll uses 1d20 + 0. There is no separate combat-track integration—same cast-chat behavior whether or not a combat is active.

The Roll save button and context menu (module/logic/spell-save-from-chat.mjs) use dc, saveType, and targetActorUuids. Concentration (module/logic/concentration-from-chat.mjs) uses spellLevel and the message speaker as the caster: Concentration (defensive) rolls vs DC 15 + spell level (see module/logic/concentration-dcs.mjs); Concentration (other)… opens a dialog for damage-based DC (10 + damage + spell level) or a custom DC. Right‑click the message → Roll Concentration… opens the same dialog. Only the owner of the speaker actor (or a GM) sees these controls. Rolls use ThirdEraActor#rollConcentrationCheck (module/documents/actor.mjs), which requires a Concentration skill item or a modifier-only Concentration entry. Extenders can rely on this flag shape when adding custom spell-cast messages or tools that react to spell casts.

Contents

  • Compendium guide
  • Future plans
  • 🡐 Development