Extending the app
How-to guide. Read Architecture first for the split between pure modules and DOM glue.
For each common kind of change, the steps name the files to edit in order
and the test file that fails when a step is missing. Run that test file alone while you work, for example
node --test tests/ConditionEffects.test.js, and run pnpm run typecheck
at the end.
A condition with a rule
A condition is a free string on a creature or a character, so the GM can add any name. A condition has a rule only when it has a row in the rules table.
- Add the name to the pick-list
CONDITIONSinsrc/entities/Conditions.js, so the condition menu offers it. - Add a row to
CONDITION_EFFECTSinsrc/entities/ConditionEffects.js. The key is the lower-case name, becauseconditionKeylower-cases a chip before the lookup. TheConditionEffecttypedef above the table lists each field, such asattacks,attacksAgainst,checks,saveAdvantage,noActions, andnoTurn. The roll sites read the table throughrollModeand the other helpers in the same file, so a new row needs no change at the roll sites. - Add cases to
tests/ConditionEffects.test.jsfor each field that the row sets.tests/Conditions.test.jscovers the pick-list.
A class feature
Adding a class mechanic covers this change. It has one section for each kind of feature: a name on the sheet, a value that scales with the class level, a pool of uses, a use spent on a turn, and a pick at level up.
A spell effect kind
A spell has one effect, and effect.kind picks the code that resolves it.
The kinds are attack, save, heal, buff, summons, and utility.
- Add the kind to the
SpellEffectunion insrc/types/spell.ts, with the fields that the kind needs. - Add the kind to
SPELL_EFFECT_KINDSinsrc/data/spells.js. - Add a row to
SPELL_KINDSinsrc/entities/SpellKinds.js. The row gives the label of the spell form’s Effect select, and thehelpsandtargetFreeflags that the cast dialog reads. The table is typed as a record over every kind, so the typecheck fails when the row is missing. - Add a branch for the kind in
src/entities/Casting.js, beside theeffect.kind === 'heal'branch and the others, to say what the cast rolls. - Write the outcome in
src/app/spellOutcomes.js, and add the fields of the kind to the spell form insrc/ui/SpellForm.js. - Run
tests/SpellKinds.test.js, which checks that the key order ofSPELL_KINDSmatchesSPELL_EFFECT_KINDS, and add cases for the new branch totests/Casting.test.js.
A sidebar panel
A panel follows the panel contract in
UI components: a
mount<Name>(container, callbacks) function that returns { update }. A
list of rows gets that contract from mountListPanel in src/ui/listPanel.js.
- Add the container element to
index.html, inside the tab that shows the panel. - Write the panel in
src/ui/. Keep the rules for its rows in a pure module undersrc/view/or the matching area, sonode --testcovers them. - Mount the panel in the wiring module of its feature area, and store the
handle on
app.views, asstoryWiring.jsdoes withapp.views.questPanel. Add the view name to theAppViewsinterface insrc/types/app.ts. - When the panel shows campaign state, add the view to the list in
rehydrateCampaign(src/app/rehydrate.js). A panel left out of that list keeps stale rows after another tab saves. - Check the panel in the browser, in the light and dark themes and at
phone width. Testing a change
shows how.
tests/mountOrder.test.jsfails when the new wiring changes the mount order that other modules depend on.
A campaign field
A campaign field is a top-level value of the save, such as quests or
clock.
- Add the field to
CampaignStateinsrc/types/storage.ts. - Add an entry to
BY_KEYinsrc/storage/CampaignFields.js. The entry gives the value that a save without the field reads as (empty), the coercion that a load runs on the stored value (coerce), and the name that an Undo or Redo toast uses (label). The table is typed againstCampaignState, so the typecheck fails when the entry is missing. - Put a coercion for a new record type in
src/storage/RecordCoercion.js, so a hand-edited import with a bad value loads as the empty value.
The save, the load, and the cross-tab sync read the field list from
CAMPAIGN_FIELDS, so they need no change. tests/CampaignFields.test.js
checks the table. Add a round trip case for the new field there, and a
coercion case to tests/LoadCoercion.test.js.