Adding a class mechanic
How-to guide. For the character model behind these steps, read Entities.
The app has no registry of class mechanics. A class feature that does
something reads the class id or the feature name at the place where it
applies. For example, ClassPools.js asks for level('fighter'), and
TurnActions.hasCunningAction asks for classLevelOf(character, 'rogue').
To add a mechanic, find the kind below that matches it and follow its steps.
A mechanic can need more than one kind. Rage, for example, is a pool, a turn
action, and a condition chip.
Each kind puts its rule in a pure module under src/entities/ or
src/combat/, so node --test covers it. Run the test file named in each
section while you work, and run pnpm run test and pnpm run typecheck
before you commit.
Show a feature on the sheet
- Add the feature name to
featuresByLevelof its class insrc/data/classCatalog.js, at the class level that grants it. The Sorcerer, Warlock, and Wizard entries are insrc/data/arcaneClasses.js. - Leave it as a plain string when the app does not model it. The sheet then lists it as text, and the GM applies it at the table.
LevelUp.unlockedFeatures collects every name that the class levels of a
character reach. The level-up toast names the new ones.
Scale a value with the class level
Use this kind for a number that grows with the level, such as Extra Attack or the dice of Sneak Attack.
- Add the feature name to
featuresByLevel, as above. - Read it in
src/entities/Features.jswithhasFeatureorfeatureSource, and return the number.attacksPerActionandsneakAttackDiceare the models. - Call the new reader where the value applies, for example in
combat/WeaponSwing.jsfor an attack. - Test it in
tests/Features.test.js.
The app derives the value on each read, so a save needs no new field.
Count uses in a pool
Use this kind for a feature with a number of uses per rest, such as Second Wind, Rage, or ki.
- Add an id constant in
src/entities/PoolIds.js, and add it toCLASS_POOL_IDSin the order that the sheet shows the pools. - Add a rule to
POOL_RULESinsrc/entities/ClassPools.js, in the same order. Theusesfunction getslevel(classId)andmod(ability), and returns{ max, recharge }. Thetierhelper reads a table of[from level, count]pairs. Amaxof 0 gives no pool. - Test the counts in
tests/ClassPools.test.js. The test “the pools come out in the order of CLASS_POOL_IDS” fails when the two lists disagree.
Progression.derive runs syncClassPools on every write that can change a
level, so the pool appears, grows, and goes away with the class. A loaded
save gains the pool the same way, so no migration is needed. A rest refills
the pool by its recharge.
Spend a use on a turn
Use this kind for a pool that the character spends from the combat action bar.
- Add a button to
turnActionsinsrc/combat/TurnActions.js. Give it the pool id aspoolId, an actioncost(or null), and agrouplabel. Test it intests/TurnActions.test.js. - Pass the uses left from
turnActionsOfinsrc/app/turnActionsWiring.js, withusesOf(found.entity, YOUR_POOL_ID). - Add the effect of the press to
useClassActionin the same file. The function already checks the uses left and spends the action cost and one use. Write a log line throughapp.actions.logEvent. - Cover the press in
tests/turnActionsWiring.test.js.
A class with several options on one pool can build its buttons in its own
module. combat/ChannelDivinity.js does this with channelActions, and
turnActionsOf appends that list.
Ask for a pick at level up
Use this kind for a choice that the player makes once, such as Expertise or a Fighting Style.
- Write the feature in
featuresByLevelas an object,{ name, effects }(ClassFeatureDefinsrc/types/class.ts). The effects use the feat effect vocabulary ofsrc/types/feat.ts. TheEXPERTISEandfightingStyleFeatureconstants insrc/data/classParts.jsare the models. - Read the pick back where it applies.
entities/FightingStyle.jsreads the style ids fromcharacter.featureChoices, for example. - Test the grant in
tests/FeatureGrants.test.js.
FeatureGrants.js treats an unlocked feature with effects and no record in
featureChoices as pending. LevelAssignFlow.js asks for each pending pick
when the class level is assigned, and the sheet offers the same pick later
for a character that skipped it. Undo of the grant gives back what it added.
A pick that the feat effect vocabulary cannot express needs its own flow.
The warlock picks are the model. LevelAssignFlow.js calls
askWarlockPicks when classId === 'warlock', and
LevelAssign.hasChoiceAt stops the donor path from moving a class level
that invocations or a Mystic Arcanum claim. Add a branch in both places
for the new class.